93 changed files with 9569 additions and 214 deletions
+25 -4
View File
@@ -23,10 +23,10 @@ jobs:
args: --bundles nsis,msi
- name: macOS Apple Silicon
os: macos-15
args: --target aarch64-apple-darwin --bundles dmg
args: --target aarch64-apple-darwin --bundles app,dmg
- name: macOS Intel
os: macos-15-intel
args: --target x86_64-apple-darwin --bundles dmg
args: --target x86_64-apple-darwin --bundles app,dmg
runs-on: ${{ matrix.os }}
@@ -45,7 +45,26 @@ jobs:
- run: npm ci
- run: npm test
- run: cargo test --locked --manifest-path src-tauri/Cargo.toml
- run: npm run tauri:build -- ${{ matrix.args }}
# These are build/test artifacts. Release signing happens separately with
# the operator-held key; CI never receives that key or publishes stable.json.
- run: npm run tauri:build -- --config scripts/tauri-unsigned.json ${{ matrix.args }}
- name: Prepare unsigned macOS updater archive
if: runner.os == 'macOS'
run: |
python3 - <<'PY'
import json
from pathlib import Path
import tarfile
apps = list(Path('src-tauri/target').glob('*/release/bundle/macos/*.app'))
if len(apps) != 1:
raise SystemExit('Expected exactly one macOS application bundle')
app = apps[0]
version = json.loads(Path('src-tauri/tauri.conf.json').read_text())['version']
architecture = app.parts[2].split('-')[0]
archive = app.parent / f'ShaCraft.Launcher_{version}_{architecture}.app.tar.gz'
with tarfile.open(archive, 'w:gz') as output:
output.add(app, arcname=app.name)
PY
- name: Upload Windows installers
if: runner.os == 'Windows'
uses: actions/upload-artifact@v4
@@ -70,4 +89,6 @@ jobs:
with:
name: shacraft-launcher-${{ matrix.name == 'macOS Apple Silicon' && 'macos-arm64' || 'macos-x64' }}
if-no-files-found: error
path: src-tauri/target/*/release/bundle/dmg/*.dmg
path: |
src-tauri/target/*/release/bundle/dmg/*.dmg
src-tauri/target/*/release/bundle/macos/*.app.tar.gz
+24
View File
@@ -13,6 +13,30 @@ concurrency:
cancel-in-progress: true
jobs:
publisher:
# Ubuntu 22.04 has no minisign package. Keep the desktop compatibility
# check there, but exercise real signature verification on Ubuntu 24.04.
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- run: sudo apt-get update && sudo apt-get install -y minisign
- run: python3 -m unittest discover -s scripts -p 'test_*.py'
admission-client:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '25'
- name: Check the Minigames admission companion
working-directory: admission-client
run: ./gradlew test build --no-daemon
- uses: actions/upload-artifact@v4
with:
name: shacraft-admission-client
if-no-files-found: error
path: admission-client/build/libs/shacraft-admission-client-0.1.0.jar
check:
runs-on: ubuntu-22.04
steps:
+4
View File
@@ -10,3 +10,7 @@ src-tauri/gen/
*.p12
*.pfx
*.sig
admission-client/.gradle/
admission-client/build/
graphify-out/
+155 -14
View File
@@ -4,8 +4,9 @@
Cross-platform desktop launcher for the ShaCraft Minecraft network. It is a
Tauri 2 application: React/Vite is the UI and Rust owns all filesystem,
network and process-adjacent work. The current production profile is
**Aeronautics** (Minecraft 1.21.1, NeoForge 21.1.248, Java 21).
network and process-adjacent work. Profiles are **Aeronautics** (Minecraft 1.21.1, NeoForge 21.1.248, Java 21)
and **Minigames** (Minecraft 26.2, Fabric 0.19.5, Java 25). See the Minigames
integration record below and `docs/release-0.1.6.md` for publication evidence.
This repository owns the launcher only. The server-side API and published
payload are in `/root/shacraft` on the ShaCraft host; see
@@ -21,8 +22,9 @@ payload are in `/root/shacraft` on the ShaCraft host; see
## Trust model
- The only supported remote profile manifest endpoint is
`https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest`.
- The only supported remote profile manifest endpoints are the fixed
`https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest` and
`https://shacraft.ru/api/launcher/v2/profiles/minigames/signed-manifest`.
The read-only Aeronautics player-count endpoint
`https://shacraft.ru/api/online/aoc` is also hardcoded in `remote.rs`; it
is display-only and is never allowed to influence downloads or launching.
@@ -41,11 +43,62 @@ payload are in `/root/shacraft` on the ShaCraft host; see
control a URL in any of those domains.
- ShaCraft accounts: `src-tauri/src/shacraft_account.rs` talks only to the
hardcoded `https://shacraft.ru` origin. Passwords are never persisted. The
revocable session token is stored locally with mode 600 on Unix. At launch,
the nickname is fetched from the verified `aoc` account link; the legacy
nickname in `settings.json` is ignored as an identity source. Server-side
whitelist enforcement and LoginSystem remain the final access-control
boundary, including for old launcher versions.
revocable session token is stored locally with mode 600 on Unix and never
passed to Java. The new admission flow uses fixed POST endpoints
`/api/launcher/v2/admission/nickname` and
`/api/launcher/v2/admission/tickets`; redirects are rejected. Neither the
manifest nor the webview can select their origin or URL.
- Free nicknames are claimed directly for the authenticated account. Existing
player names are reserved server-side and assigned by an administrator;
deleting a website account must not make an existing player's name free.
- After installation, `admission.rs` generates an ephemeral Ed25519 key with
the OS CSPRNG. The backend binds its public key to a one-use ticket, current
account session, canonical `aoc` nickname and server access. Only that
returned nickname selects launch identity; `settings.json` is never a fallback.
Ticket and PKCS#8 private key go only in the final Java child's environment
(`SHACRAFT_ADMISSION_TICKET`, `SHACRAFT_ADMISSION_PRIVATE_KEY`). Never put
them in global environment, argv/argfiles, settings, logs or IPC. A fresh
launch obtains a fresh ticket; there is no shared launcher secret.
- Once admission is enforced on Aeronautics, its server mod verifies the
challenge/proof before world entry, and server-side whitelist enforcement
remains a final access boundary. Replace LoginSystem only as part of the
validated `aoc` rollout; other servers are unaffected. Old launchers without
admission proof will be rejected after enforcement. This authenticates an
account's permission, not the integrity of an unmodified launcher binary.
- Application updates are a separate trust domain from Minecraft profiles.
The only channel is `https://shacraft.ru/launcher/updates/stable.json`.
Both release metadata and the installer need a valid Minisign signature
under the separate updater public key embedded in `tauri.conf.json`.
Never reuse the profile signing key, accept unsigned metadata, or allow IPC
to select an update URL, key, version, installer argument or destination.
Downloads require HTTPS without redirects on exact `shacraft.ru`, below
`/downloads/shacraft-launcher/<signed-version>/`, and are bounded to 256 MiB.
Stable versions must increase. The native layer owns every candidate.
- Linux self-update supports AppImage and an installed `sha-craft-launcher`
deb. AppImage preserves executable permissions and uses same-directory
atomic replacement after verification. Deb selects only the signed
`linux-x86_64-deb` entry; retain legacy `linux-x86_64` AppImage metadata for
installed 0.1.3 clients. Never silently switch installation formats.
- Deb elevation uses only `/usr/bin/pkexec --disable-internal-agent` and the
root-owned installed `/usr/bin/shacraft-launcher --shacraft-install-deb`.
This early helper mode never starts GTK/Tauri or account/network code. It
receives bounded metadata/package bytes over stdin, not paths or commands,
and re-verifies both signatures with the embedded key as root. Before the
fixed dpkg installation, require exact package name, architecture and signed
version, root-only staging and monotonic installed-package version; pass
`--refuse-downgrade` to dpkg to close concurrent-update races. No system
password collection, sudo fallback or permissive polkit policy is allowed.
Cancellation, denied authorization and a busy package manager stay distinct.
Windows/macOS use the pinned Tauri installer implementation; the vendored
updater change only exposes construction from already verified metadata to
avoid a second, unbounded remote JSON request. See its patch notes.
Installation holds game/install/account permits until restart. The game
permit lasts until the tracked Java child exits. These are process-local
guards; another launcher process is not a cross-process lock.
- The updater signing private key stays outside Git on the operator's local
machine; CI receives no production key. Publish only verified packages,
public signatures and signed feed. Updater signatures are separate from
Windows Authenticode and macOS code signing/notarization.
## Layout
@@ -60,7 +113,8 @@ payload are in `/root/shacraft` on the ShaCraft host; see
for account/game/host/preferences/profiles. Unsigned sync/inspect IPC was
removed; only verified remote manifests may drive profile mutations.
- `operations.rs` — process-local install/account permits owned by workers.
Launch must use the authenticated ShaCraft nickname; no settings fallback.
The account permit covers ticket issuance through Java spawn, preventing
local logout/account switching from racing that handoff.
- `storage.rs` — unique same-directory atomic writes, owner-only Unix files.
- `trusted_http.rs` — HTTPS and exact-host redirect policy per game provider.
- `download.rs` — shared verified-download helper (temp file, hash,
@@ -76,8 +130,15 @@ payload are in `/root/shacraft` on the ShaCraft host; see
placeholder pending ShaCraft's own Azure AD app registration and
Minecraft-API approval.
- `shacraft_account.rs` — local ShaCraft login/registration, session and
verified nickname-link API.
- `launch.rs` — builds and spawns the actual `java` process.
nickname claim/admission API; legacy link commands remain for compatibility.
- `admission.rs` — ephemeral key generation, strict ticket response validation,
canonical identity and child-only admission environment.
- `launch.rs` — builds and spawns the actual `java` process; admission secrets
must remain outside its argument substitution and JVM argfile paths.
- `updater.rs`, `commands/updater.rs` — authenticated release metadata,
bounded package download, platform installation and guarded restart.
- `deb_updater.rs` — installed-package checks, one system authentication
prompt and the bounded, signature-verifying non-GUI root helper.
- `src-tauri/src/settings.rs` — durable local preferences; maintain backward
compatibility with already-written JSON.
- `docs/manifest-v1.md` — signed manifest envelope and payload contract
@@ -85,8 +146,9 @@ payload are in `/root/shacraft` on the ShaCraft host; see
- `docs/game-trust-boundary.md` — the Mojang/NeoForge/Microsoft/Adoptium
trust domains used to install and run the game itself.
- `.github/workflows/check.yml` — push/PR UI checks and Linux Rust tests.
- `.github/workflows/build.yml` — main-push/manual cross-platform builds with artifacts;
not a signed release or updater publication.
- `.github/workflows/build.yml` — main-push/manual cross-platform CI artifacts;
updater signing is explicitly disabled there. Local release signing and
atomic feed publication are documented in `docs/launcher-updates.md`.
## Verification
@@ -105,6 +167,14 @@ not prove Tauri commands work.
See `PLAN.md` for known gaps. Never label browser preview or unit tests as
a successful cold game install / Microsoft OAuth / Windows/macOS beta test.
The admission implementation has local unit/UI checks and unsigned Linux
0.1.2 AppImage/deb packages built on Ubuntu 26.04. Older Ubuntu compatibility
has not been tested. The backend/mod rollout is active on Aeronautics as of
2026-09-10. Real isolated NeoForge admission tests and a production rejection
without the mod passed; these do not certify a full production modpack join.
The server has a required early duplicate-login guard so unauthenticated
connections cannot evict an already-online UUID. The client mod pins the actual
socket to `135.106.154.86:25567`. See architecture and server rollout records.
## Working conventions
@@ -114,3 +184,74 @@ a successful cold game install / Microsoft OAuth / Windows/macOS beta test.
them. Inspect `git status` before staging.
- Update this file and `docs/launcher-architecture.md` whenever the trust
model, endpoint contract, storage layout, or release workflow changes.
## Cross-platform release 0.1.5 (2026-09-10)
Published Windows x64 EXE/MSI, macOS aarch64 and x86_64 DMG/app.tar.gz,
and Linux x64 AppImage/DEB on https://shacraft.ru/help#launcher. The signed
stable feed includes all platforms plus the legacy/exact Linux aliases.
CI source b43fc610c43a9ec9f5f3ffce601de9604670ff8a, successful Actions run
https://github.com/emil28092005/shacraft-launcher/actions/runs/34512683651.
An initial non-Linux borrow/move compilation error in updater target selection
was fixed before the final build. Native tests: Windows70, macOS74 per arch,
Linux84; UI tests pass on all four runners. Local checks verify macOS bundle
version/CPU type, Linux package identity and every artifact signature. Public
HTTPS downloads of all eight artifacts match local SHA-256. Website418 tests
plus48 subtests pass; only backend recreated, game containers unchanged.
Updater signatures use the existing operator-held key, never uploaded to CI or
server. Windows installers have no Authenticode signature; macOS is not Apple
notarized. Native CI tests and packaging do not certify full Minecraft installs
or desktop updater/restart behavior on Windows/macOS. Older unsupported clients
need a manual installation of the current release. Previous releases immutable.
## Admission client menu 0.1.1 (2026-09-11)
The signed Aeronautics payload now contains admission mod 0.1.1 at the existing
managed path `mods/shacraft-admission-0.1.0.jar` to prevent duplicate mod IDs on
upgrade. SHA-256: `faa9ae13cb0f2d93c03dae26ab36ae20d3fb6b66c89c09254b16808d6b183f89`.
From the title screen (and vanilla safety acknowledgement), Multiplayer connects
to fixed `135.106.154.86:25567`. Cancel/errors return to TitleScreen; transitions
from other screens do not auto-connect. Client-only registration, protocol 1 and
one-use admission remain unchanged. Running game server was not restarted.
Linux Java 21 build and 8 mod tests pass. An opt-in native live test verifies
signed-manifest retrieval and download/repair/restoration of the admission jar
only in a temporary directory. Mac 0.1.5 connection failure remains unclassified
pending exact error/log; this is not a verified macOS fix or desktop UI test.
## Minigames integration (2026-09-13, published in 0.1.6)
- Native profile mapping is fixed: aeronautics → aoc; minigames → minigames.
Both display/claim the canonical existing aoc nickname. The backend enforces
the current shared aoc subscription and whitelist for Minigames as well.
Ticket requests and responses remain bound to the selected server; never
accept an aoc ticket as a Minigames ticket.
- `fabric.rs` adds independent exact HTTPS domains `meta.fabricmc.net` and
`maven.fabricmc.net`. It verifies profile identity/parent/main class, bounded
metadata, portable Maven coordinates, hashes and sizes before the existing
atomic library installer. Unknown loaders now fail manifest validation.
- Minigames launches with a native-owned Quick Play endpoint
`135.106.154.86:25568`. The manifest cannot choose a game destination.
- `admission-client/` owns the small client-only Fabric 26.2 companion. Its
configuration-phase proof uses `minigames` in the existing Ed25519 transcript,
verifies the actual socket, canonical nickname and nonce, and signs once per
process. Only a fresh launch can retry a consumed ticket. Java receives only
the ephemeral ticket and private key in its child environment, never the
website session/password. Keep server verification on Paper before world
entry with an early duplicate UUID guard.
- The standalone Paper admission adapter and backend remain server-project
responsibilities. Do not put map/SMASH source into this launcher repository.
- Production updater publication requires a separately built, monotonically
newer launcher release and existing operator signatures. Source tests or a
client jar alone do not update installed 0.1.5 launchers.
Launcher 0.1.6 was built from `799fa692` on `codex/launcher-updater`; divergent
`main` remains unchanged. All four native CI builds, UI/native checks and 18
publisher signature tests pass. The unchanged Fabric companion completed a
real Minecraft 26.2 → Paper configuration handshake with a synthetic account;
its CI artifact is byte-identical. All eight public packages and the stable
feed were signed with the existing local updater key and verified through
public HTTPS downloads. See `docs/release-0.1.6.md` and its committed receipt.
This records successful release/admission verification, not a Windows/macOS
cold install or OS signing/notarization certification.
+102 -5
View File
@@ -17,16 +17,113 @@
approval и живой OAuth-тест. Текущий запуск использует ShaCraft identity.
- [ ] Cold install / repair / update / game exit на чистых Windows/Linux/macOS.
Unit tests и web preview не заменяют эти прогоны.
- [ ] Подписанные installer-релизы и подписанное автообновление лаунчера.
- [ ] Windows Authenticode / macOS signing-notarization и проверка установщиков.
- [ ] Реальная отмена загрузок, журнал с редактированием токенов и retry UX.
- [ ] Выбор каталога профиля и безопасный reset только managed-файлов.
- [ ] Keychain-хранилище refresh token; cross-process exclusion при необходимости.
- [ ] Динамический каталог и новости; реальный Aeronautics онлайн уже
загружается через фиксированный display-only API. Не имитировать данные.
## Вход по одноразовому разрешению: внедрение 2026-09-10
- [x] Новый native claim закрепляет свободный ник за аккаунтом без входа в игру.
Старые имена резервируются backend и переносятся администратором.
- [x] После установки Rust создаёт Ed25519 ключ через OS CSPRNG и запрашивает
ticket на фиксированном ShaCraft API. Ник для запуска берётся из ticket;
локальные настройки не могут его заменить.
- [x] Ticket и PKCS#8 private key передаются только окружением дочерней Java;
session сайта остаётся в native. В argv, argfile, settings, логах и IPC
секретов нет. Account permit держится до spawn.
- [x] Ошибки и результаты привязки показаны в диалоге; двойной клик не создаёт
параллельных запросов, старого polling нет.
- [x] Локально пройдены 64 Rust-теста (5 live-тестов пропущены), 22 UI-теста,
TypeScript/Vite и сборка Linux x86-64 `tauri:build -- --no-bundle`.
Шесть браузерных сценариев используют только замоканный Tauri IPC.
- [x] Собраны неподписанные AppImage и deb версии 0.1.2 на Ubuntu 26.04;
проверены извлечение AppImage и metadata deb. Работа на старых Ubuntu и
установка пакетов на чистой машине этим не подтверждены.
- [x] Выложены backend, подписанный клиентский мод и серверный мод Aeronautics;
сервер перезапущен и healthy. Whitelist сохранён; LoginSystem заменён только
на `aoc`. Пакеты Linux 0.1.2 опубликованы на сайте, AppImage запущен локально.
- [x] Настоящий изолированный NeoForge: новый ticket допускает в мир, отсутствие
мода/билета и повтор билета отклоняются. Старый LoginSystem на клиенте не мешает.
Подключение дубликата без авторизации не выбивает уже играющего владельца.
- [x] На публичном сервере подключение без мода отклонено до входа в мир;
HTTPS verifier из контейнера, подпись manifest и SHA-256 мода проверены.
Backend Docker: 404 tests + 48 subtests, включая истечение, отзыв session,
чужую identity, whitelist, резервирование имён и атомарное погашение.
- [x] Полный вход в production Aeronautics через установленный лаунчер
подтверждён пользователем 2026-09-10: «Присоединился!». Это пользовательское
подтверждение, а не автоматизированный cold-install тест.
- [ ] Проверить cold install и этот протокол на Windows/macOS, выпустить
подписанные пакеты. Локальная Linux-сборка не подтверждает эти платформы.
- [ ] Удобное переподключение: сейчас использованный или истёкший ticket
требует нового запуска игры из лаунчера; автоматического обновления нет.
## Самообновление лаунчера 0.1.3: реализация 2026-09-10
- [x] Отдельный канал обновления приложения на фиксированном HTTPS endpoint.
Выделенный публичный ключ проверяет подпись metadata и пакета; версия,
заметки и URL связаны подписью. Redirect, downgrade и произвольные пути
из webview запрещены; размеры metadata и загрузки ограничены.
- [x] Vendored Tauri updater 2.11.0 принимает уже проверенный JSON через
`check_metadata` без второго HTTP-запроса. На Linux AppImage заменяется
атомарно через временный файл в том же каталоге с проверкой подписи и fsync.
- [x] Проверка при старте без автоматической установки; доступны уведомление
о новой версии, ручная проверка, заметки, прогресс, ошибки, повторная попытка
и явный перезапуск. Сбой проверки не блокирует установленный лаунчер.
- [x] Native guards исключают обновление во время игры и конфликтующих
операций. На время установки и до перезапуска заблокированы запуск игры,
ремонт сборки и изменения аккаунта. Для deb и development binary показано
сообщение об установке вручную; версия 0.1.2 требует первого ручного обновления.
- [x] Пройдены 76 Rust-тестов, 28 UI-тестов и 11 тестов publisher;
TypeScript/Vite успешно собраны. Девять браузерных сценариев с mock Tauri IPC
проверяют обновление, ошибки, повтор, блокировки и восстановление состояния;
внешняя сеть и реальные аккаунты в этих сценариях не используются.
- [x] Живой native-прогон скачал 0.1.3 с production HTTPS: повреждение
отклонено без изменения старого файла, подлинный пакет атомарно заменил
временную копию 0.1.2. Исходный AppImage сохранён, хеши проверены.
- [x] Опубликованы Linux 0.1.3 и подписанный stable feed; HTTPS 200,
`Cache-Control: no-store`, подписи и хеши проверены. Ссылка на сайте обновлена.
AppImage установлен в `~/Applications`, добавлен ярлык и проверен запуск.
- [ ] Полный GUI-цикл «Обновить → Перезапустить» проверить на следующем
релизе; текущий прогон проверяет native-установку и запуск пакета отдельно.
- [ ] Проверить установку и самообновление Windows/macOS перед публикацией
пакетов этих платформ; Authenticode/notarization остаются отдельными задачами.
## Обновление DEB 0.1.4: 2026-09-10
- [x] Отдельный подписанный deb entry, определение установленного формата
и сохранение AppImage-совместимости для клиентов 0.1.3.
- [x] Одно системное подтверждение через pkexec. Root helper до запуска GUI
повторно проверяет подписи и точные Package/Version/Architecture, получает
только bounded bytes через stdin и устанавливает пакет из root-only staging.
Пароль не попадает в лаунчер; отмена не открывает запасные окна авторизации.
- [x] Dpkg отказывается от downgrade и сохраняет системную блокировку пакетов.
Ошибки частичной установки не маскируются; автоматических повторов нет.
Read-only inspection имеет ограничение вывода и времени для группы процессов;
работающий dpkg не прерывается таймаутом посреди изменения пакета.
- [x] 32 UI-теста, TypeScript/Vite и 12 браузерных сценариев с mock IPC;
18 publisher-тестов с настоящими minisign и deb, включая переход feed 0.1.3.
- [x] 84 native-теста прошли. Реальный root-helper в изолированном Ubuntu 26.04
прошёл 10 сценариев: установка подписанного 0.1.4, повреждения, неверные
права/временный каталог, занятый dpkg, повтор и защита от downgrade.
Dpkg подтвердил версию, тестовый маркер профиля сохранён. GUI-подтверждение
PolicyKit этим контейнерным прогоном не проверялось; отмена покрыта UI/unit.
- [x] Опубликованы подписанные AppImage/deb 0.1.4, проверены байты feed и deb,
обе кнопки сайта и системная инструкция. Backend: 404 tests + 48 subtests,
Ruff clean. Minecraft не перезапускался, данные аккаунтов не менялись.
Deb 0.1.3 и ниже требуют первого ручного обновления до 0.1.4.
## Связанные серверные риски
Серверный план находится в `/root/shacraft/PLAN.md`. Важные следующие шаги:
одноразовое подтверждение ника внутри игры (NoGravity не связывает игрока
с веб-запросом), enforcement реферальных правил и очередь повторов whitelist.
Не менять этот протокол незаметно в клиентском рефакторинге.
Серверный план находится в `/root/shacraft/PLAN.md`. Для admission обязательны
атомарная одноразовая проверка ticket, привязка к текущим session/аккаунту/нику,
проверка до входа в мир и сохранение whitelist как серверного ограничения.
Старые игровые имена нельзя отдавать первому зарегистрировавшемуся; их
резервирование и назначение аккаунтам — отдельный этап миграции. Старый
NoGravity challenge-поток должен быть закрыт при включённом admission, чтобы
он не обходил резервирование имени. Остальные серверные задачи включают
enforcement реферальных правил и очередь повторов whitelist. Не менять
протокол незаметно в клиентском рефакторинге и не считать ticket доказательством
неизменённости клиентского бинарника.
+24 -6
View File
@@ -3,13 +3,15 @@
Кроссплатформенный Tauri 2 лаунчер для [ShaCraft](https://shacraft.ru/):
React/TypeScript интерфейс, Rust — файлы, сеть и запуск процессов.
Реализованы подписанная синхронизация Aeronautics, проверка/восстановление
модов и конфигурации, Java discovery/provisioning, bootstrap Minecraft и
NeoForge, настройки памяти и ника, обработка установки/запуска/выхода.
Реализованы отдельные профили Aeronautics (Minecraft 1.21.1, NeoForge, Java 21)
и Minigames (Minecraft 26.2, Fabric, Java 25), подписанная синхронизация файлов,
проверка/восстановление модов и конфигурации, установка игры и Java, настройки
памяти, обработка установки/запуска/выхода. Minigames опубликован в 0.1.6;
проверки и результаты публикации — в [записи релиза](docs/release-0.1.6.md).
Вход выполняется через аккаунт ShaCraft — тот же, что на сайте. Игровой ник
берётся только из подтверждённой привязки Aeronautics, а не из редактируемых
локальных настроек. Пароли не сохраняются; сессию можно отозвать.
общий для обоих профилей и берётся только из подтверждённой привязки `aoc`.
Тикет входа привязан к выбранному серверу; локальные настройки не выбирают личность. Пароли не сохраняются; сессию можно отозвать.
Microsoft OAuth-модуль сохранён отдельно, но не используется текущим
сценарием запуска; для его активации потребуются client ID и API approval.
@@ -37,14 +39,30 @@ cargo test --locked --manifest-path src-tauri/Cargo.toml
Build включает строгий TypeScript. GitHub Actions проверяет UI и Rust на
push/PR; workflow на main-push/ручном запуске собирает Windows x64, Linux x64, macOS Intel
и Apple Silicon и сохраняет артефакты. Подпись релиза/автообновления ещё впереди.
и Apple Silicon и сохраняет неподписанные артефакты. Релизный оператор
подписывает проверенные пакеты и metadata локальным ключом; CI его не получает.
Используемые macOS runners соответствуют [списку GitHub](https://docs.github.com/en/actions/reference/runners/github-hosted-runners).
## Обновление лаунчера
С версии 0.1.3 настройки содержат проверку обновлений, установку с прогрессом
и перезапуск. Лаунчер также проверяет новые версии при старте, но устанавливает
их только по кнопке. Подписи пакета и сведений о версии обязательны.
Закройте запущенный Minecraft перед установкой обновления.
В Linux обновляются AppImage и установленный deb (с версии 0.1.4).
Для deb система запрашивает права администратора; пароль не передаётся лаунчеру.
Deb 0.1.3 и ниже нужно один раз обновить вручную до 0.1.4. Dev-бинарник
обновляется вручную. С версии 0.1.2 нужен ручной переход на новый AppImage.
Пакеты Windows/macOS 0.1.5 опубликованы. Проверка реальной установки на каждой
платформе остаётся отдельной от сборки и автоматических тестов.
## Навигация
- [Архитектура](docs/launcher-architecture.md) — компоненты, данные, IPC.
- [Trust boundaries](docs/game-trust-boundary.md) — доверенные источники игры.
- [Manifest](docs/manifest-v1.md) — подписанный контракт модпака.
- [Обновления](docs/launcher-updates.md) — подпись, публикация и восстановление.
- [PLAN.md](PLAN.md) — ограничения и следующие шаги.
- [AGENTS.md](AGENTS.md) — инструкции для следующего разработчика/агента.
+47
View File
@@ -0,0 +1,47 @@
# ShaCraft Minigames admission client
Client-only Fabric companion for Minecraft 26.2, Java 25, Fabric Loader 0.19.5
and Fabric API 0.160.0+26.2. This is account admission, not an anti-cheat or a
proof that the original launcher binary is running.
Build with Java 25: `./gradlew test build --no-daemon`.
Output: `build/libs/shacraft-admission-client-0.1.0.jar`.
Publish that jar and the pinned Fabric API jar in the signed Minigames profile.
The native launcher supplies `SHACRAFT_ADMISSION_TICKET` and
`SHACRAFT_ADMISSION_PRIVATE_KEY` only to the final Java child environment.
The client accepts a CONFIGURATION payload on `shacraft_admission:challenge`
with three Minecraft UTF strings: server ID (16), nickname (16), nonce (43).
It verifies server `minigames`, the exact current game nickname and actual
socket `135.106.154.86:25568`, then signs once with the ephemeral Ed25519 key.
The response on `shacraft_admission:proof` contains ticket (43) and standard
Base64 signature (88). The transcript has no final newline:
```
shacraft-admission-v1
{ticket_id}
minigames
{mc_username}
{nonce}
```
The private key and website session never go onto the Minecraft wire. Errors
are redacted. A fresh game launch is needed for another connection after a
proof has been sent. `SHACRAFT_ADMISSION_ALLOW_LOOPBACK=1` additionally permits
literal loopback sockets for isolated tests; normal releases do not set it.
Paper must fail closed before world entry and reject unauthenticated duplicate
UUIDs before the vanilla duplicate-player eviction. The backend checks the
current shared aoc account access and atomically redeems the server-bound ticket.
Three unit tests cover exact signature binding, invalid/cross-server fields
and socket allowlisting. The unchanged production companion also passed actual Minecraft 26.2
configuration negotiation and entered a local Paper lobby with a synthetic
backend ticket; see [receipt](../docs/verification/minigames-fabric-2026-09-13.json).
The public server and other platforms still require their own rollout checks.
The client explicitly advertises its single challenge receiver with vanilla
`minecraft:register` at the start of configuration. Fabric normally waits for
the server's registration first, while Paper gates plugin sends on that client
advertisement. This bootstrap uses the pinned Fabric API's RegistrationPayload;
update it and rerun live negotiation checks when upgrading Fabric API. It is
queued after INIT so vanilla has switched outbound protocol to CONFIGURATION.
+37
View File
@@ -0,0 +1,37 @@
plugins {
id 'net.fabricmc.fabric-loom' version "${loom_version}"
}
repositories { mavenCentral() }
loom {
splitEnvironmentSourceSets()
mods {
'shacraft_admission' {
sourceSet sourceSets.main
sourceSet sourceSets.client
}
}
}
dependencies {
minecraft "com.mojang:minecraft:${project.minecraft_version}"
implementation "net.fabricmc:fabric-loader:${project.loader_version}"
implementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}"
testImplementation platform('org.junit:junit-bom:5.12.2')
testImplementation 'org.junit.jupiter:junit-jupiter'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
processResources {
inputs.property 'version', project.version
filesMatching('fabric.mod.json') { expand version: project.version }
}
tasks.withType(JavaCompile).configureEach { options.release = 25 }
java { toolchain.languageVersion = JavaLanguageVersion.of(25); withSourcesJar() }
test { useJUnitPlatform() }
// Pure HTTP/validation tests use the same client implementation without launching Minecraft.
sourceSets.test.compileClasspath += sourceSets.client.output
sourceSets.test.runtimeClasspath += sourceSets.client.output
+9
View File
@@ -0,0 +1,9 @@
org.gradle.jvmargs=-Xmx2G
org.gradle.parallel=false
org.gradle.configuration-cache=false
minecraft_version=26.2
loader_version=0.19.5
loom_version=1.17.20
fabric_api_version=0.160.0+26.2
version=0.1.0
group=ru.shacraft
Binary file not shown.
@@ -0,0 +1,8 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
distributionSha256Sum=bafc141b619ad6350fd975fc903156dd5c151998cc8b058e8c1044ab5f7b031f
networkTimeout=30000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+82
View File
@@ -0,0 +1,82 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute Gradle
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
+8
View File
@@ -0,0 +1,8 @@
pluginManagement {
repositories {
maven { url = 'https://maven.fabricmc.net/' }
mavenCentral()
gradlePluginPortal()
}
}
rootProject.name = 'shacraft-admission-client'
@@ -0,0 +1,57 @@
package ru.shacraft.admission;
import java.net.InetSocketAddress;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.networking.v1.ClientConfigurationNetworking;
import net.fabricmc.fabric.api.client.networking.v1.ClientConfigurationConnectionEvents;
import net.fabricmc.fabric.impl.networking.RegistrationPayload;
import net.minecraft.network.protocol.common.ServerboundCustomPayloadPacket;
import java.util.List;
import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry;
import net.minecraft.network.chat.Component;
/** The account session remains in the native launcher, never in Minecraft. */
public final class ClientAdmission implements ClientModInitializer {
private static final AtomicBoolean USED = new AtomicBoolean();
@Override public void onInitializeClient() {
PayloadTypeRegistry.clientboundConfiguration().register(AdmissionPayloads.Challenge.TYPE, AdmissionPayloads.Challenge.CODEC);
PayloadTypeRegistry.serverboundConfiguration().register(AdmissionPayloads.Proof.TYPE, AdmissionPayloads.Proof.CODEC);
ClientConfigurationNetworking.registerGlobalReceiver(AdmissionPayloads.Challenge.TYPE, ClientAdmission::challenge);
// Paper waits for vanilla channel advertisement, while Fabric normally waits
// for the server's registration first. Bootstrap our one fixed receiver.
// INIT runs in the listener constructor; schedule() queues until after vanilla
// has switched the outbound protocol from LOGIN to CONFIGURATION.
ClientConfigurationConnectionEvents.INIT.register((listener, client) -> client.schedule(() ->
listener.send(new ServerboundCustomPayloadPacket(new RegistrationPayload(
RegistrationPayload.REGISTER, List.of(AdmissionPayloads.Challenge.TYPE.id()))))));
}
private static void challenge(AdmissionPayloads.Challenge challenge, ClientConfigurationNetworking.Context context) {
var connection = context.packetContext().orElseThrow(net.fabricmc.fabric.api.networking.v1.context.PacketContext.CONNECTION);
boolean loopback = "1".equals(System.getenv("SHACRAFT_ADMISSION_ALLOW_LOOPBACK"));
if (!(connection.getRemoteAddress() instanceof InetSocketAddress remote)
|| remote.getAddress() == null
|| !AdmissionProof.allowedTarget(remote.getAddress().getHostAddress(), remote.getPort(), loopback)
|| !AdmissionProof.SERVER_ID.equals(challenge.serverId())
|| !context.client().getUser().getName().equals(challenge.nickname())
|| !AdmissionProof.validOpaque(challenge.nonce())) {
deny(context); return;
}
String ticket = System.getenv("SHACRAFT_ADMISSION_TICKET");
String privateKey = System.getenv("SHACRAFT_ADMISSION_PRIVATE_KEY");
if (!AdmissionProof.validOpaque(ticket) || !USED.compareAndSet(false, true)) {
deny(context); return;
}
try {
String signature = AdmissionProof.sign(privateKey, ticket, challenge.serverId(), challenge.nickname(), challenge.nonce());
context.responseSender().sendPacket(new AdmissionPayloads.Proof(ticket, signature));
} catch (Exception invalidKey) { deny(context); }
}
private static void deny(ClientConfigurationNetworking.Context context) {
context.responseSender().disconnect(Component.literal(
"Не удалось подтвердить вход ShaCraft. Закройте игру и запустите её заново через ShaCraft Launcher."));
}
}
@@ -0,0 +1,28 @@
package ru.shacraft.admission;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
public final class AdmissionPayloads {
private AdmissionPayloads() {}
public record Challenge(String serverId, String nickname, String nonce) implements CustomPacketPayload {
public static final Type<Challenge> TYPE = new Type<>(Identifier.fromNamespaceAndPath("shacraft_admission", "challenge"));
public static final StreamCodec<FriendlyByteBuf, Challenge> CODEC = StreamCodec.of(
(buffer, value) -> { buffer.writeUtf(value.serverId, 16); buffer.writeUtf(value.nickname, 16); buffer.writeUtf(value.nonce, 43); },
buffer -> new Challenge(buffer.readUtf(16), buffer.readUtf(16), buffer.readUtf(43)));
@Override public Type<Challenge> type() { return TYPE; }
@Override public String toString() { return "AdmissionChallenge[redacted]"; }
}
public record Proof(String ticket, String signature) implements CustomPacketPayload {
public static final Type<Proof> TYPE = new Type<>(Identifier.fromNamespaceAndPath("shacraft_admission", "proof"));
public static final StreamCodec<FriendlyByteBuf, Proof> CODEC = StreamCodec.of(
(buffer, value) -> { buffer.writeUtf(value.ticket, 43); buffer.writeUtf(value.signature, 88); },
buffer -> new Proof(buffer.readUtf(43), buffer.readUtf(88)));
@Override public Type<Proof> type() { return TYPE; }
@Override public String toString() { return "AdmissionProof[redacted]"; }
}
}
@@ -0,0 +1,67 @@
package ru.shacraft.admission;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Base64;
import java.util.regex.Pattern;
/** The only client credential is an ephemeral private key supplied by the launcher. */
public final class AdmissionProof {
public static final String SERVER_ID = "minigames";
private static final Pattern OPAQUE = Pattern.compile("[A-Za-z0-9_-]{43}");
private static final Pattern NICKNAME = Pattern.compile("[A-Za-z0-9_]{3,16}");
private AdmissionProof() {}
public static boolean validOpaque(String value) {
return value != null && OPAQUE.matcher(value).matches();
}
public static byte[] transcript(String ticket, String serverId, String nickname, String nonce) {
if (!validOpaque(ticket) || !validOpaque(nonce) || !SERVER_ID.equals(serverId)
|| nickname == null || !NICKNAME.matcher(nickname).matches()) {
throw new IllegalArgumentException("Invalid admission challenge");
}
return ("shacraft-admission-v1\n" + ticket + "\n" + serverId + "\n"
+ nickname + "\n" + nonce).getBytes(StandardCharsets.UTF_8);
}
public static String sign(String encodedPrivateKey, String ticket, String serverId,
String nickname, String nonce) throws Exception {
if (encodedPrivateKey == null || encodedPrivateKey.length() > 256) {
throw new IllegalArgumentException("Missing admission key");
}
byte[] encoded = Base64.getDecoder().decode(encodedPrivateKey);
try {
PrivateKey key = KeyFactory.getInstance("Ed25519")
.generatePrivate(new PKCS8EncodedKeySpec(encoded));
Signature signer = Signature.getInstance("Ed25519");
signer.initSign(key);
signer.update(transcript(ticket, serverId, nickname, nonce));
return Base64.getEncoder().encodeToString(signer.sign());
} finally {
java.util.Arrays.fill(encoded, (byte) 0);
}
}
public static boolean validSignature(String value) {
if (value == null || value.length() != 88) return false;
try {
byte[] decoded = Base64.getDecoder().decode(value);
return decoded.length == 64 && Base64.getEncoder().encodeToString(decoded).equals(value);
} catch (IllegalArgumentException invalid) {
return false;
}
}
public static boolean allowedTarget(String host, int port, boolean allowLoopback) {
if (host == null) return false;
if (allowLoopback && (host.equals("127.0.0.1") || host.equals("::1") || host.equals("[::1]") || host.equals("0:0:0:0:0:0:0:1"))) {
return port > 0 && port <= 65535;
}
return port == 25568 && (host.equalsIgnoreCase("shacraft.ru") || host.equals("135.106.154.86"));
}
}
@@ -0,0 +1,10 @@
{
"schemaVersion": 1,
"id": "shacraft_admission",
"version": "${version}",
"name": "ShaCraft Minigames Admission",
"description": "Account-bound admission to ShaCraft Minigames.",
"environment": "client",
"entrypoints": { "client": ["ru.shacraft.admission.ClientAdmission"] },
"depends": { "fabricloader": ">=0.19.5", "minecraft": "26.2", "java": ">=25", "fabric-networking-api-v1": "*" }
}
@@ -0,0 +1,32 @@
package ru.shacraft.admission;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.charset.StandardCharsets;
import java.security.KeyPairGenerator;
import java.security.Signature;
import java.util.Base64;
import org.junit.jupiter.api.Test;
class AdmissionProofTest {
private static final String TICKET = "A".repeat(43), NONCE = "B".repeat(43);
@Test void signsExactServerBoundTranscript() throws Exception {
var pair=KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
String signed=AdmissionProof.sign(Base64.getEncoder().encodeToString(pair.getPrivate().getEncoded()), TICKET,"minigames","Pilot_1",NONCE);
var verifier=Signature.getInstance("Ed25519"); verifier.initVerify(pair.getPublic());
verifier.update(("shacraft-admission-v1\n"+TICKET+"\nminigames\nPilot_1\n"+NONCE).getBytes(StandardCharsets.UTF_8));
assertTrue(verifier.verify(Base64.getDecoder().decode(signed)));
verifier.update(AdmissionProof.transcript(TICKET,"minigames","Other",NONCE));
assertFalse(verifier.verify(Base64.getDecoder().decode(signed)));
}
@Test void rejectsCrossServerAndMalformedFields() {
for (String[] v:new String[][]{{TICKET,"aoc","Pilot",NONCE},{TICKET,"minigames","Bad\nName",NONCE},{"bad","minigames","Pilot",NONCE},{TICKET,"minigames","Pilot","bad"}})
assertThrows(IllegalArgumentException.class,()->AdmissionProof.transcript(v[0],v[1],v[2],v[3]));
}
@Test void trustsOnlyMinigamesSocketAndExplicitLocalTests() {
assertTrue(AdmissionProof.allowedTarget("135.106.154.86",25568,false));
assertFalse(AdmissionProof.allowedTarget("135.106.154.86",25567,false));
assertFalse(AdmissionProof.allowedTarget("127.0.0.1",25568,false));
assertTrue(AdmissionProof.allowedTarget("127.0.0.1",25570,true));
assertFalse(AdmissionProof.allowedTarget("attacker.invalid",25568,true));
}
}
+18
View File
@@ -90,3 +90,21 @@ URL — cannot redirect a download to an attacker-controlled host in any of
these domains. When adding a new game-related download, verify its host is
one of the ones above (or add a new hardcoded constant following the same
pattern) rather than accepting a URL from anywhere else.
## 5. Fabric (`fabric.rs`, Minigames)
Metadata comes only from `https://meta.fabricmc.net/v2/versions/loader/` for
manifest-selected, validated version identifiers. Profile ID, parent and
KnotClient main class must match. At most 512 KiB metadata and 32 libraries
are accepted. Library URLs are constructed only below
`https://maven.fabricmc.net/` from portable three-part Maven coordinates;
other metadata origins are rejected. Libraries require a 40-hex SHA-1 and a
positive size up to 64 MiB. When Fabric metadata omits either for its loader
jar, the fixed Maven's `.sha1` sidecar and HEAD provide them. Existing verified
downloads perform the hash/size check and atomic rename. The ShaCraft signed
manifest cannot select Fabric metadata URLs, repositories or launch targets.
Minecraft 26.2's official version metadata requires Java 25. `runtime.rs`
already provisions a separate Adoptium Java 25 runtime without changing the
Aeronautics Java 21 runtime. Client companion mods and Fabric API are separately
approved ShaCraft managed files in the signed Minigames profile.
+271 -17
View File
@@ -2,24 +2,37 @@
## Current capability
The launcher persists local settings, synchronises Aeronautics mod/config
The launcher persists local settings, synchronises profile mod/config
files from the signed ShaCraft v2 manifest, installs the exact Minecraft +
NeoForge version the manifest specifies, and launches the game. A player
signs in with the same local ShaCraft account used on the website. The game
identity is derived only from that account's verified Aeronautics nickname;
the legacy editable nickname setting is not trusted at launch.
signs in with the same local ShaCraft account used on the website. The current
admission implementation claims a free nickname for that account and requests
a one-use permission immediately before launching Java. The game identity
comes only from the canonical Aeronautics nickname in that permission; the
legacy editable nickname setting is not trusted at launch.
The backend and admission mod were deployed to Aeronautics on 2026-09-10;
the server is healthy with whitelist enforcement retained. Real isolated
NeoForge connections verified successful admission, absent/replayed proof
rejection and protection of an online player from duplicate login. A public
production connection without the mod was rejected before world entry.
This does not certify a full cold installation or Windows/macOS operation.
The interface also shows a live Aeronautics player count from the fixed,
read-only `https://shacraft.ru/api/online/aoc` endpoint. It is display-only:
the result never controls files, versions, URLs, or the launch command.
Not yet implemented: a user-selectable profile directory, a "reset managed
files only" recovery action, and signed cross-platform release builds of the
launcher itself. Do not represent these as completed in UI or release notes.
Version 0.1.3 adds signed application updates, separate from modpack sync.
Version 0.1.4 adds installed deb updates with system administrator confirmation.
The first AppImage upgrade from 0.1.2 and deb upgrade from 0.1.3 are manual.
Windows/macOS 0.1.5 packages are published; actual desktop installation tests remain pending.
Not yet implemented: a user-selectable profile directory and a "reset managed
files only" recovery action. OS code signing/notarization is separate from the
updater signatures and is not certified by this implementation.
## Data flow
Two independent pipelines feed one launch:
Managed files, game installation and account admission meet at Java spawn:
```text
ShaCraft manifest (mods/config + which MC/loader/Java version to use)
@@ -33,9 +46,14 @@ Game itself (never controlled by the manifest above)
-> NeoForge's own installer, run headlessly (neoforge.rs)
-> generic inheritsFrom merge of the two version JSONs (mojang.rs)
-> SHA-1-verified merged libraries + platform natives (mojang.rs)
-> verified ShaCraft account link (shacraft_account.rs)
-> deterministic offline UUID for the linked nickname (session.rs)
-> java process spawned with the merged classpath/args (launch.rs)
Admission (fixed ShaCraft account API; never controlled by a manifest)
native website session -> direct free-nickname claim or admin migration
-> after installation: OS CSPRNG -> ephemeral Ed25519 key (admission.rs)
-> public key + bearer session -> one-use ticket for canonical aoc nickname
-> deterministic offline UUID for that nickname (session.rs)
-> Java with merged classpath/args + child-only ticket/private-key environment
-> client mod signs server challenge; server validates before world entry
```
Profiles (ShaCraft-managed mods/config, and the player's own worlds/
@@ -45,7 +63,8 @@ install (versions/libraries/assets/runtime, reused across profiles that
target the same Minecraft version) lives at `app_data_dir()/game`. Settings
live at `app_data_dir()/settings.json`, and the revocable ShaCraft session at
`app_data_dir()/shacraft-session` (mode 600 on Unix). Passwords are never
written to disk. None of these should be assumed to
written to disk. The admission private key and ticket are ephemeral native
values and are not persisted. None of these directories should be assumed to
be the system `.minecraft` directory.
## Aeronautics contract
@@ -60,18 +79,81 @@ be the system `.minecraft` directory.
- ShaCraft download files: HTTPS only, exact hosts `shacraft.ru` and
`cdn.shacraft.ru`.
- Account API origin: fixed `https://shacraft.ru`; redirects are rejected.
- Launch identity: the most recently verified `aoc` nickname returned by the
authenticated account API. Local nickname edits cannot select an identity.
- Launch identity: the canonical `aoc` nickname returned with the admission
ticket. Local nickname edits cannot select an identity.
## Admission contract (implementation: 2026-09-10)
Both endpoints use the fixed account API origin, HTTPS without redirects and
the native website session as bearer authentication. The session is never
passed to the client mod or Java process.
`POST /api/launcher/v2/admission/nickname` accepts
`{server_id: "aoc", mc_username: "Chosen_Name"}` and returns the existing
account shape `{username, links}`. The backend atomically assigns a free name
to that account. Existing player names remain reserved and require explicit
administrator migration. The launcher no longer asks the player to enter the
game to complete this claim. Legacy challenge IPC remains available to older
flows, but an admission-enabled backend must block those legacy endpoints
from bypassing reserved-name ownership.
After installation completes, the native launcher generates a fresh Ed25519
key using the OS CSPRNG and calls
`POST /api/launcher/v2/admission/tickets` with
`{server_id: "aoc", public_key: "<standard base64 raw 32-byte public key>"}`.
The response is `{ticket_id, mc_username, server_id, expires_in_seconds}`.
The native boundary requires a canonical 43-character base64url ticket ID,
an ASCII Minecraft nickname of 3–16 letters/digits/underscores, server `aoc`
and a positive lifetime of at most 600 seconds. The backend checks the current
account session, bound nickname and server access before issuing it.
`admission.rs` has no secret-bearing `Debug` or `Serialize` implementation.
Only the final Java child's environment receives:
- `SHACRAFT_ADMISSION_TICKET`: the one-use ticket ID.
- `SHACRAFT_ADMISSION_PRIVATE_KEY`: standard base64 of the Ed25519 PKCS#8
private-key-only DER representation accepted by Java's `KeyFactory`.
No global environment mutation, webview/IPC payload, argument substitution,
JVM argfile, settings file or log stores these values. The account operation
permit remains held from issuance through spawn so local account switching
or logout cannot race the handoff. Missing, disabled or invalid admission
responses fail the launch with a visible error; there is no legacy-name or
unsigned fallback. In this first version, a consumed or expired ticket needs
a fresh game launch from the launcher; transparent in-game reconnect is not
implemented.
The client mod proves possession of the ephemeral private key by signing the
server's challenge. The server mod gates world entry on successful backend
verification, including one-use consumption and current account/link/access
checks. Whitelist enforcement is retained. The 2026-09-10 Aeronautics rollout
replaced its separate LoginSystem `/register` and `/login` flow; other servers
keep their existing authentication. Old launchers without admission proof
cannot join Aeronautics. A required server-only Mixin rejects a duplicate
online UUID before vanilla can disconnect the existing player. The client pins
the actual game socket to `135.106.154.86:25567`; changing that address requires
an explicit mod update. Server source and rollout records live in
`/root/shacraft/services/admission-mod` and `docs/admission-2026-09-10.md` on
the ShaCraft host.
This protocol prevents entry without the account's current permission. It
does not attest that an original launcher or game binary is unmodified:
software running as the same user can read its own process environment, and
a compatible client can implement the protocol. Never replace account-bound
proof with a shared key embedded in distributed binaries.
## Planned but not implemented
1. User-selectable profile directory and structured launcher logs.
2. "Reset managed files only" recovery action that doesn't touch player
worlds/screenshots/resourcepacks.
3. Signed, cross-platform release builds of the launcher itself.
3. Windows Authenticode/macOS signing-notarization and cross-platform release
installation testing.
4. Cancellation, structured logs and a full cold-install/recovery beta on
every target OS. Install progress reports bytes or installer work counts
depending on the stage; these units are not interchangeable.
5. Transparent reconnect after the admission ticket has been consumed or
expired. The current implementation requires a new launch.
Do not represent these as completed features in UI or release notes.
@@ -96,6 +178,54 @@ URLs and redirects. Manifest profile identity, size, signature, portable
paths and existing symlinks are checked before managed file writes.
Hostile same-user TOCTOU is outside this protection; it is not an OS sandbox.
## Signed application updates (0.1.3)
`updater.rs` accepts only the fixed HTTPS feed
`https://shacraft.ru/launcher/updates/stable.json`. A dedicated embedded Tauri
public key authenticates both the metadata payload and the selected package.
The signed metadata binds the plain stable version, release notes, date and
platform URLs. Artifact URLs are confined to the matching version directory
under `https://shacraft.ru/downloads/shacraft-launcher/`. The IPC never accepts
a URL, key, destination path or replacement executable from the webview.
Metadata is downloaded once, with a 192 KiB envelope/64 KiB payload bound;
packages are capped at 256 MiB. Redirects and version downgrades are rejected.
The small vendored Tauri 2.11.0 `check_metadata` patch constructs its update
object without another HTTP request. Linux AppImage installation uses a
same-directory temporary file, signature verification, preserved permissions,
atomic rename and file/directory fsync. Windows/macOS retain Tauri's platform
installers. Unsupported Linux formats show manual installation instructions.
For installed deb packages, 0.1.4 selects only `linux-x86_64-deb`. The feed also
retains the identical legacy `linux-x86_64` and explicit `linux-x86_64-appimage`
AppImage entries so installed 0.1.3 readers remain compatible. Remote metadata
cannot switch a deb installation into an AppImage installation.
`deb_updater.rs` checks root ownership of the installed executable and its
parents and dpkg's ownership/version record. One `pkexec` invocation starts an
early non-GUI mode of `/usr/bin/shacraft-launcher`; no password is collected by
the launcher and no fallback prompt runs after cancellation. Bounded stdin
framing carries the signed envelope and package bytes, never user paths.
The helper authenticates both again as root, checks exact package identity
`sha-craft-launcher`, architecture and version, stages the package under a
root-only temporary directory and invokes the fixed dpkg installer. An explicit
`--refuse-downgrade` protects against another installation winning the version
race. Failed/partial package transactions require honest system-package recovery;
they are not reported as completed or automatically retried. Restart launches
the fixed installed executable even after dpkg replaces the running inode.
The native updater holds installation, account and game permits while installing
and until restart. The game permit remains held until the launched Java child
exits. These guards cover this launcher process, not other launcher instances.
Startup checks never silently install; settings expose check, install, progress,
errors and restart. A failed check does not prevent using the installed version.
The private updater key stays on the operator's computer. Normal CI builds are
explicitly unsigned; reviewed release artifacts and metadata are signed locally
and published only after signature/hash verification. The Caddy feed route uses
`Cache-Control: no-store`. See [launcher-updates.md](launcher-updates.md) for
the envelope contract, publisher commands and recovery constraints.
## Verification and distribution
`npm test` covers asynchronous helpers and state transitions;
@@ -103,5 +233,129 @@ Hostile same-user TOCTOU is outside this protection; it is not an OS sandbox.
covers native policy and storage. Push/PR CI repeats checks on Linux.
The package workflow runs on main pushes or manually and builds Windows
x64, Linux x64 and both macOS architectures with named artifacts.
Packages are not yet signed release artifacts. Native cold-install and
launch tests are required before calling a platform release-ready.
CI packages are unsigned build artifacts. Signed updater publication is a
separate local operator step. Native cold-install and launch tests are required
before calling a platform release-ready.
The local admission checkpoint passed 64 Rust tests (5 live tests ignored),
22 UI unit tests, TypeScript/Vite build and a Linux x86-64 release build with
`npm run tauri:build -- --no-bundle`. Six browser scenarios with mocked Tauri
IPC covered invalid nicknames, deleted sessions, reserved-name errors,
successful claims, duplicate clicks and a session revoked during a claim.
They also checked modal feedback, Escape preserving the settings drawer and
the absence of legacy link polling. These checks used no production account
or real Minecraft connection. The Linux output is a dynamically linked
binary, not evidence of Windows/macOS support testing.
Version 0.1.2 also produced unsigned Linux amd64 AppImage and deb packages with
`npm run tauri:build -- --bundles appimage,deb`. AppImage extraction and the
deb's version/architecture metadata were checked without running the app or
installing the package. The build host was Ubuntu 26.04; do not claim support
for older Ubuntu releases from this build. For local AppImage packaging,
linuxdeploy's GTK plugin needs `librsvg-2.0.pc` from the matching `librsvg2-dev`
package. Extracting that package into a temporary build directory and setting
`PKG_CONFIG_PATH` supplied the missing metadata without changing host packages.
The 0.1.3 updater checkpoint passed 76 native tests (6 live tests ignored),
28 UI tests, 11 publisher tests with real minisign and TypeScript/Vite build.
Nine browser scenarios used mocked IPC. The signed Linux AppImage/deb were
published on 2026-09-10 with a signed stable feed; feed bytes, signatures and
public HTTPS responses were verified. A separately invoked live native test
downloaded the production release, rejected corrupted bytes without changing
the old file, atomically updated a temporary copy of 0.1.2 and compared hashes.
The original source AppImage was retained. The installed 0.1.3 AppImage was
then started from `~/Applications` and its captured runtime paths verified.
This is not a full GUI update/restart cycle or a Windows/macOS installation test.
The Linux build host remains Ubuntu 26.04.
The 0.1.4 checkpoint passed 84 native tests (6 ignored), 32 UI tests and 18
publisher tests; 12 browser scenarios use mocked IPC. In a disposable Ubuntu
26.04 Docker container without network or production mounts, the actual signed
deb helper passed 10 scenarios: unprivileged invocation, truncated/trailing
input, damaged metadata/package, dpkg lock, unsafe temporary directory,
successful installation, replay and downgrade refusal. The fixture installed
the genuine old 0.1.3 package and bootstrapped the new verifier binary over its
package record; it then installed the genuine signed 0.1.4. It did not relabel
signed versions. Dpkg reported 0.1.4 and a fixture profile marker survived.
This tests the elevated helper and dpkg, not a real desktop PolicyKit dialog.
Cancellation/error rendering is covered by unit/browser scenarios. Published
metadata and deb bytes match the locally verified files; both website download
buttons target 0.1.4. No user host package installation was performed for QA.
The live native AppImage smoke also passed against the published 0.1.4 feed,
including corruption rejection and replacement of only a temporary source copy.
## Cross-platform release 0.1.5 (2026-09-10)
Published Windows x64 EXE/MSI, macOS aarch64 and x86_64 DMG/app.tar.gz,
and Linux x64 AppImage/DEB on https://shacraft.ru/help#launcher. The signed
stable feed includes all platforms plus the legacy/exact Linux aliases.
CI source b43fc610c43a9ec9f5f3ffce601de9604670ff8a, successful Actions run
https://github.com/emil28092005/shacraft-launcher/actions/runs/34512683651.
An initial non-Linux borrow/move compilation error in updater target selection
was fixed before the final build. Native tests: Windows70, macOS74 per arch,
Linux84; UI tests pass on all four runners. Local checks verify macOS bundle
version/CPU type, Linux package identity and every artifact signature. Public
HTTPS downloads of all eight artifacts match local SHA-256. Website418 tests
plus48 subtests pass; only backend recreated, game containers unchanged.
Updater signatures use the existing operator-held key, never uploaded to CI or
server. Windows installers have no Authenticode signature; macOS is not Apple
notarized. Native CI tests and packaging do not certify full Minecraft installs
or desktop updater/restart behavior on Windows/macOS. Older unsupported clients
need a manual installation of the current release. Previous releases immutable.
The live native updater test passed against the published 0.1.5 feed: verified download, corrupted-byte rejection and replacement of only a temporary AppImage copy. The user-installed launcher was not modified.
## Admission client menu 0.1.1 (2026-09-11)
The signed Aeronautics payload now contains admission mod 0.1.1 at the existing
managed path `mods/shacraft-admission-0.1.0.jar` to prevent duplicate mod IDs on
upgrade. SHA-256: `faa9ae13cb0f2d93c03dae26ab36ae20d3fb6b66c89c09254b16808d6b183f89`.
From the title screen (and vanilla safety acknowledgement), Multiplayer connects
to fixed `135.106.154.86:25567`. Cancel/errors return to TitleScreen; transitions
from other screens do not auto-connect. Client-only registration, protocol 1 and
one-use admission remain unchanged. Running game server was not restarted.
Linux Java 21 build and 8 mod tests pass. An opt-in native live test verifies
signed-manifest retrieval and download/repair/restoration of the admission jar
only in a temporary directory. Mac 0.1.5 connection failure remains unclassified
pending exact error/log; this is not a verified macOS fix or desktop UI test.
## Minigames alongside Aeronautics (2026-09-13)
The new native allowlist adds profile `minigames` at
`https://shacraft.ru/api/launcher/v2/profiles/minigames/signed-manifest` and
its display-only count at `https://shacraft.ru/api/online/minigames`.
Aeronautics remains a separate profile and keeps its previous managed files.
Minigames uses Minecraft 26.2, Fabric Loader 0.19.5, Java 25 and its own
`profiles/minigames` game directory. The signed payload supplies Fabric API
0.160.0+26.2 and `mods/shacraft-admission-client-0.1.0.jar`; it never supplies
Paper, the server plugins, maps, credentials or game-download URLs.
`fabric.rs` obtains an exact parent/loader profile from fixed Fabric metadata,
checks its identity and KnotClient entry point, and converts bounded Maven
library entries into the existing verified library contract. Each artifact
URL is constructed from a validated coordinate below fixed Fabric Maven;
metadata-supplied alternative origins are rejected. SHA-1 and size come from
Fabric metadata, or the same Maven's hash sidecar and HEAD for the loader jar.
Java provisioning already accepts exactly Java 25. Automatic installation on
a platform still requires a real cold-install check on that platform.
Both profiles claim/display the canonical `aoc` nickname. Only the native
profile mapping determines ticket `server_id` (`aoc` or `minigames`), and the
response must match that exact server. The backend is responsible for shared
access checks at both issuance and redemption. Existing account sessions and
settings need no migration. No copied subscription/whitelist grant is trusted.
Minigames adds native `--quickPlayMultiplayer 135.106.154.86:25568` at launch.
The client-only Fabric companion validates the actual socket target and signs
the existing configuration challenge with `minigames` in the transcript.
Paper performs verification before entry; its early duplicate UUID gate must
run before vanilla would evict the existing player. There is no proxy and no
client-side shared secret. One ticket is used for one game connection; a fresh
launcher start is required after expiry, consumption or a failed proof attempt.
The integration is staged until server authentication, signed profile payload,
and a newer signed launcher release are deployed and checked together. The
existing public 0.1.5 binary cannot select the new profile by a website-only
catalog change. Preserve both catalog entries when publishing either profile.
+266
View File
@@ -0,0 +1,266 @@
# Signed launcher updates
The application updater is separate from the signed Aeronautics modpack
manifest. Its only metadata endpoint is
`https://shacraft.ru/launcher/updates/stable.json`. Its artifact URLs are confined
to `https://shacraft.ru/downloads/shacraft-launcher/<version>/<filename>`.
The webview cannot choose a URL, signing key or executable path. The native
updater verifies signatures before installing; an unavailable or invalid feed
does not prevent playing with the installed launcher.
The first version containing the updater must be installed manually. Version
0.1.2 has no code capable of installing this feature itself. AppImage supports
self-updates from 0.1.3; deb adds them in 0.1.4. An existing 0.1.3 deb therefore
needs one manual upgrade to 0.1.4 before its own update button can work. A deb
installation keeps its package format and asks for system administrator
authorization when installing an update. The launcher itself runs as the normal
user. Development binaries use the manual download path. Windows/macOS
publication and actual
installation tests remain separate release work; supporting a platform in the
feed schema does not certify a working release on it.
## Release-line compatibility
The archived 2026-09-09 review bundle at
`/home/emil/Desktop/shacraft-updater-review/README.md` describes a different,
unreleased updater prototype: GitHub-hosted `latest.json`, a different pinned
key and the former LoginSystem/Game Bridge proof flow. Its successful CI and
prototype version numbers do not establish compatibility with the deployed
admission protocol. The 0.1.3 and 0.1.4 releases follow the deployed 0.1.2
admission line based on `bf43254`, using the ShaCraft-hosted feed described here.
Never publish the archived `CI-NOT-FOR-RELEASE`/`CI_NOT_FOR_RELEASE` packages or
substitute the unreleased 0.2.0 prototype for an admission-compatible release.
Do not merge its updater key, endpoint or account flow blindly: that can break
both update continuity and server login. Future reconciliation requires an
explicit compatibility review retaining admission support and the key/feed
contract already distributed to players, or a separately designed migration.
## Authentication contract
The public key embedded in the application is a **dedicated Tauri updater
key**, separate from the existing modpack manifest key. Tauri's minisign format
wraps the entire minisign public-key/signature text in standard base64. The
contents of a `.sig` file belong in metadata, not its filename or URL.
The stable feed contains the normal Tauri fields `version`, `notes`, `pub_date`
and `platforms`, and two additional fields:
- `signedPayload`: standard base64 of the exact UTF-8 JSON bytes containing
only the four normal fields. The publisher produces these bytes with sorted
keys, compact separators, literal UTF-8 and no trailing newline.
- `metadataSignature`: the Tauri `.sig` contents for those exact payload bytes,
signed with the same updater key that signs the application packages.
The launcher authenticates the payload, requires it to equal the visible
fields and then selects the signed platform artifact. This also authenticates
the version and artifact URL: an old signed installer cannot be relabelled as
a newer release by modifying unsigned metadata. Every artifact is separately
verified through Tauri's built-in updater signature check. The current version
must increase; there is no unsigned or automatic downgrade fallback.
The stable publisher accepts only plain `MAJOR.MINOR.PATCH` versions and these
platforms: `linux-x86_64` (legacy `.AppImage`), `linux-x86_64-appimage`
(`.AppImage`), `linux-x86_64-deb` (`.deb`), `windows-x86_64` (`.exe` or `.msi`),
`darwin-x86_64` and `darwin-aarch64` (`.app.tar.gz`). Artifact filenames contain
only ASCII letters, digits, dots, underscores and hyphens. Files must already
exist in the matching version directory, must not be symlinks and must be
between 1 byte and 256 MiB. A platform without a tested signed artifact is
omitted, never represented by an empty signature or another platform's file.
Format-aware Linux feeds must contain both AppImage keys with exactly the same
URL and signature. This keeps 0.1.3 clients on their original AppImage path.
New AppImage clients prefer `linux-x86_64-appimage` and can read the legacy key;
deb clients require `linux-x86_64-deb` and never fall back to an AppImage.
Preserve all three entries when publishing a release that supports both formats.
After verifying each signature, the publisher also checks Linux package format.
AppImage must have the ELF64 little-endian x86_64 and type-2 AppImage header.
For deb, `/usr/bin/dpkg-deb` must report package `sha-craft-launcher`, architecture
`amd64` and the exact signed release version. Inspection uses fixed arguments,
no shell, a cleared environment, a 10-second timeout and a 4 KiB output limit.
It does not install a package or execute its maintainer scripts. This protects
against accidental publication of the wrong signed package; an installer
signature remains mandatory and is checked before package inspection.
## Debian installation boundary
The installed launcher must be `/usr/bin/shacraft-launcher`, owned by root in
root-owned directories that other users cannot write. The package database
must assign that file to an installed `sha-craft-launcher` of the expected
architecture. The updater needs the system `pkexec` authorization agent; it
does not collect a password or fall back to running a shell with privileges.
After the normal-user downloader verifies the update, `pkexec` launches the
fixed installed binary with `--shacraft-install-deb`. This mode runs before
Tauri/GTK initialization. It accepts only length-bounded signed metadata and
package bytes over stdin, never a user-provided package path. The root helper
independently verifies the metadata, selects only the exact deb target, checks
the package signature and requires a higher version than the current dpkg
database. It writes the verified bytes to a root-created mode-0700 temporary
directory under the validated `/var/tmp`; the file has mode 0600.
The helper checks the package's exact name, version and architecture with
`dpkg-deb`, then invokes fixed `dpkg --refuse-downgrade --install` arguments in
an environment without inherited variables. Dpkg's own downgrade refusal
protects against a competing newer installation between the version check and
the package-manager lock. A successful result also requires the package
database to report the intended version as installed. The temporary package
is removed on completion. A signed deb may include maintainer scripts, which
dpkg runs with administrator privileges as part of normal installation: review
release package contents before signing.
Cancellation of system authorization, missing authorization support, signature
rejection, a busy package manager and installation failure have distinct
messages. There is no automatic retry with weaker checks. Dpkg installation
is not an atomic file replacement: dependency/configuration failures or power
loss can require normal package-manager recovery. The launcher reports failure
instead of claiming the old installation is intact. Successful deb updates
restart the fixed installed binary as the ordinary user. These guarantees
are separate from AppImage's same-directory atomic replacement.
## Keys and builds
The production private key stays **only on the operator's local machine** at
`/home/emil/.local/share/shacraft-updater/production.key`, with owner-only
permissions. Its public companion is `production.key.pub`. Never transfer the
private key to the web server, GitHub, CI, logs, chat, a package or a public
artifact. Signing commands below pass the local path, not the key contents.
Keep a protected operator-controlled backup: replacing or losing the key will
break continuity for installations trusting the existing public key. There is
no automatic key rotation mechanism in this release.
Normal `build.yml` jobs explicitly merge `scripts/tauri-unsigned.json` to disable
updater signing. They upload ordinary packages and unsigned macOS `.app.tar.gz`
archives. CI does not receive the production key and does not publish the
stable feed. A release operator reviews/tests these build artifacts, then signs
the chosen packages locally. For a signed local Tauri bundle build, set
`TAURI_SIGNING_PRIVATE_KEY` to the protected key path; never disable verification
in the application to make a build pass.
Updater signatures authenticate ShaCraft's update channel. They are separate
from Windows Authenticode, Apple signing/notarization, and Linux distribution
package signatures; passing updater checks does not establish those assurances.
## Local preparation and signing
The publisher requires Python 3.10+ and `minisign`; releases containing deb also
require `/usr/bin/dpkg-deb` (Debian/Ubuntu's `dpkg` package). It performs verification
through the standard minisign CLI, without implementing cryptography in Python.
`--minisign /absolute/path/to/minisign` supports a locally extracted tool without
installing a global package. Run these examples from the launcher repository,
substituting the actual release version and tested filenames.
1. Stage immutable, tested packages below a local downloads root. The following
example assumes both tested Linux artifacts already exist below
`/tmp/shacraft-release/downloads/0.1.4/`
and release notes exist at `/tmp/shacraft-release/notes.txt`. Create signatures
with the Tauri CLI; `.sig` is written beside each artifact:
```bash
npm run tauri -- signer sign \
--private-key-path /home/emil/.local/share/shacraft-updater/production.key \
/tmp/shacraft-release/downloads/0.1.4/ShaCraft.Launcher_0.1.4_amd64.AppImage
npm run tauri -- signer sign \
--private-key-path /home/emil/.local/share/shacraft-updater/production.key \
/tmp/shacraft-release/downloads/0.1.4/ShaCraft.Launcher_0.1.4_amd64.deb
```
2. Prepare a deterministic payload after verifying every package signature.
Repeat `--artifact PLATFORM=FILENAME` for each tested platform included in this
release. Keep the legacy AppImage alias. Do not list a dmg, nonexistent
package or untested architecture:
```bash
python3 scripts/publish_launcher_update.py prepare \
--version 0.1.4 \
--downloads-root /tmp/shacraft-release/downloads \
--artifact linux-x86_64=ShaCraft.Launcher_0.1.4_amd64.AppImage \
--artifact linux-x86_64-appimage=ShaCraft.Launcher_0.1.4_amd64.AppImage \
--artifact linux-x86_64-deb=ShaCraft.Launcher_0.1.4_amd64.deb \
--notes-file /tmp/shacraft-release/notes.txt \
--public-key /home/emil/.local/share/shacraft-updater/production.key.pub \
--payload /tmp/shacraft-release/release.payload.json
```
3. Inspect the payload and sign its exact bytes locally:
```bash
npm run tauri -- signer sign \
--private-key-path /home/emil/.local/share/shacraft-updater/production.key \
/tmp/shacraft-release/release.payload.json
```
Editing notes, timestamps, versions, signatures or URLs after this step
invalidates the metadata signature. Prepare and sign again after any change.
## Publication
Upload **only** the packages, their `.sig` files, `release.payload.json`, its
`.sig`, the public key and the publisher script. Stage and hash-check artifacts
before publishing metadata. Production paths are:
- Downloads root: `/root/shacraft/caddy/www/downloads/shacraft-launcher`.
- Stable feed: `/root/shacraft/data/launcher/updates/stable.json`.
- Public feed: `https://shacraft.ru/launcher/updates/stable.json`.
Keep previous version directories immutable and save the current feed before
replacing it. Run the publisher on the host with a public-key file and minisign
available there. Neither operation needs a private key:
```bash
python3 publish_launcher_update.py publish \
--downloads-root /root/shacraft/caddy/www/downloads/shacraft-launcher \
--public-key /path/to/production.key.pub \
--payload /path/to/release.payload.json \
--signature /path/to/release.payload.json.sig \
--output /root/shacraft/data/launcher/updates/stable.json \
--dry-run
```
After that succeeds, repeat without `--dry-run`. The publisher verifies metadata
and all artifacts under the public key, authenticates the previous feed before
comparing versions, and refuses same-version replacement or downgrade. It holds
an exclusive publication lock and writes/fsyncs a temporary sibling before
atomically replacing `stable.json`. Dry-run validates everything but does not
replace the feed. Do not change staged artifacts concurrently with publication.
Do not overwrite a released version to add another platform: publish a higher
version containing the complete intended platform set.
Alternatively, run the same verification locally against byte-for-byte copies
of the current feed and staged downloads, then deploy the resulting feed only
after checking uploaded package and metadata hashes against those validated
files. An initial publication has no previous feed; subsequent publications
must validate against the actual deployed feed, not an empty staging directory.
Caddy should serve this feed as JSON with `Cache-Control: no-store`. Check the
public response, decoded metadata, signatures and downloadable artifact hashes
after deployment. Exercise a real installed AppImage updating to a higher
version, including relaunch and retained settings/account state. For deb, also
exercise administrator cancellation, package-manager lock conflicts, failed
installation and a successful package upgrade/relaunch. Use an isolated system
for destructive package-manager failure cases; never modify player data as a
test fixture. Unit tests, packaging or a browser mock alone do not establish
successful installation or distribution compatibility.
If a release is faulty, stop offering it and publish a corrected higher version;
do not weaken signature checks or silently downgrade users.
## Verification
```bash
python3 -m unittest discover -s scripts -p 'test_*.py'
```
Publisher tests exercise the real minisign CLI with temporary test keys,
including valid publication, modified packages and metadata, authenticated
previous-version checks, downgrade refusal, URL/path restrictions and dry-run.
Linux tests also build real temporary deb packages with `dpkg-deb`, validate
package/version/architecture, ensure inspection never executes maintainer
scripts, retain the legacy AppImage feed alias, and reject malformed signed
Linux packages. Package-inspection output and time bounds are exercised.
No test private key is checked into the repository. CI installs minisign so
the signature tests run; locally they explicitly skip if the tool is absent.
Set `SHACRAFT_TEST_MINISIGN` to use an extracted executable.
The artifact formats and signature encoding follow the
[official Tauri updater documentation](https://v2.tauri.app/plugin/updater/).
+20
View File
@@ -0,0 +1,20 @@
## Cross-platform release 0.1.5 (2026-09-10)
Published Windows x64 EXE/MSI, macOS aarch64 and x86_64 DMG/app.tar.gz,
and Linux x64 AppImage/DEB on https://shacraft.ru/help#launcher. The signed
stable feed includes all platforms plus the legacy/exact Linux aliases.
CI source b43fc610c43a9ec9f5f3ffce601de9604670ff8a, successful Actions run
https://github.com/emil28092005/shacraft-launcher/actions/runs/34512683651.
An initial non-Linux borrow/move compilation error in updater target selection
was fixed before the final build. Native tests: Windows70, macOS74 per arch,
Linux84; UI tests pass on all four runners. Local checks verify macOS bundle
version/CPU type, Linux package identity and every artifact signature. Public
HTTPS downloads of all eight artifacts match local SHA-256. Website418 tests
plus48 subtests pass; only backend recreated, game containers unchanged.
Updater signatures use the existing operator-held key, never uploaded to CI or
server. Windows installers have no Authenticode signature; macOS is not Apple
notarized. Native CI tests and packaging do not certify full Minecraft installs
or desktop updater/restart behavior on Windows/macOS. Older unsupported clients
need a manual installation of the current release. Previous releases immutable.
The live native updater test passed against the published 0.1.5 feed: verified download, corrupted-byte rejection and replacement of only a temporary AppImage copy. The user-installed launcher was not modified.
+117
View File
@@ -0,0 +1,117 @@
# Launcher 0.1.6 release
Adds a separate Minigames profile (Minecraft 26.2 / Fabric 0.19.5 / Java 25)
while keeping Aeronautics and its installed profile intact. Both use the
existing canonical aoc nickname and shared access. Tickets remain bound to
the selected server. Minigames connects to 135.106.154.86:25568 and proves
account admission during configuration before world entry.
Local checks: 87 native tests, 33 UI tests, TypeScript/Vite, three Java client
proof tests, an official Fabric metadata resolution check and the Linux native
release build. The [real Fabric/Paper smoke](verification/minigames-fabric-2026-09-13.json)
passed with a synthetic account: the unchanged production companion entered
the lobby and the backend consumed its Minigames ticket. Published on 2026-09-13:
all four cross-platform jobs succeeded in [build run 34778218510](https://github.com/emil28092005/shacraft-launcher/actions/runs/34778218510)
at runtime source `799fa692ef5e775f0044fe300f65fd4564771d47`.
[Checks run 34778334417](https://github.com/emil28092005/shacraft-launcher/actions/runs/34778334417)
passed at `5b741771b4668a834a2c5b757379340f11b8ae3a`; the only difference is the
CI job that runs all 18 publisher signature tests on Ubuntu 24.04. The CI Fabric
jar exactly matches the one used by the real client smoke.
All eight packages were signed locally, verified again on the server, and
published after a successful dry-run. Full public HTTPS downloads match the
recorded SHA-256 values; package signatures and the public feed signature pass.
The feed payload equals the locally signed canonical bytes. See the
[publication receipt](verification/launcher-release-0.1.6.json). The previous
0.1.5 feed was backed up; its packages remain unchanged. The private key stayed
on the operator workstation.
## Build contract
Git remote: `git@github.com:emil28092005/shacraft-launcher.git`.
The production admission line is branch `codex/launcher-updater`; `main` is a
divergent unreleased prototype and must remain unchanged for this release.
Push the reviewed commit to the production branch, then dispatch
`gh workflow run build.yml --ref codex/launcher-updater --repo emil28092005/shacraft-launcher`.
The check workflow runs on branch pushes. Verify each run's head SHA equals the
reviewed commit before downloading artifacts. A main push also triggers the
build matrix, but was not the publication path for this release.
CI publishes unsigned artifacts named:
- `shacraft-launcher-linux-x64`: AppImage and deb.
- `shacraft-launcher-windows-x64`: NSIS exe and MSI.
- `shacraft-launcher-macos-arm64`: aarch64 app.tar.gz and DMG.
- `shacraft-launcher-macos-x64`: x86_64 app.tar.gz and DMG.
`.github/workflows/check.yml` additionally tests the Java 25 admission client
and uploads `shacraft-admission-client`. It has no production credentials.
Download the artifacts from the checked run at the exact reviewed commit with
`gh run download RUN_ID --repo emil28092005/shacraft-launcher --dir STAGING`.
## Signing and publication
Stage renamed ASCII filenames below a local downloads root, for example
`/tmp/shacraft-release-0.1.6/downloads/0.1.6/`. Preserve already published
0.1.5 bytes. Expected updater filenames:
- `ShaCraft.Launcher_0.1.6_amd64.AppImage`
- `ShaCraft.Launcher_0.1.6_amd64.deb`
- `ShaCraft.Launcher_0.1.6_x64-setup.exe`
- `ShaCraft.Launcher_0.1.6_aarch64.app.tar.gz`
- `ShaCraft.Launcher_0.1.6_x86_64.app.tar.gz`
MSI and DMG are additional manual downloads; the updater uses EXE and app.tar.gz.
Inspect package versions, architecture and contents before signing. Sign each
chosen file with the existing local operator key:
```bash
npm run tauri -- signer sign --private-key-path /home/emil/.local/share/shacraft-updater/production.key ARTIFACT
```
The key file is mode 0600 and remains local. Never read its contents into logs,
copy it to CI/server or substitute a different signing identity. Existing
`production.key.pub` is sufficient for every later verification/publication.
After creating a UTF-8 release notes file, prepare the payload:
```bash
python3 scripts/publish_launcher_update.py prepare \
--version 0.1.6 \
--downloads-root /tmp/shacraft-release-0.1.6/downloads \
--artifact linux-x86_64=ShaCraft.Launcher_0.1.6_amd64.AppImage \
--artifact linux-x86_64-appimage=ShaCraft.Launcher_0.1.6_amd64.AppImage \
--artifact linux-x86_64-deb=ShaCraft.Launcher_0.1.6_amd64.deb \
--artifact windows-x86_64=ShaCraft.Launcher_0.1.6_x64-setup.exe \
--artifact darwin-aarch64=ShaCraft.Launcher_0.1.6_aarch64.app.tar.gz \
--artifact darwin-x86_64=ShaCraft.Launcher_0.1.6_x86_64.app.tar.gz \
--notes-file /tmp/shacraft-release-0.1.6/notes.txt \
--public-key /home/emil/.local/share/shacraft-updater/production.key.pub \
--payload /tmp/shacraft-release-0.1.6/release.payload.json
npm run tauri -- signer sign \
--private-key-path /home/emil/.local/share/shacraft-updater/production.key \
/tmp/shacraft-release-0.1.6/release.payload.json
```
Upload only public packages, signatures, payload, public key and publisher.
Server downloads root is `/root/shacraft/caddy/www/downloads/shacraft-launcher`;
public artifact URLs are `https://shacraft.ru/downloads/shacraft-launcher/0.1.6/`
followed by the checked filename. Preserve the old feed before publication.
With the exact staged server paths, run the existing publisher first with
`--dry-run`, then without it:
```bash
python3 publish_launcher_update.py publish \
--downloads-root /root/shacraft/caddy/www/downloads/shacraft-launcher \
--public-key PUBLIC_KEY_FILE \
--payload SIGNED_PAYLOAD_FILE \
--signature PAYLOAD_SIGNATURE_FILE \
--output /root/shacraft/data/launcher/updates/stable.json \
--dry-run
```
Publication depends on the signed Minigames profile containing the final client
and Fabric API jars, the healthy Paper admission gate, and the shared-access
backend endpoints being available. Verify public HTTPS package hashes, feed
signatures and an actual client admission before updating the website buttons.
OS Authenticode/Apple notarization and cold installations on other platforms
remain distinct from successful native CI/builds.
@@ -0,0 +1,84 @@
{
"recorded_at": "2026-09-13T20:03:46.913196+00:00",
"version": "0.1.6",
"source_sha": "799fa692ef5e775f0044fe300f65fd4564771d47",
"branch": "codex/launcher-updater",
"build_run": 34778218510,
"checks_run": 34778334417,
"stable_feed": "https://shacraft.ru/launcher/updates/stable.json",
"feed_sha256": "342876aee8760a984421889cec1453dd642ebf988ab2bad27f23ea4f4f203f8d",
"feed_signature_verified": true,
"feed_payload_equals_locally_signed_payload": true,
"platforms": [
"darwin-aarch64",
"darwin-x86_64",
"linux-x86_64",
"linux-x86_64-appimage",
"linux-x86_64-deb",
"windows-x86_64"
],
"artifacts": [
{
"artifact": "ShaCraft.Launcher_0.1.6_amd64.AppImage",
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.6/ShaCraft.Launcher_0.1.6_amd64.AppImage",
"size": 83274232,
"sha256": "fa01905baddebd08b59ab7855009f0b29df02fcd3813941a0190f5135ff58f7c",
"public_signature_verified": true
},
{
"artifact": "ShaCraft.Launcher_0.1.6_amd64.deb",
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.6/ShaCraft.Launcher_0.1.6_amd64.deb",
"size": 5997818,
"sha256": "01621fde16f6fc1ca453acffb1d22d664c8d8b60909d22a8aa2af10ac80d8737",
"public_signature_verified": true
},
{
"artifact": "ShaCraft.Launcher_0.1.6_x64-setup.exe",
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.6/ShaCraft.Launcher_0.1.6_x64-setup.exe",
"size": 3793645,
"sha256": "0bb2a577d88d4387f23efcd28b19bf83e373b1b10c5cbbb5d6e0f69428d9f9ab",
"public_signature_verified": true
},
{
"artifact": "ShaCraft.Launcher_0.1.6_x64_en-US.msi",
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.6/ShaCraft.Launcher_0.1.6_x64_en-US.msi",
"size": 5554176,
"sha256": "8a53a9d8d6dbdd98238b294df604fe5050375e64709dfdff942bf60fcd61519b",
"public_signature_verified": true
},
{
"artifact": "ShaCraft.Launcher_0.1.6_aarch64.app.tar.gz",
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.6/ShaCraft.Launcher_0.1.6_aarch64.app.tar.gz",
"size": 5876796,
"sha256": "c113afc0794eaa6788cb994e7d84f821d22573d559f3cddfb0bcd5c2cad557cf",
"public_signature_verified": true
},
{
"artifact": "ShaCraft.Launcher_0.1.6_aarch64.dmg",
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.6/ShaCraft.Launcher_0.1.6_aarch64.dmg",
"size": 6420101,
"sha256": "0391a1d4afa6cb21663f1f8de367ecdd68a51656db48e86ad0bec617270c6036",
"public_signature_verified": true
},
{
"artifact": "ShaCraft.Launcher_0.1.6_x86_64.app.tar.gz",
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.6/ShaCraft.Launcher_0.1.6_x86_64.app.tar.gz",
"size": 6032564,
"sha256": "ee025cd13af1887334d0af5a7e04259eddd6339fe8509b108e0b3ac0e7268daf",
"public_signature_verified": true
},
{
"artifact": "ShaCraft.Launcher_0.1.6_x64.dmg",
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.6/ShaCraft.Launcher_0.1.6_x64.dmg",
"size": 6591340,
"sha256": "cd54c8f975bb7a1ec587bbadde35caf4a3eb9a1f86182150310590e41dec4023",
"public_signature_verified": true
}
],
"previous_feed_version": "0.1.5",
"previous_feed_sha256": "d81ab2b007f7f9a385f2ec28d17f204e75c0c2e92cf40931064dd66d3bd2898f",
"previous_feed_backup": "/root/shacraft/.release-staging/launcher-0.1.6/stable.before-0.1.6.json",
"private_key_location": "remains on operator workstation; not uploaded",
"remote_publication": "all8 local signatures verified, remote hashes/signatures verified, dry-run passed, atomic publish passed",
"limitations": "Cross-platform CI and package inspection passed. Windows/macOS cold installation and OS signing/notarization were not exercised."
}
@@ -0,0 +1,33 @@
{
"recorded_at": "2026-09-13T19:32:59.436884+00:00",
"result": "joined_world",
"client": "Minecraft 26.2 / Fabric Loader 0.19.5 / Fabric API 0.160.0+26.2",
"java": "25.0.2",
"client_jar_sha256": "3335626b7c8fdd233e8531ad382594398c7df48842b1b919934d1154dd4ba8f1",
"client_jar_size": 10667,
"profile": "minigames",
"synthetic_player": "AdmissionPilot",
"endpoint": "127.0.0.1:25608",
"environment": "isolated Xvfb :95; separate temporary game directory; synthetic account/ticket; explicit local socket test flag",
"server_evidence": [
"[22:32:02 INFO]: AdmissionPilot joined the game",
"[22:32:02 INFO]: AdmissionPilot[/127.0.0.1:54602] logged in with entity id 77 at ([minecraft:shacraft_lobby_v2]0.5, 96.0, 43.5)"
],
"verified": [
"production companion jar bytes unchanged",
"queued minecraft:register during configuration",
"server-bound Ed25519 response accepted",
"actual vanilla client entered lobby world"
],
"not_tested": [
"production public endpoint login",
"Windows/macOS cold installation",
"full GUI launcher install/account flow"
],
"notes": "Offline client emits Microsoft profile-certificate HTTP401; admission and world entry succeeded. Real account/session/password not used.",
"backend_evidence": {
"source": "backend agent read-only synthetic SQLite query",
"issued_minigames_tickets_for_player": 1,
"consumed_minigames_tickets_for_player": 1
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "shacraft-launcher-ui",
"version": "0.1.1",
"version": "0.1.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "shacraft-launcher-ui",
"version": "0.1.1",
"version": "0.1.6",
"dependencies": {
"@tauri-apps/api": "2.11.1",
"lucide-react": "1.41.0",
+2 -2
View File
@@ -2,13 +2,13 @@
"name": "shacraft-launcher-ui",
"license": "MIT",
"private": true,
"version": "0.1.1",
"version": "0.1.6",
"type": "module",
"scripts": {
"dev": "vite",
"build": "npm run typecheck && vite build",
"typecheck": "tsc --noEmit",
"test": "tsx --test src/services/async.test.ts src/state/game.test.ts src/state/profiles.test.ts src/state/account.test.ts src/components/account.test.tsx",
"test": "tsx --test src/services/async.test.ts src/state/game.test.ts src/state/profiles.test.ts src/state/account.test.ts src/components/account.test.tsx src/state/updater.test.ts src/components/updater.test.tsx",
"preview": "vite preview",
"tauri": "tauri",
"tauri:dev": "tauri dev",
+1
View File
@@ -0,0 +1 @@
__pycache__/
+360
View File
@@ -0,0 +1,360 @@
#!/usr/bin/env python3
"""Prepare and atomically publish a signed ShaCraft stable updater feed.
Requires Python 3.10+ and the minisign CLI. Only public keys are inputs.
Signing is deliberately a separate, operator-controlled action.
"""
import argparse
import base64
import binascii
import contextlib
from datetime import datetime, timezone
import fcntl
import json
import os
from pathlib import Path
import re
import selectors
import stat
import subprocess
import tempfile
import time
ORIGIN = "https://shacraft.ru/downloads/shacraft-launcher/"
PLATFORMS = {
"linux-x86_64": (".AppImage",),
"linux-x86_64-appimage": (".AppImage",),
"linux-x86_64-deb": (".deb",),
"windows-x86_64": (".exe", ".msi"),
"darwin-x86_64": (".app.tar.gz",),
"darwin-aarch64": (".app.tar.gz",),
}
FIELDS = {"version", "notes", "pub_date", "platforms"}
MAX_ARTIFACT_BYTES = 256 * 1024 * 1024
MAX_METADATA_BYTES = 64 * 1024
DEB_PACKAGE = "sha-craft-launcher"
DPKG_DEB = "/usr/bin/dpkg-deb"
PACKAGE_TOOL_ENV = {"PATH": "/usr/bin:/bin", "LC_ALL": "C"}
class InvalidRelease(ValueError):
pass
def version_tuple(version):
if not isinstance(version, str) or not re.fullmatch(
r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)", version
):
raise InvalidRelease("stable version must be plain MAJOR.MINOR.PATCH")
parts = tuple(map(int, version.split(".")))
if any(part > 2**64 - 1 for part in parts):
raise InvalidRelease("version component exceeds SemVer range")
return parts
def artifact_name(platform, filename):
if platform not in PLATFORMS:
raise InvalidRelease("unsupported updater platform")
if not isinstance(filename, str) or not re.fullmatch(
r"[A-Za-z0-9][A-Za-z0-9._-]{0,199}", filename
):
raise InvalidRelease("artifact filename must be a plain ASCII filename")
if not filename.endswith(PLATFORMS[platform]):
raise InvalidRelease("artifact suffix does not match updater platform")
return filename
def regular_file(path, limit):
info = path.lstat()
if not stat.S_ISREG(info.st_mode) or info.st_size == 0 or info.st_size > limit:
raise InvalidRelease("input must be a nonempty regular file within size limit")
return info
def read_file(path, limit=MAX_METADATA_BYTES):
regular_file(path, limit)
with path.open("rb") as stream:
value = stream.read(limit + 1)
if len(value) > limit:
raise InvalidRelease("input exceeds size limit")
return value
def decode_tauri(value):
if not isinstance(value, str) or not value or len(value) > MAX_METADATA_BYTES:
raise InvalidRelease("invalid Tauri base64 value")
try:
decoded = base64.b64decode(value, validate=True)
decoded.decode("utf-8")
except (binascii.Error, UnicodeDecodeError) as exc:
raise InvalidRelease("invalid Tauri base64 encoding") from exc
if base64.b64encode(decoded).decode("ascii") != value:
raise InvalidRelease("noncanonical Tauri base64 encoding")
return decoded
def verify_signature(artifact, signature, public_key, minisign):
# Tauri wraps the entire standard minisign text file in base64.
signature_bytes = decode_tauri(signature)
key_bytes = decode_tauri(public_key)
with tempfile.TemporaryDirectory(prefix="shacraft-update-verify-") as temporary:
root = Path(temporary)
signature_path = root / "signature.minisig"
key_path = root / "public.minisign.pub"
signature_path.write_bytes(signature_bytes)
key_path.write_bytes(key_bytes)
try:
result = subprocess.run(
[minisign, "-V", "-q", "-m", str(artifact), "-x", str(signature_path),
"-p", str(key_path)],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, timeout=120, check=False,
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise InvalidRelease("minisign verification could not run") from exc
if result.returncode != 0:
raise InvalidRelease("signature verification failed")
def bounded_command_output(command, limit=4096, timeout=10):
"""Run a fixed package inspector without shell, inherited hooks or unbounded output."""
process = None
try:
process = subprocess.Popen(
command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, env=PACKAGE_TOOL_ENV,
)
deadline = time.monotonic() + timeout
output = bytearray()
with selectors.DefaultSelector() as selector:
selector.register(process.stdout, selectors.EVENT_READ)
while True:
remaining = deadline - time.monotonic()
if remaining <= 0 or not selector.select(remaining):
raise InvalidRelease("package inspection timed out")
chunk = os.read(process.stdout.fileno(), min(4096, limit + 1 - len(output)))
if not chunk:
break
output.extend(chunk)
if len(output) > limit:
raise InvalidRelease("package inspection exceeds output limit")
returncode = process.wait(timeout=max(0.001, deadline - time.monotonic()))
if returncode != 0:
raise InvalidRelease("package inspection failed")
return bytes(output)
except (OSError, subprocess.TimeoutExpired) as exc:
raise InvalidRelease("package inspection could not run") from exc
finally:
if process is not None:
if process.poll() is None:
process.kill()
process.wait()
process.stdout.close()
def validate_artifact_format(platform, path, version):
if platform in {"linux-x86_64", "linux-x86_64-appimage"}:
with path.open("rb") as stream:
header = stream.read(64)
if (len(header) < 64 or header[:7] != b"\x7fELF\x02\x01\x01"
or header[8:11] != b"AI\x02" or header[18:20] != b"\x3e\x00"):
raise InvalidRelease("AppImage must be a type-2 x86_64 ELF image")
elif platform == "linux-x86_64-deb":
# Inspect only authenticated package bytes; dpkg-deb does not run maintainer scripts.
output = bounded_command_output([
DPKG_DEB, "--showformat=${Package}\n${Version}\n${Architecture}\n", "--show", str(path),
])
expected = f"{DEB_PACKAGE}\n{version}\namd64\n".encode("ascii")
if output != expected:
raise InvalidRelease("deb identity must match sha-craft-launcher, signed version and amd64")
def validate_linux_aliases(platforms):
if "linux-x86_64-appimage" in platforms or "linux-x86_64-deb" in platforms:
legacy = platforms.get("linux-x86_64")
exact = platforms.get("linux-x86_64-appimage")
if legacy is None or exact is None or legacy != exact:
raise InvalidRelease("format-aware Linux releases require identical legacy and AppImage entries")
def strict_json(data):
def unique(pairs):
result = {}
for key, value in pairs:
if key in result:
raise InvalidRelease("duplicate JSON key")
result[key] = value
return result
try:
return json.loads(data, object_pairs_hook=unique)
except (ValueError, UnicodeDecodeError) as exc:
raise InvalidRelease("invalid release JSON") from exc
def canonical(payload):
return json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
def verify_payload_bytes(payload_bytes, signature, public_key, minisign):
with tempfile.TemporaryDirectory(prefix="shacraft-update-payload-") as temporary:
immutable_payload = Path(temporary) / "payload.json"
immutable_payload.write_bytes(payload_bytes)
verify_signature(immutable_payload, signature, public_key, minisign)
def verified_previous(data, public_key, minisign):
envelope = strict_json(data)
if not isinstance(envelope, dict) or set(envelope) != FIELDS | {"signedPayload", "metadataSignature"}:
raise InvalidRelease("existing feed must have authenticated metadata")
payload_bytes = decode_tauri(envelope["signedPayload"])
verify_payload_bytes(payload_bytes, envelope["metadataSignature"], public_key, minisign)
payload = strict_json(payload_bytes)
if payload != {key: envelope[key] for key in FIELDS}:
raise InvalidRelease("existing feed fields differ from signed metadata")
return payload
def validate_payload(payload, downloads_root, public_key, minisign):
if not isinstance(payload, dict) or set(payload) != FIELDS:
raise InvalidRelease("payload must contain exactly the four Tauri release fields")
version_tuple(payload["version"])
if not isinstance(payload["notes"], str) or len(payload["notes"]) > 8000:
raise InvalidRelease("release notes must contain at most 8000 characters")
if not isinstance(payload["pub_date"], str):
raise InvalidRelease("release date must be RFC3339 UTC")
try:
datetime.strptime(payload["pub_date"], "%Y-%m-%dT%H:%M:%SZ")
except ValueError as exc:
raise InvalidRelease("release date must be RFC3339 UTC") from exc
platforms = payload["platforms"]
if not isinstance(platforms, dict) or not platforms:
raise InvalidRelease("at least one signed updater artifact is required")
validate_linux_aliases(platforms)
release_dir = downloads_root.resolve() / payload["version"]
if release_dir.is_symlink() or not release_dir.is_dir():
raise InvalidRelease("release directory must be an existing real directory")
prefix = ORIGIN + payload["version"] + "/"
for platform, artifact in platforms.items():
if not isinstance(artifact, dict) or set(artifact) != {"url", "signature"}:
raise InvalidRelease("artifact requires exactly url and signature")
url = artifact["url"]
if not isinstance(url, str) or not url.startswith(prefix):
raise InvalidRelease("artifact must use the fixed ShaCraft release URL")
filename = artifact_name(platform, url[len(prefix):])
local_path = release_dir / filename
before = regular_file(local_path, MAX_ARTIFACT_BYTES)
verify_signature(local_path, artifact["signature"], public_key, minisign)
validate_artifact_format(platform, local_path, payload["version"])
after = regular_file(local_path, MAX_ARTIFACT_BYTES)
if (before.st_ino, before.st_size, before.st_mtime_ns) != (
after.st_ino, after.st_size, after.st_mtime_ns
):
raise InvalidRelease("artifact changed during verification")
def atomic_write(destination, data):
destination.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary = tempfile.mkstemp(prefix="." + destination.name + ".", dir=destination.parent)
try:
with os.fdopen(descriptor, "wb") as stream:
os.fchmod(stream.fileno(), 0o644)
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, destination)
directory = os.open(destination.parent, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory)
finally:
os.close(directory)
finally:
with contextlib.suppress(FileNotFoundError):
os.unlink(temporary)
def prepare(args, public_key):
version_tuple(args.version)
artifacts = {}
for item in args.artifact:
platform, separator, filename = item.partition("=")
if not separator or platform in artifacts:
raise InvalidRelease("use each --artifact PLATFORM=FILENAME exactly once")
artifact_name(platform, filename)
signature = read_file(args.downloads_root / args.version / (filename + ".sig"), 16384).decode("ascii").strip()
artifacts[platform] = {
"url": ORIGIN + args.version + "/" + filename,
"signature": signature,
}
payload = {
"version": args.version,
"notes": read_file(args.notes_file).decode("utf-8").strip(),
"pub_date": args.pub_date or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"platforms": artifacts,
}
validate_payload(payload, args.downloads_root, public_key, args.minisign)
atomic_write(args.payload, canonical(payload))
return payload
def publish(args, public_key):
payload_bytes = read_file(args.payload)
payload = strict_json(payload_bytes)
if not isinstance(payload, dict) or set(payload) != FIELDS:
raise InvalidRelease("payload must contain exactly the four Tauri release fields")
signature = read_file(args.signature, 16384).decode("ascii").strip()
if canonical(payload) != payload_bytes:
raise InvalidRelease("payload must be the exact canonical file from prepare")
# Verify the captured bytes, so a changing operator input cannot replace
# a verified file with different bytes in the feed.
verify_payload_bytes(payload_bytes, signature, public_key, args.minisign)
envelope = dict(payload)
envelope["signedPayload"] = base64.b64encode(payload_bytes).decode("ascii")
envelope["metadataSignature"] = signature
data = json.dumps(envelope, ensure_ascii=False, indent=2).encode("utf-8") + b"\n"
if len(data) > MAX_METADATA_BYTES:
raise InvalidRelease("signed metadata exceeds size limit")
args.output.parent.mkdir(parents=True, exist_ok=True)
lock = args.output.with_name("." + args.output.name + ".lock")
descriptor = os.open(lock, os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW, 0o600)
with os.fdopen(descriptor, "wb") as lock_stream:
fcntl.flock(lock_stream, fcntl.LOCK_EX)
if args.output.exists() or args.output.is_symlink():
previous = verified_previous(read_file(args.output), public_key, args.minisign)
if version_tuple(payload["version"]) <= version_tuple(previous["version"]):
raise InvalidRelease("stable publication must strictly increase version")
validate_payload(payload, args.downloads_root, public_key, args.minisign)
if not args.dry_run:
atomic_write(args.output, data)
return payload
def main():
parser = argparse.ArgumentParser(description=__doc__)
common = argparse.ArgumentParser(add_help=False)
common.add_argument("--downloads-root", type=Path, required=True)
common.add_argument("--public-key", type=Path, required=True, help="Tauri outer-base64 .pub file")
common.add_argument("--minisign", default="minisign")
common.add_argument("--payload", type=Path, required=True)
commands = parser.add_subparsers(dest="command", required=True)
prepare_parser = commands.add_parser("prepare", parents=[common])
prepare_parser.add_argument("--version", required=True)
prepare_parser.add_argument("--artifact", action="append", required=True, metavar="PLATFORM=FILENAME")
prepare_parser.add_argument("--notes-file", type=Path, required=True)
prepare_parser.add_argument("--pub-date")
publish_parser = commands.add_parser("publish", parents=[common])
publish_parser.add_argument("--signature", type=Path, required=True)
publish_parser.add_argument("--output", type=Path, required=True)
publish_parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
try:
public_key = read_file(args.public_key, 16384).decode("ascii").strip()
payload = prepare(args, public_key) if args.command == "prepare" else publish(args, public_key)
except (InvalidRelease, OSError, UnicodeError) as exc:
parser.exit(1, f"Release rejected: {exc}\n")
print(f"{args.command}: {payload['version']} ({', '.join(sorted(payload['platforms']))})")
if __name__ == "__main__":
main()
+5
View File
@@ -0,0 +1,5 @@
{
"bundle": {
"createUpdaterArtifacts": false
}
}
+287
View File
@@ -0,0 +1,287 @@
"""Publisher policy and real minisign verification; keys exist only in tempdirs."""
import argparse
import base64
import copy
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
import publish_launcher_update as publisher
MINISIGN = os.environ.get("SHACRAFT_TEST_MINISIGN", "minisign")
def appimage_fixture():
header = bytearray(64)
header[:7] = b"\x7fELF\x02\x01\x01"
header[8:11] = b"AI\x02"
header[18:20] = b"\x3e\x00"
return bytes(header) + b"isolated format fixture; not a runnable launcher"
class PolicyTests(unittest.TestCase):
def test_stable_versions_are_strict_and_order_numerically(self):
self.assertGreater(publisher.version_tuple("0.1.10"), publisher.version_tuple("0.1.9"))
for value in ("v0.1.3", "0.01.3", "0.1.3-beta", "0.1.3+build", "../0.1.3", 3):
with self.subTest(value=value), self.assertRaises(publisher.InvalidRelease):
publisher.version_tuple(value)
def test_platform_filename_policy(self):
publisher.artifact_name("linux-x86_64", "ShaCraft.Launcher_0.1.3_amd64.AppImage")
publisher.artifact_name("linux-x86_64-appimage", "ShaCraft.Launcher_0.1.4_amd64.AppImage")
publisher.artifact_name("linux-x86_64-deb", "ShaCraft.Launcher_0.1.4_amd64.deb")
for platform, filename in (
("linux-x86_64", "../bad.AppImage"), ("linux-x86_64", "foo.AppImage?secret"),
("linux-x86_64", "%2e%2e.AppImage"), ("linux-x86_64", "install.exe"),
("unknown", "test.AppImage"), ("darwin-aarch64", "installer.dmg"),
("linux-x86_64", "install.deb"), ("linux-x86_64-appimage", "install.deb"),
("linux-x86_64-deb", "install.AppImage"),
):
with self.subTest(filename=filename), self.assertRaises(publisher.InvalidRelease):
publisher.artifact_name(platform, filename)
def test_duplicate_json_keys_are_rejected(self):
with self.assertRaises(publisher.InvalidRelease):
publisher.strict_json(b'{"version":"0.1.3","version":"9.0.0"}')
def test_package_inspection_is_bounded_and_clears_environment(self):
with self.assertRaisesRegex(publisher.InvalidRelease, "output limit"):
publisher.bounded_command_output([sys.executable, "-c", "print('x' * 8192)"], limit=128)
with self.assertRaisesRegex(publisher.InvalidRelease, "timed out"):
publisher.bounded_command_output([sys.executable, "-c", "import time; time.sleep(30)"], timeout=0.1)
os.environ["SHACRAFT_INSPECTION_SECRET_TEST"] = "must-not-be-inherited"
try:
output = publisher.bounded_command_output([
sys.executable, "-c", "import os; print(os.getenv('SHACRAFT_INSPECTION_SECRET_TEST', 'clean'))",
])
finally:
del os.environ["SHACRAFT_INSPECTION_SECRET_TEST"]
self.assertEqual(output, b"clean\n")
@unittest.skipUnless(shutil.which(MINISIGN), "minisign CLI required for signature integration tests")
class SignatureTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory(prefix="shacraft-update-test-")
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.key = self.root / "fixture.key"
public = self.root / "fixture.pub"
subprocess.run(
[MINISIGN, "-G", "-W", "-p", str(public), "-s", str(self.key)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True,
)
self.public_key = base64.b64encode(public.read_bytes()).decode("ascii")
self.downloads = self.root / "downloads"
release = self.downloads / "0.1.3"
release.mkdir(parents=True)
self.artifact = release / "fixture.AppImage"
self.artifact.write_bytes(appimage_fixture())
self.payload = {
"version": "0.1.3", "notes": "Проверка обновления", "pub_date": "2026-09-10T00:00:00Z",
"platforms": {"linux-x86_64": {
"url": publisher.ORIGIN + "0.1.3/fixture.AppImage",
"signature": self.sign(self.artifact),
}},
}
self.payload_path = self.root / "payload.json"
self.output = self.root / "stable.json"
def sign(self, path):
signature_path = path.with_name(path.name + ".minisig")
subprocess.run(
[MINISIGN, "-S", "-s", str(self.key), "-m", str(path), "-x", str(signature_path), "-q"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True,
)
signature = base64.b64encode(signature_path.read_bytes()).decode("ascii")
path.with_name(path.name + ".sig").write_text(signature, encoding="ascii")
return signature
def publish(self, payload=None, dry_run=False):
self.payload_path.write_bytes(publisher.canonical(payload or self.payload))
self.sign(self.payload_path)
args = argparse.Namespace(
payload=self.payload_path, signature=self.payload_path.with_name("payload.json.sig"),
output=self.output, downloads_root=self.downloads, minisign=MINISIGN, dry_run=dry_run,
)
return publisher.publish(args, self.public_key)
def test_valid_signed_feed_binds_metadata_and_artifact(self):
self.publish()
envelope = publisher.strict_json(self.output.read_bytes())
payload_bytes = publisher.decode_tauri(envelope["signedPayload"])
self.assertEqual(publisher.strict_json(payload_bytes), self.payload)
self.assertEqual({key: envelope[key] for key in publisher.FIELDS}, self.payload)
self.assertIn("metadataSignature", envelope)
self.assertEqual(self.output.stat().st_mode & 0o777, 0o644)
def test_tampered_artifact_is_rejected_before_publication(self):
self.artifact.write_bytes(b"replaced executable")
with self.assertRaisesRegex(publisher.InvalidRelease, "signature verification failed"):
self.publish()
self.assertFalse(self.output.exists())
def test_tampered_metadata_signature_is_rejected(self):
self.payload_path.write_bytes(publisher.canonical(self.payload))
signature = self.sign(self.payload_path)
self.payload["notes"] = "Changed after signing"
self.payload_path.write_bytes(publisher.canonical(self.payload))
with self.assertRaisesRegex(publisher.InvalidRelease, "signature verification failed"):
publisher.verify_signature(self.payload_path, signature, self.public_key, MINISIGN)
def test_same_version_or_downgrade_keeps_original_feed(self):
self.publish()
original = self.output.read_bytes()
for version in ("0.1.3", "0.1.2"):
payload = copy.deepcopy(self.payload)
payload["version"] = version
with self.subTest(version=version), self.assertRaisesRegex(
publisher.InvalidRelease, "strictly increase"
):
self.publish(payload)
self.assertEqual(self.output.read_bytes(), original)
def test_foreign_url_cannot_be_signed_into_feed(self):
self.payload["platforms"]["linux-x86_64"]["url"] = "https://example.com/test.AppImage"
with self.assertRaisesRegex(publisher.InvalidRelease, "fixed ShaCraft release URL"):
self.publish()
self.assertFalse(self.output.exists())
def test_previous_version_must_also_be_authenticated(self):
self.publish()
envelope = publisher.strict_json(self.output.read_bytes())
envelope["version"] = "99.0.0"
self.output.write_bytes(publisher.canonical(envelope))
with self.assertRaisesRegex(publisher.InvalidRelease, "differ from signed metadata"):
self.publish()
def test_missing_signature_or_symlink_is_rejected(self):
original = self.artifact.read_bytes()
target = self.root / "outside.AppImage"
target.write_bytes(original)
self.artifact.unlink()
self.artifact.symlink_to(target)
with self.assertRaisesRegex(publisher.InvalidRelease, "regular file"):
self.publish()
self.artifact.unlink()
self.artifact.write_bytes(original)
self.payload["platforms"]["linux-x86_64"].pop("signature")
with self.assertRaisesRegex(publisher.InvalidRelease, "exactly url and signature"):
self.publish()
def test_dry_run_verifies_without_creating_feed(self):
self.publish(dry_run=True)
self.assertFalse(self.output.exists())
def make_deb(self, package="sha-craft-launcher", version=None, architecture="amd64"):
if not Path(publisher.DPKG_DEB).is_file():
self.skipTest("dpkg-deb required for real deb validation")
version = version or self.payload["version"]
tree = self.root / "deb-tree"
control = tree / "DEBIAN"
control.mkdir(parents=True, exist_ok=True)
(control / "control").write_text(
f"Package: {package}\nVersion: {version}\nArchitecture: {architecture}\n"
"Maintainer: Test <test@example.invalid>\nDescription: isolated updater fixture\n",
encoding="ascii",
)
# Inspection must not execute a package script, even for an authenticated package.
script = control / "preinst"
script.write_text(f"#!/bin/sh\ntouch '{self.root / 'script-executed'}'\n", encoding="ascii")
script.chmod(0o755)
deb = self.artifact.with_name("fixture.deb")
subprocess.run(
[publisher.DPKG_DEB, "--build", "--root-owner-group", str(tree), str(deb)],
env=publisher.PACKAGE_TOOL_ENV, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
timeout=10, check=True,
)
self.payload["platforms"]["linux-x86_64-appimage"] = copy.deepcopy(
self.payload["platforms"]["linux-x86_64"]
)
self.payload["platforms"]["linux-x86_64-deb"] = {
"url": publisher.ORIGIN + self.payload["version"] + "/fixture.deb", "signature": self.sign(deb),
}
return deb
def test_format_aware_release_preserves_legacy_appimage_and_verifies_real_deb(self):
self.make_deb()
notes = self.root / "notes.txt"
notes.write_text(self.payload["notes"], encoding="utf-8")
args = argparse.Namespace(
version="0.1.3", artifact=[
"linux-x86_64=fixture.AppImage", "linux-x86_64-appimage=fixture.AppImage",
"linux-x86_64-deb=fixture.deb",
], downloads_root=self.downloads, notes_file=notes, payload=self.payload_path,
pub_date=self.payload["pub_date"], minisign=MINISIGN,
)
self.assertEqual(publisher.prepare(args, self.public_key), self.payload)
self.publish()
feed = publisher.verified_previous(self.output.read_bytes(), self.public_key, MINISIGN)
self.assertEqual(feed["platforms"]["linux-x86_64"], feed["platforms"]["linux-x86_64-appimage"])
self.assertEqual(set(feed["platforms"]), {"linux-x86_64", "linux-x86_64-appimage", "linux-x86_64-deb"})
self.assertFalse((self.root / "script-executed").exists())
def test_authenticated_legacy_feed_advances_to_format_aware_release(self):
self.publish()
old_release = self.artifact.parent
old_bytes = self.artifact.read_bytes()
new_release = self.downloads / "0.1.4"
shutil.copytree(old_release, new_release)
self.artifact = new_release / self.artifact.name
self.payload["version"] = "0.1.4"
self.payload["platforms"]["linux-x86_64"]["url"] = publisher.ORIGIN + "0.1.4/fixture.AppImage"
self.make_deb()
self.publish()
feed = publisher.verified_previous(self.output.read_bytes(), self.public_key, MINISIGN)
self.assertEqual(feed["version"], "0.1.4")
self.assertEqual(len(feed["platforms"]), 3)
self.assertEqual((old_release / self.artifact.name).read_bytes(), old_bytes)
def test_linux_format_release_cannot_drop_or_repoint_legacy_entry(self):
self.make_deb()
for key in ("linux-x86_64", "linux-x86_64-appimage"):
payload = copy.deepcopy(self.payload)
del payload["platforms"][key]
with self.subTest(key=key), self.assertRaisesRegex(publisher.InvalidRelease, "identical legacy"):
self.publish(payload)
payload = copy.deepcopy(self.payload)
payload["platforms"]["linux-x86_64-appimage"]["url"] = publisher.ORIGIN + "0.1.3/other.AppImage"
with self.assertRaisesRegex(publisher.InvalidRelease, "identical legacy"):
self.publish(payload)
self.assertFalse(self.output.exists())
def test_deb_identity_must_match_application_signed_version_and_architecture(self):
for changes in ({"package": "another-launcher"}, {"version": "9.0.0"}, {"architecture": "arm64"}):
with self.subTest(changes=changes):
self.make_deb(**changes)
with self.assertRaisesRegex(publisher.InvalidRelease, "deb identity"):
self.publish()
self.assertFalse(self.output.exists())
def test_signed_invalid_deb_is_rejected_without_running_package_scripts(self):
deb = self.make_deb()
deb.write_bytes(b"not a Debian archive")
self.payload["platforms"]["linux-x86_64-deb"]["signature"] = self.sign(deb)
with self.assertRaisesRegex(publisher.InvalidRelease, "package inspection failed"):
self.publish()
self.assertFalse((self.root / "script-executed").exists())
self.assertFalse(self.output.exists())
def test_signed_wrong_appimage_format_is_rejected(self):
for changed_slice, replacement in ((slice(8, 11), b"AI\x01"), (slice(18, 20), b"\xb7\x00")):
malformed = bytearray(appimage_fixture())
malformed[changed_slice] = replacement
self.artifact.write_bytes(malformed)
self.payload["platforms"]["linux-x86_64"]["signature"] = self.sign(self.artifact)
with self.subTest(replacement=replacement), self.assertRaisesRegex(publisher.InvalidRelease, "type-2 x86_64"):
self.publish()
self.assertFalse(self.output.exists())
if __name__ == "__main__":
unittest.main()
+332 -8
View File
@@ -1770,6 +1770,36 @@ dependencies = [
"windows-sys 0.45.0",
]
[[package]]
name = "jni"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
dependencies = [
"cfg-if",
"combine",
"jni-macros",
"jni-sys 0.4.1",
"log",
"simd_cesu8",
"thiserror 2.0.20",
"walkdir",
"windows-link 0.2.1",
]
[[package]]
name = "jni-macros"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"simd_cesu8",
"syn 2.0.119",
]
[[package]]
name = "jni-sys"
version = "0.3.1"
@@ -1975,6 +2005,12 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2217,6 +2253,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.13.1",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
@@ -2232,6 +2269,18 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-osa-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
dependencies = [
"bitflags 2.13.1",
"objc2",
"objc2-app-kit",
"objc2-foundation",
]
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
@@ -2295,12 +2344,32 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "osakit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
dependencies = [
"objc2",
"objc2-foundation",
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 2.0.20",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -2817,15 +2886,20 @@ dependencies = [
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
"serde_json",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
@@ -2893,6 +2967,18 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
@@ -2903,6 +2989,33 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
dependencies = [
"core-foundation",
"core-foundation-sys",
"jni 0.22.4",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.15"
@@ -2935,6 +3048,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "schemars"
version = "0.8.22"
@@ -2992,6 +3114,29 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.1",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "selectors"
version = "0.36.1"
@@ -3216,13 +3361,18 @@ dependencies = [
[[package]]
name = "shacraft-launcher"
version = "0.1.1"
version = "0.1.6"
dependencies = [
"base64 0.22.1",
"ed25519-dalek",
"flate2",
"getrandom 0.3.4",
"libc",
"md-5",
"minisign-verify",
"reqwest 0.12.28",
"reqwest 0.13.4",
"semver",
"serde",
"serde_json",
"sha1",
@@ -3230,8 +3380,11 @@ dependencies = [
"tar",
"tauri",
"tauri-build",
"tauri-plugin-updater",
"tempfile",
"url",
"zip",
"zeroize",
"zip 2.4.2",
]
[[package]]
@@ -3255,6 +3408,22 @@ version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "simd_cesu8"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
dependencies = [
"rustc_version",
"simdutf8",
]
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "siphasher"
version = "1.0.3"
@@ -3477,7 +3646,7 @@ dependencies = [
"gdkwayland-sys",
"gdkx11-sys",
"gtk",
"jni",
"jni 0.21.1",
"libc",
"log",
"ndk",
@@ -3544,7 +3713,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"jni",
"jni 0.21.1",
"libc",
"log",
"mime",
@@ -3640,6 +3809,53 @@ dependencies = [
"tauri-utils",
]
[[package]]
name = "tauri-plugin"
version = "2.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020"
dependencies = [
"anyhow",
"glob",
"plist",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri-utils",
"walkdir",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.11.0"
dependencies = [
"base64 0.22.1",
"dirs",
"flate2",
"futures-util",
"http",
"infer",
"log",
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest 0.13.4",
"rustls",
"semver",
"serde",
"serde_json",
"tar",
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.20",
"time",
"tokio",
"url",
"windows-sys 0.60.2",
"zip 4.6.1",
]
[[package]]
name = "tauri-runtime"
version = "2.11.3"
@@ -3650,7 +3866,7 @@ dependencies = [
"dpi",
"gtk",
"http",
"jni",
"jni 0.21.1",
"objc2",
"objc2-ui-kit",
"objc2-web-kit",
@@ -3673,7 +3889,7 @@ checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f"
dependencies = [
"gtk",
"http",
"jni",
"jni 0.21.1",
"log",
"objc2",
"objc2-app-kit",
@@ -3740,6 +3956,19 @@ dependencies = [
"toml 1.1.5+spec-1.1.0",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "tendril"
version = "0.5.1"
@@ -4417,6 +4646,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
@@ -4674,6 +4912,15 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -4707,13 +4954,30 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm",
"windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link 0.2.1",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
]
[[package]]
name = "windows-threading"
version = "0.1.0"
@@ -4744,6 +5008,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
@@ -4756,6 +5026,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
@@ -4768,12 +5044,24 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
@@ -4786,6 +5074,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
@@ -4798,6 +5092,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
@@ -4810,6 +5110,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
@@ -4822,6 +5128,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.5.40"
@@ -4886,7 +5198,7 @@ dependencies = [
"gtk",
"http",
"javascriptcore-rs",
"jni",
"jni 0.21.1",
"libc",
"ndk",
"objc2",
@@ -5043,6 +5355,18 @@ dependencies = [
"zopfli",
]
[[package]]
name = "zip"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"indexmap 2.14.2",
"memchr",
]
[[package]]
name = "zlib-rs"
version = "0.6.7"
+13 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "shacraft-launcher"
version = "0.1.1"
version = "0.1.6"
description = "ShaCraft Minecraft launcher"
authors = ["ShaCraft"]
license = "MIT"
@@ -20,10 +20,21 @@ sha1 = "0.10"
sha2 = "0.10"
md-5 = "0.10"
base64 = "0.22"
ed25519-dalek = "2"
ed25519-dalek = { version = "2", features = ["pkcs8"] }
getrandom = "0.3"
zeroize = "1"
libc = "0.2"
tempfile = "3"
tauri = { version = "2", features = [] }
tauri-plugin-updater = { version = "=2.11.0", path = "../vendor/tauri-plugin-updater", default-features = false, features = ["rustls-tls", "zip"] }
reqwest-updater = { package = "reqwest", version = "0.13", default-features = false }
minisign-verify = "0.2"
semver = "1"
url = "2"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls", "json"] }
flate2 = "1"
tar = "0.4"
zip = { version = "2", default-features = false, features = ["deflate"] }
[dev-dependencies]
tauri = { version = "2", features = ["test"] }
+244
View File
@@ -0,0 +1,244 @@
//! Ephemeral proof of possession for one Aeronautics connection.
//!
//! Neither this private key nor its ticket crosses IPC, enters launch arguments,
//! or is persisted. Only the new Java child's environment receives them. A new
//! launch obtains a new key and ticket; account session credentials stay native.
use crate::session::PlayerIdentity;
use base64::{
engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
Engine,
};
use ed25519_dalek::{
pkcs8::{EncodePrivateKey, KeypairBytes},
SigningKey,
};
use serde::{Deserialize, Serialize};
use std::process::Command;
use zeroize::Zeroizing;
pub(crate) const TICKET_ENV: &str = "SHACRAFT_ADMISSION_TICKET";
pub(crate) const PRIVATE_KEY_ENV: &str = "SHACRAFT_ADMISSION_PRIVATE_KEY";
#[cfg(test)]
const SERVER_ID: &str = "aoc";
pub(crate) fn server_for_profile(profile: &str) -> Result<&'static str, &'static str> {
match profile {
"aeronautics" => Ok("aoc"),
"minigames" => Ok("minigames"),
_ => Err("Unknown ShaCraft profile"),
}
}
const MAX_LIFETIME_SECONDS: u64 = 600;
// Deliberately no Debug, Clone or Serialize for secret-bearing values.
pub(crate) struct AdmissionKey {
server_id: &'static str,
public_key: String,
private_key: Zeroizing<String>,
}
#[derive(Serialize)]
pub(crate) struct TicketRequest<'a> {
server_id: &'static str,
public_key: &'a str,
}
#[derive(Deserialize)]
pub(crate) struct TicketResponse {
ticket_id: String,
mc_username: String,
server_id: String,
expires_in_seconds: u64,
}
pub(crate) struct Admission {
server_id: &'static str,
ticket_id: Zeroizing<String>,
private_key: Zeroizing<String>,
identity: PlayerIdentity,
}
impl AdmissionKey {
pub(crate) fn generate(server_id: &'static str) -> Result<Self, &'static str> {
if !matches!(server_id, "aoc" | "minigames") { return Err("Unknown ShaCraft server"); }
let mut seed = Zeroizing::new([0_u8; 32]);
getrandom::fill(seed.as_mut())
.map_err(|_| "Не удалось создать защищённый ключ входа. Повторите запуск лаунчера.")?;
let signing_key = SigningKey::from_bytes(&seed);
let public_key = STANDARD.encode(signing_key.verifying_key().to_bytes());
// RFC 8410 PKCS#8 v1 (PrivateKeyInfo) without the optional public key.
// This is accepted by Java 21's Ed25519 KeyFactory/PKCS8EncodedKeySpec.
let key_bytes = KeypairBytes {
secret_key: signing_key.to_bytes(),
public_key: None,
};
let encoded = key_bytes
.to_pkcs8_der()
.map_err(|_| "Не удалось подготовить защищённый ключ входа.")?;
Ok(Self {
server_id,
public_key,
private_key: Zeroizing::new(STANDARD.encode(encoded.as_bytes())),
})
}
pub(crate) fn request(&self) -> TicketRequest<'_> {
TicketRequest {
server_id: self.server_id,
public_key: &self.public_key,
}
}
pub(crate) fn bind(self, response: TicketResponse) -> Result<Admission, &'static str> {
let ticket_id = Zeroizing::new(response.ticket_id);
let valid_ticket = ticket_id.len() == 43
&& URL_SAFE_NO_PAD
.decode(ticket_id.as_bytes())
.is_ok_and(|bytes| {
bytes.len() == 32 && URL_SAFE_NO_PAD.encode(bytes) == *ticket_id
});
let valid_nickname = (3..=16).contains(&response.mc_username.len())
&& response
.mc_username
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_');
if !valid_ticket
|| !valid_nickname
|| response.server_id != self.server_id
|| !(1..=MAX_LIFETIME_SECONDS).contains(&response.expires_in_seconds)
{
return Err("Сервер вернул некорректное разрешение на вход. Повторите попытку позже.");
}
Ok(Admission {
server_id: self.server_id,
ticket_id,
private_key: self.private_key,
identity: PlayerIdentity::Offline {
name: response.mc_username,
},
})
}
}
impl Admission {
pub(crate) fn server_id(&self) -> &str { self.server_id }
pub(crate) fn identity(&self) -> &PlayerIdentity {
&self.identity
}
pub(crate) fn configure_child(&self, command: &mut Command) {
command.env(TICKET_ENV, self.ticket_id.as_str());
command.env(PRIVATE_KEY_ENV, self.private_key.as_str());
}
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::{pkcs8::DecodePrivateKey, Signer, Verifier};
fn response() -> TicketResponse {
TicketResponse {
ticket_id: URL_SAFE_NO_PAD.encode([37_u8; 32]),
mc_username: "Canonical_Name".into(),
server_id: SERVER_ID.into(),
expires_in_seconds: 600,
}
}
#[test]
fn profile_tickets_are_server_bound_with_shared_canonical_identity() {
assert_eq!(server_for_profile("aeronautics").unwrap(), "aoc");
assert_eq!(server_for_profile("minigames").unwrap(), "minigames");
assert!(server_for_profile("../../other").is_err());
assert!(AdmissionKey::generate("other").is_err());
assert!(AdmissionKey::generate("minigames").unwrap().bind(response()).is_err());
let key=AdmissionKey::generate("minigames").unwrap();
assert_eq!(serde_json::to_value(key.request()).unwrap()["server_id"], "minigames");
let mut payload=response(); payload.server_id="minigames".into();
let admitted=key.bind(payload).unwrap();
assert_eq!(admitted.server_id(), "minigames");
assert_eq!(admitted.identity().name(), "Canonical_Name");
}
#[test]
fn generates_distinct_keys_and_only_sends_the_public_key() {
let key = AdmissionKey::generate("aoc").unwrap();
let other = AdmissionKey::generate("aoc").unwrap();
assert_ne!(key.public_key, other.public_key);
let payload = serde_json::to_value(key.request()).unwrap();
assert_eq!(payload.as_object().unwrap().len(), 2);
assert_eq!(payload["server_id"], SERVER_ID);
assert_eq!(payload["public_key"], key.public_key);
assert!(!payload.to_string().contains(key.private_key.as_str()));
let der = Zeroizing::new(STANDARD.decode(key.private_key.as_bytes()).unwrap());
let restored = SigningKey::from_pkcs8_der(&der).unwrap();
assert_eq!(
STANDARD.encode(restored.verifying_key().to_bytes()),
key.public_key
);
let message = b"shacraft-admission-v1:challenge-fixture";
restored
.verifying_key()
.verify(message, &restored.sign(message))
.unwrap();
// Java's standard Ed25519 encoding is the 48-byte private-key-only form.
assert_eq!(der.len(), 48);
}
#[test]
fn rejects_untrusted_identity_ticket_server_and_expiry() {
let mutations: Vec<Box<dyn Fn(&mut TicketResponse)>> = vec![
Box::new(|r| r.ticket_id = "../unsafe".into()),
Box::new(|r| r.ticket_id = "A".repeat(42) + "!"),
Box::new(|r| r.ticket_id = "A".repeat(42) + "B"),
Box::new(|r| r.mc_username = "../../outside".into()),
Box::new(|r| r.mc_username = "ab".into()),
Box::new(|r| r.mc_username = "a".repeat(17)),
Box::new(|r| r.server_id = "other".into()),
Box::new(|r| r.expires_in_seconds = 0),
Box::new(|r| r.expires_in_seconds = 601),
];
for mutate in mutations {
let mut payload = response();
mutate(&mut payload);
assert!(AdmissionKey::generate("aoc").unwrap().bind(payload).is_err());
}
}
#[test]
fn secrets_only_enter_the_child_environment_and_identity_comes_from_ticket() {
let original_ticket = std::env::var_os(TICKET_ENV);
let original_key = std::env::var_os(PRIVATE_KEY_ENV);
let admission = AdmissionKey::generate("aoc").unwrap().bind(response()).unwrap();
let mut command = Command::new("java");
command
.arg("-Xmx6144M")
.arg("net.minecraft.client.main.Main");
admission.configure_child(&mut command);
assert_eq!(admission.identity().name(), "Canonical_Name");
let env: std::collections::HashMap<_, _> = command.get_envs().collect();
assert_eq!(
env.get(std::ffi::OsStr::new(TICKET_ENV)).unwrap().unwrap(),
admission.ticket_id.as_str()
);
assert_eq!(
env.get(std::ffi::OsStr::new(PRIVATE_KEY_ENV))
.unwrap()
.unwrap(),
admission.private_key.as_str()
);
assert_eq!(env.len(), 2);
for argument in command.get_args() {
assert!(!argument
.to_string_lossy()
.contains(admission.ticket_id.as_str()));
assert!(!argument
.to_string_lossy()
.contains(admission.private_key.as_str()));
}
assert_eq!(std::env::var_os(TICKET_ENV), original_ticket);
assert_eq!(std::env::var_os(PRIVATE_KEY_ENV), original_key);
}
}
+15 -5
View File
@@ -1,7 +1,7 @@
use super::{data_dir, shacraft::resolve_identity};
use super::data_dir;
use crate::{
java, launch, manifest, mojang, neoforge, operations::LauncherOperations, remote, runtime,
settings,
fabric, java, launch, manifest, mojang, neoforge, operations::LauncherOperations, remote, runtime,
settings, shacraft_account,
};
use reqwest::blocking::Client;
use serde::Serialize;
@@ -56,6 +56,9 @@ fn resolve_merged_version(
)
.map_err(|error| error.to_string())?;
mojang::merge_versions(&vanilla, Some(&neoforge_version)).map_err(|error| error.to_string())
} else if manifest.minecraft.loader.kind == "fabric" {
let child = fabric::fetch_profile(&manifest.minecraft.version, &manifest.minecraft.loader.version)?;
mojang::merge_versions(&vanilla, Some(&child)).map_err(|error| error.to_string())
} else {
mojang::merge_versions(&vanilla, None).map_err(|error| error.to_string())
}
@@ -163,6 +166,7 @@ pub(crate) async fn launch_game(
let game_dir = data_dir(&app)?.join("game");
let data_dir = data_dir(&app)?;
let permit = state.installation.acquire("Installation")?;
let game_permit = state.game.acquire("Игра")?;
let account_operation = state.shacraft_account.clone();
tauri::async_runtime::spawn_blocking(move || -> Result<(), String> {
@@ -172,7 +176,6 @@ pub(crate) async fn launch_game(
let manifest = remote::fetch_manifest(&profile_id).map_err(|error| error.to_string())?;
let profile_dir = data_dir.join("profiles").join(&manifest.id);
let settings = settings::load(&data_dir).map_err(|error| error.to_string())?;
let identity = resolve_identity(&data_dir, &account_operation)?;
// Everything here should already be installed by `ensure_game_installed`,
// so these are expected to hit their fast paths; no progress to show.
@@ -201,12 +204,17 @@ pub(crate) async fn launch_game(
std::fs::create_dir_all(&log_dir).map_err(|error| error.to_string())?;
let log_path = log_dir.join(format!("{profile_id}-{timestamp}.log"));
// Generate fresh proof only after installation. Keep the account gate
// through spawn so local logout/account switching cannot race issuance.
let _account_permit = account_operation.acquire("ShaCraft account operation")?;
let admission =
shacraft_account::issue_admission(&data_dir, crate::admission::server_for_profile(&profile_id)?).map_err(|error| error.to_string())?;
let request = launch::LaunchRequest {
java_executable: Path::new(&java_install.executable),
game_dir: &game_dir,
profile_dir: &profile_dir,
merged: &merged,
identity: &identity,
admission: &admission,
memory_mb: settings.memory_mb,
log_path: &log_path,
};
@@ -215,7 +223,9 @@ pub(crate) async fn launch_game(
let watch_app = app.clone();
let watch_profile_id = profile_id.clone();
std::thread::spawn(move || {
let game_permit = game_permit;
let exit_code = child.wait().ok().and_then(|status| status.code());
drop(game_permit);
let _ = watch_app.emit(
"game-exited",
GameExited {
+1
View File
@@ -5,6 +5,7 @@ pub(crate) mod host;
pub(crate) mod preferences;
pub(crate) mod profiles;
pub(crate) mod shacraft;
pub(crate) mod updater;
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
+13 -36
View File
@@ -1,9 +1,6 @@
//! ShaCraft sessions and verified account links are the launch identity source.
use super::data_dir;
use crate::{
operations::{LauncherOperations, Operation},
session, shacraft_account,
};
use crate::{operations::LauncherOperations, shacraft_account};
use std::path::Path;
use tauri::{AppHandle, State};
@@ -75,6 +72,18 @@ pub(crate) async fn shacraft_start_link(
.await
}
#[tauri::command]
pub(crate) async fn shacraft_claim_nickname(
app: AppHandle,
state: State<'_, LauncherOperations>,
nickname: String,
) -> Result<shacraft_account::Account, String> {
account_task(&app, &state, move |directory| {
shacraft_account::claim_nickname(directory, &nickname)
})
.await
}
#[tauri::command]
pub(crate) async fn shacraft_link_status(
app: AppHandle,
@@ -86,35 +95,3 @@ pub(crate) async fn shacraft_link_status(
})
.await
}
/// Always revalidate the server session and its aoc link. Legacy local settings
/// and Microsoft tokens do not select the identity in the ShaCraft-only flow.
pub(super) fn resolve_identity(
directory: &Path,
operation: &Operation,
) -> Result<session::PlayerIdentity, String> {
let _permit = operation.acquire("ShaCraft account operation")?;
let name =
shacraft_account::aeronautics_nickname(directory).map_err(|error| error.to_string())?;
Ok(session::PlayerIdentity::Offline { name })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_shacraft_session_cannot_fall_back_to_legacy_nickname() {
let directory = std::env::temp_dir().join(format!(
"shacraft-identity-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
crate::settings::save(&directory, crate::settings::LauncherSettings::default()).unwrap();
assert!(resolve_identity(&directory, &Operation::default()).is_err());
std::fs::remove_dir_all(directory).unwrap();
}
}
+203
View File
@@ -0,0 +1,203 @@
use crate::{
operations::LauncherOperations,
updater::{self, LauncherUpdater, Stage, UpdateProgress, UpdateStatus},
};
use tauri::{AppHandle, Emitter, State};
#[tauri::command]
pub(crate) fn get_launcher_update_status(
app: AppHandle,
updater: State<'_, LauncherUpdater>,
) -> Result<UpdateStatus, String> {
updater.status(&app)
}
#[tauri::command]
pub(crate) async fn check_launcher_update(
app: AppHandle,
updater: State<'_, LauncherUpdater>,
) -> Result<UpdateStatus, String> {
let status = updater.status(&app)?;
if !status.supported || status.stage == Stage::Ready {
return Ok(status);
}
let permit = updater
.operation
.acquire("Проверка или установка обновления")
.map_err(|_| "Проверка или установка обновления уже выполняется.".to_string())?;
let updater = updater.inner().clone();
updater.set_stage(Stage::Checking)?;
// Detached task owns the permit: cancelling an IPC caller cannot release
// the operation while its native HTTP request is still in flight.
tauri::async_runtime::spawn(async move {
let _permit = permit;
let result = async {
let key = updater::public_key(&app)?;
updater::check_candidate(
updater::trusted_builder(&app)?,
&key,
&app.package_info().version.to_string(),
updater::installation_kind(&app),
)
.await
}
.await;
{
let mut state = updater
.state
.lock()
.map_err(|_| "Состояние обновления недоступно.".to_string())?;
match result {
Ok(candidate) => {
state.stage = if candidate.is_some() {
Stage::Available
} else {
Stage::Idle
};
state.candidate = candidate;
}
Err(error) => {
state.stage = if state.candidate.is_some() {
Stage::Available
} else {
Stage::Idle
};
return Err(error);
}
}
}
updater.status(&app)
})
.await
.map_err(|_| "Проверка обновления завершилась с ошибкой.".to_string())?
}
#[tauri::command]
pub(crate) async fn install_launcher_update(
app: AppHandle,
updater: State<'_, LauncherUpdater>,
operations: State<'_, LauncherOperations>,
) -> Result<(), String> {
if let Some(reason) = updater::unsupported_reason(&app) {
return Err(reason);
}
let permit = updater
.operation
.acquire("Проверка или установка обновления")
.map_err(|_| "Проверка или установка обновления уже выполняется.".to_string())?;
let candidate = {
let state = updater
.state
.lock()
.map_err(|_| "Состояние обновления недоступно.".to_string())?;
if state.stage == Stage::Ready {
return Err("Обновление уже установлено. Перезапустите лаунчер.".into());
}
state
.candidate
.clone()
.ok_or("Сначала проверьте наличие обновлений.")?
};
let destination = updater::installation_path(&app)?;
let mutation_permits = operations.acquire_update()
.map_err(|_| "Закройте Minecraft и дождитесь завершения установки или входа в аккаунт перед обновлением.".to_string())?;
let updater = updater.inner().clone();
updater.set_stage(Stage::Downloading)?;
tauri::async_runtime::spawn(async move {
let _permit = permit;
let result = async {
let key = updater::public_key(&app)?;
let _ = app.emit(
"launcher-update-progress",
UpdateProgress {
stage: Stage::Downloading,
downloaded_bytes: 0,
total_bytes: None,
},
);
let bytes =
updater::download_verified(&candidate, &key, |downloaded_bytes, total_bytes| {
let _ = app.emit(
"launcher-update-progress",
UpdateProgress {
stage: Stage::Downloading,
downloaded_bytes,
total_bytes,
},
);
})
.await?;
let size = bytes.len() as u64;
updater.set_stage(Stage::Installing)?;
let _ = app.emit(
"launcher-update-progress",
UpdateProgress {
stage: Stage::Installing,
downloaded_bytes: size,
total_bytes: Some(size),
},
);
tauri::async_runtime::spawn_blocking(move || {
updater::install_verified(&candidate, &bytes, &key, destination.as_deref())
})
.await
.map_err(|_| "Установка обновления завершилась с ошибкой.".to_string())??;
Ok::<_, String>(size)
}
.await;
match result {
Ok(size) => {
let mut state = updater
.state
.lock()
.map_err(|_| "Состояние обновления недоступно.".to_string())?;
state.stage = Stage::Ready;
state.restart_permits = Some(mutation_permits);
let _ = app.emit(
"launcher-update-progress",
UpdateProgress {
stage: Stage::Ready,
downloaded_bytes: size,
total_bytes: Some(size),
},
);
Ok(())
}
Err(error) => {
updater.set_stage(Stage::Available)?;
Err(error)
}
}
})
.await
.map_err(|_| "Установка обновления завершилась с ошибкой.".to_string())?
}
#[tauri::command]
pub(crate) fn restart_launcher_after_update(
app: AppHandle,
updater: State<'_, LauncherUpdater>,
) -> Result<(), String> {
let _permit = updater
.operation
.acquire("Установка обновления")
.map_err(|_| "Установка обновления ещё выполняется.".to_string())?;
{
let state = updater
.state
.lock()
.map_err(|_| "Состояние обновления недоступно.".to_string())?;
if state.stage != Stage::Ready || state.restart_permits.is_none() {
return Err("Сначала установите обновление лаунчера.".into());
}
}
#[cfg(target_os = "linux")]
if updater::installation_kind(&app) == updater::InstallationKind::Deb
|| crate::deb_updater::is_deleted_installed_binary()
{
crate::deb_updater::restart()?;
app.exit(0);
return Ok(());
}
app.restart()
}
+555
View File
@@ -0,0 +1,555 @@
//! The only elevated updater operation. pkexec starts this installed, root-owned
//! binary in an early non-GUI mode. Input is untrusted until BOTH signatures
//! are verified again here. No user-supplied path, command or password is used.
use crate::updater::{self, InstallationKind, MAX_ARTIFACT_BYTES, MAX_METADATA_BYTES};
use serde_json::Value;
use std::{
fs,
io::{self, Read, Write},
os::unix::{
fs::{MetadataExt, PermissionsExt},
process::CommandExt,
},
path::Path,
process::{Command, ExitStatus, Stdio},
sync::mpsc,
time::{Duration, Instant},
};
use tauri_plugin_updater::Update;
use url::Url;
const BINARY: &str = "/usr/bin/shacraft-launcher";
const HELPER_FLAG: &str = "--shacraft-install-deb";
const PACKAGE: &str = "sha-craft-launcher";
const PKEXEC: &str = "/usr/bin/pkexec";
const DPKG: &str = "/usr/bin/dpkg";
const QUERY: &str = "/usr/bin/dpkg-query";
const DEB: &str = "/usr/bin/dpkg-deb";
const OUTPUT_LIMIT: usize = 16 * 1024;
const INPUT_MAGIC: &[u8; 8] = b"SCDUPD01";
const REJECTED: i32 = 20;
const LOCKED: i32 = 21;
const INSTALL_FAILED: i32 = 22;
const INVALID_HOST: i32 = 23;
fn architecture() -> &'static str {
match std::env::consts::ARCH {
"x86_64" => "amd64",
"aarch64" => "arm64",
_ => "unsupported",
}
}
/// Validate the full path without following symlinks. The executable and every
/// parent must be root-owned and not writable by group/other users.
fn trusted_root_path(path: &Path, executable: bool) -> bool {
if !path.is_absolute()
|| path
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return false;
}
let mut leaf = true;
for item in path.ancestors() {
let Ok(meta) = fs::symlink_metadata(item) else {
return false;
};
if meta.file_type().is_symlink() || meta.uid() != 0 || meta.mode() & 0o022 != 0 {
return false;
}
if leaf && executable {
if !meta.is_file() || meta.mode() & 0o111 == 0 {
return false;
}
} else if !meta.is_dir() {
return false;
}
leaf = false;
}
true
}
fn safe_sticky_temporary_parent(path: &Path) -> bool {
fs::symlink_metadata(path).is_ok_and(|meta| {
meta.is_dir()
&& !meta.file_type().is_symlink()
&& meta.uid() == 0
&& (meta.mode() & 0o022 == 0 || meta.mode() & 0o1000 != 0)
})
}
fn fixed_command(path: &str) -> Command {
let mut command = Command::new(path);
command
.env_clear()
.env("PATH", "/usr/sbin:/usr/bin:/sbin:/bin")
.env("LC_ALL", "C");
command
}
fn drain_capped(mut source: impl Read) -> io::Result<Vec<u8>> {
let mut result = Vec::new();
let mut buffer = [0_u8; 4096];
loop {
let read = source.read(&mut buffer)?;
if read == 0 {
return Ok(result);
}
let remaining = OUTPUT_LIMIT.saturating_sub(result.len());
result.extend_from_slice(&buffer[..read.min(remaining)]);
}
}
struct Captured {
status: ExitStatus,
stdout: Vec<u8>,
stderr: Vec<u8>,
}
/// Drain both pipes concurrently; output can never make the root helper buffer
/// unbounded data or deadlock dpkg while it is changing the package database.
fn capture(command: &mut Command) -> io::Result<Captured> {
capture_with_deadline(command, Duration::from_secs(15))
}
fn capture_with_deadline(command: &mut Command, deadline: Duration) -> io::Result<Captured> {
// Read-only inspection has a deadline. Never kill dpkg during mutation:
// interrupting it could leave a partially configured installed package.
let inspection = command.get_program() != DPKG;
if inspection {
command.process_group(0);
}
let mut child = command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let stdout = child.stdout.take().expect("piped stdout");
let stderr = child.stderr.take().expect("piped stderr");
let (out_send, out_receive) = mpsc::channel();
let (err_send, err_receive) = mpsc::channel();
std::thread::spawn(move || {
let _ = out_send.send(drain_capped(stdout));
});
std::thread::spawn(move || {
let _ = err_send.send(drain_capped(stderr));
});
let start = Instant::now();
let mut stdout = None;
let mut stderr = None;
loop {
if stdout.is_none() {
stdout = out_receive.try_recv().ok();
}
if stderr.is_none() {
stderr = err_receive.try_recv().ok();
}
// Do not reap the parent before its pipes close. Its unreaped PID
// reserves the process-group id until a possible timeout kill below.
if stdout.is_some() && stderr.is_some() {
if let Some(status) = child.try_wait()? {
return Ok(Captured {
status,
stdout: stdout.unwrap()?,
stderr: stderr.unwrap()?,
});
}
}
if inspection && start.elapsed() > deadline {
// Also terminate dpkg-deb's decompressor descendants so they cannot
// retain the pipes after the inspection parent has been killed.
unsafe {
libc::kill(-(child.id() as i32), libc::SIGKILL);
}
let _ = child.wait();
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"package inspection timed out",
));
}
std::thread::sleep(Duration::from_millis(20));
}
}
fn installed_version() -> Result<String, ()> {
let result = capture(fixed_command(QUERY).args([
"--show",
"--showformat=${db:Status-Status}\n${Version}\n${Architecture}\n",
PACKAGE,
]))
.map_err(|_| ())?;
if !result.status.success() {
return Err(());
}
let fields = std::str::from_utf8(&result.stdout)
.map_err(|_| ())?
.lines()
.collect::<Vec<_>>();
if fields.len() != 3 || fields[0] != "installed" || fields[2] != architecture() {
return Err(());
}
let version = semver::Version::parse(fields[1]).map_err(|_| ())?;
if version.to_string() != fields[1] || !version.pre.is_empty() || !version.build.is_empty() {
return Err(());
}
// dpkg's database must also assign the precise executable to our package.
let owner = capture(fixed_command(QUERY).args(["--search", BINARY])).map_err(|_| ())?;
if !owner.status.success() || owner.stdout != format!("{PACKAGE}: {BINARY}\n").as_bytes() {
return Err(());
}
Ok(fields[1].to_owned())
}
pub(crate) fn installed_binary_supported() -> bool {
std::env::current_exe().is_ok_and(|path| path == Path::new(BINARY))
&& trusted_root_path(Path::new(BINARY), true)
&& [QUERY, DEB, DPKG]
.iter()
.all(|path| trusted_root_path(Path::new(path), true))
&& installed_version().is_ok()
}
pub(crate) fn unsupported_reason() -> Option<String> {
if trusted_root_path(Path::new(PKEXEC), true) {
None
} else {
Some("Для обновления deb нужен системный компонент pkexec (PolicyKit). Установите его или скачайте новый deb с shacraft.ru/help#launcher.".into())
}
}
pub(crate) fn is_deleted_installed_binary() -> bool {
std::env::current_exe()
.is_ok_and(|path| path == Path::new("/usr/bin/shacraft-launcher (deleted)"))
}
pub(crate) fn restart() -> Result<(), String> {
if !trusted_root_path(Path::new(BINARY), true) {
return Err("Установленный лаунчер недоступен. Запустите его из меню приложений.".into());
}
Command::new(BINARY).spawn().map_err(|_| {
"Не удалось перезапустить лаунчер. Запустите его из меню приложений.".to_string()
})?;
Ok(())
}
fn write_input(mut output: impl Write, metadata: &[u8], bytes: &[u8]) -> io::Result<()> {
if metadata.len() > MAX_METADATA_BYTES || bytes.len() > MAX_ARTIFACT_BYTES {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"update exceeds input limit",
));
}
output.write_all(INPUT_MAGIC)?;
output.write_all(&(metadata.len() as u64).to_be_bytes())?;
output.write_all(metadata)?;
output.write_all(&(bytes.len() as u64).to_be_bytes())?;
output.write_all(bytes)
}
fn read_part(input: &mut impl Read, maximum: usize) -> io::Result<Vec<u8>> {
let mut length = [0_u8; 8];
input.read_exact(&mut length)?;
let length = u64::from_be_bytes(length);
if length == 0 || length > maximum as u64 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"invalid update input length",
));
}
let mut bytes = vec![0; length as usize];
input.read_exact(&mut bytes)?;
Ok(bytes)
}
fn read_input(mut input: impl Read) -> io::Result<(Value, Vec<u8>)> {
let mut magic = [0_u8; 8];
input.read_exact(&mut magic)?;
if &magic != INPUT_MAGIC {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"invalid protocol",
));
}
let metadata = read_part(&mut input, MAX_METADATA_BYTES)?;
let bytes = read_part(&mut input, MAX_ARTIFACT_BYTES)?;
let mut trailing = [0_u8];
if input.read(&mut trailing)? != 0 {
return Err(io::Error::new(io::ErrorKind::InvalidData, "trailing input"));
}
let raw = serde_json::from_slice(&metadata)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid metadata"))?;
Ok((raw, bytes))
}
fn exit_message(code: Option<i32>) -> String {
match code {
Some(0) => "",
Some(126) => "Установка отменена в системном окне. Текущая версия лаунчера сохранена.",
Some(127) => "Система не разрешила установку. Подтвердите права администратора в системном окне; при его отсутствии проверьте PolicyKit.",
Some(REJECTED) => "Системная проверка подписи или версии deb не пройдена. Установка отменена.",
Some(LOCKED) => "Пакетный менеджер занят другой установкой. Дождитесь её завершения и нажмите «Обновить» ещё раз.",
Some(INVALID_HOST) => "Системная установка ShaCraft не подтверждена. Установите новый deb вручную с shacraft.ru/help#launcher.",
_ => "Пакетный менеджер не завершил установку. Проверьте состояние пакетов в системе и повторите попытку; при необходимости установите deb вручную.",
}.to_owned()
}
pub(crate) fn install(update: &Update, bytes: &[u8]) -> Result<(), String> {
if !installed_binary_supported() {
return Err(exit_message(Some(INVALID_HOST)));
}
if let Some(reason) = unsupported_reason() {
return Err(reason);
}
let metadata =
serde_json::to_vec(&update.raw_json).map_err(|_| exit_message(Some(REJECTED)))?;
let mut child = Command::new(PKEXEC)
.args(["--disable-internal-agent", BINARY, HELPER_FLAG])
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|_| exit_message(Some(127)))?;
// Always wait even on EPIPE: declining the system dialog closes stdin, and
// its exit status is the useful cancellation result, not "broken pipe".
let write_result = write_input(
child.stdin.take().expect("piped helper input"),
&metadata,
bytes,
);
let status = child.wait().map_err(|_| exit_message(None))?;
if status.success() && write_result.is_ok() {
Ok(())
} else {
Err(exit_message(status.code().filter(|code| *code != 0)))
}
}
fn verify_deb_release(raw: &Value, bytes: &[u8], key: &str, installed: &str) -> Result<String, ()> {
let metadata = updater::verified_metadata(raw, key).map_err(|_| ())?;
if !updater::newer_version(&metadata, installed).map_err(|_| ())? {
return Err(());
}
let version = metadata["version"].as_str().ok_or(())?;
let target = format!("linux-{}-deb", std::env::consts::ARCH);
let artifact = metadata["platforms"].get(&target).ok_or(())?;
let url = Url::parse(artifact["url"].as_str().ok_or(())?).map_err(|_| ())?;
updater::validate_download_url(&url, version, InstallationKind::Deb).map_err(|_| ())?;
updater::verify_signature(bytes, artifact["signature"].as_str().ok_or(())?, key)
.map_err(|_| ())?;
Ok(version.to_owned())
}
fn valid_package_fields(output: &[u8], version: &str) -> bool {
std::str::from_utf8(output)
.is_ok_and(|text| text == format!("{PACKAGE}\n{version}\n{}\n", architecture()))
}
fn lock_error(stderr: &[u8]) -> bool {
let text = String::from_utf8_lossy(stderr).to_ascii_lowercase();
(text.contains("lock")
&& (text.contains("locked")
|| text.contains("another process")
|| text.contains("resource temporarily unavailable")
|| text.contains("unable to acquire")))
|| text.contains("dpkg frontend lock was locked")
}
fn embedded_key() -> Result<String, ()> {
let config: Value = serde_json::from_str(include_str!("../tauri.conf.json")).map_err(|_| ())?;
config["plugins"]["updater"]["pubkey"]
.as_str()
.map(str::to_owned)
.ok_or(())
}
fn run_helper() -> Result<(), i32> {
// pkexec cleans the environment before executing this root-owned program.
// Never initialize Tauri/GTK or network/account code in privileged mode.
if unsafe { libc::geteuid() } != 0 || !installed_binary_supported() {
return Err(INVALID_HOST);
}
let installed = installed_version().map_err(|_| INVALID_HOST)?;
let (raw, bytes) = read_input(io::stdin().lock()).map_err(|_| REJECTED)?;
let version = verify_deb_release(
&raw,
&bytes,
&embedded_key().map_err(|_| REJECTED)?,
&installed,
)
.map_err(|_| REJECTED)?;
// No untrusted filesystem object crosses the privilege boundary. This
// directory is created by root, mode 0700, after all signature checks.
if !trusted_root_path(Path::new("/var"), false)
|| !safe_sticky_temporary_parent(Path::new("/var/tmp"))
{
return Err(INVALID_HOST);
}
let temp = tempfile::Builder::new()
.prefix("shacraft-update-")
.tempdir_in("/var/tmp")
.map_err(|_| INSTALL_FAILED)?;
fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o700))
.map_err(|_| INSTALL_FAILED)?;
let package = temp.path().join("release.deb");
let mut output = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&package)
.map_err(|_| INSTALL_FAILED)?;
output
.set_permissions(fs::Permissions::from_mode(0o600))
.map_err(|_| INSTALL_FAILED)?;
output
.write_all(&bytes)
.and_then(|_| output.sync_all())
.map_err(|_| INSTALL_FAILED)?;
drop(output);
let fields = capture(
fixed_command(DEB)
.arg("--show")
.arg("--showformat=${Package}\n${Version}\n${Architecture}\n")
.arg(&package),
)
.map_err(|_| REJECTED)?;
if !fields.status.success() || !valid_package_fields(&fields.stdout, &version) {
return Err(REJECTED);
}
// Check again immediately before mutation: another updater might have
// installed the release while the authentication dialog was open.
if !updater::newer_version(
&serde_json::json!({"version": version}),
&installed_version().map_err(|_| INVALID_HOST)?,
)
.map_err(|_| REJECTED)?
{
return Err(REJECTED);
}
let result = capture(
fixed_command(DPKG)
.args(["--refuse-downgrade", "--install"])
.arg(&package),
)
.map_err(|_| INSTALL_FAILED)?;
if !result.status.success() {
return Err(if lock_error(&result.stderr) {
LOCKED
} else {
INSTALL_FAILED
});
}
if installed_version().map_err(|_| INSTALL_FAILED)? != version {
return Err(INSTALL_FAILED);
}
Ok(())
}
/// The special flag is never registered as IPC and does not accept filenames.
/// Even manually invoking it cannot bypass signatures, package identity or
/// privilege checks. Errors intentionally print no package/metadata contents.
pub(crate) fn run_helper_if_requested() -> Option<i32> {
let arguments = std::env::args_os().skip(1).collect::<Vec<_>>();
if !arguments.iter().any(|argument| argument == HELPER_FLAG) {
return None;
}
if arguments.len() != 1 || arguments[0] != HELPER_FLAG {
return Some(REJECTED);
}
Some(match run_helper() {
Ok(()) => 0,
Err(code) => code,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn framed_input_rejects_oversized_truncated_and_trailing_data() {
let mut bytes = Vec::new();
write_input(&mut bytes, b"{}", b"package").unwrap();
let (metadata, package) = read_input(&bytes[..]).unwrap();
assert_eq!(metadata, serde_json::json!({}));
assert_eq!(package, b"package");
assert!(read_input(&bytes[..bytes.len() - 1]).is_err());
bytes.push(0);
assert!(read_input(&bytes[..]).is_err());
let mut oversized = INPUT_MAGIC.to_vec();
oversized.extend_from_slice(&u64::MAX.to_be_bytes());
assert!(read_input(&oversized[..]).is_err());
}
#[test]
fn package_identity_version_architecture_are_exact() {
let good = format!("{PACKAGE}\n0.1.4\n{}\n", architecture());
assert!(valid_package_fields(good.as_bytes(), "0.1.4"));
for wrong in [
good.replace(PACKAGE, "another-package"),
good.replace("0.1.4", "0.1.5"),
good.replace(architecture(), "all"),
format!("{good}extra\n"),
] {
assert!(!valid_package_fields(wrong.as_bytes(), "0.1.4"));
}
}
#[test]
fn cancellation_authorization_and_package_lock_remain_distinct() {
assert!(exit_message(Some(126)).contains("отменена"));
assert!(exit_message(Some(127)).contains("не разрешила"));
assert!(exit_message(Some(LOCKED)).contains("занят"));
assert!(lock_error(
b"dpkg: error: dpkg frontend lock was locked by another process"
));
assert!(!lock_error(
b"dpkg: dependency problems prevent configuration"
));
}
#[test]
fn privileged_path_rejects_user_owned_files_symlinks_and_relative_paths() {
let directory = tempfile::tempdir().unwrap();
let file = directory.path().join("launcher");
fs::write(&file, b"file").unwrap();
fs::set_permissions(&file, fs::Permissions::from_mode(0o777)).unwrap();
assert!(!trusted_root_path(&file, true));
let link = directory.path().join("link");
std::os::unix::fs::symlink("/usr/bin/dpkg", &link).unwrap();
assert!(!trusted_root_path(&link, true));
assert!(!trusted_root_path(Path::new("usr/bin/dpkg"), true));
}
#[test]
fn root_verification_does_not_accept_legacy_appimage_as_deb() {
let fixture: Value =
serde_json::from_str(include_str!("../tests/fixtures/updater-signed.json")).unwrap();
assert!(verify_deb_release(
&fixture["metadata"],
fixture["artifactText"].as_str().unwrap().as_bytes(),
fixture["publicKey"].as_str().unwrap(),
"0.0.0"
)
.is_err());
}
#[test]
fn inspection_timeout_kills_descendants_holding_output_pipes() {
let start = Instant::now();
let result = capture_with_deadline(
Command::new("/bin/sh").args(["-c", "sleep 30 & exit 0"]),
Duration::from_millis(100),
);
assert!(matches!(result, Err(error) if error.kind() == io::ErrorKind::TimedOut));
assert!(start.elapsed() < Duration::from_secs(3));
let output = capture(fixed_command(DEB).arg("--version")).unwrap();
assert!(output.status.success());
assert!(String::from_utf8_lossy(&output.stdout).contains("Debian"));
}
#[test]
fn command_output_is_bounded_and_fully_drained() {
let input = vec![b'x'; OUTPUT_LIMIT * 4];
assert_eq!(drain_capped(input.as_slice()).unwrap().len(), OUTPUT_LIMIT);
}
}
+220
View File
@@ -0,0 +1,220 @@
//! Fabric metadata and Maven are fixed, independent game trust domains.
//! The signed ShaCraft manifest selects versions, never arbitrary loader URLs.
use crate::mojang::{Artifact, LibraryDownloads, VersionJson};
use reqwest::blocking::Client;
use serde::Deserialize;
use std::{io::Read, time::Duration};
pub(crate) const MAVEN_HOST: &str = "maven.fabricmc.net";
const HOSTS: [&str; 2] = ["meta.fabricmc.net", MAVEN_HOST];
const MAX_PROFILE: usize = 512 * 1024;
#[derive(Deserialize)]
struct FabricLibrary {
name: String,
url: String,
sha1: Option<String>,
size: Option<u64>,
}
fn coordinate_path(name: &str) -> Result<String, String> {
let pieces: Vec<_> = name.split(':').collect();
if pieces.len() != 3
|| pieces.iter().any(|p| {
!crate::manifest::is_portable_component(p)
|| *p == "."
|| *p == ".."
|| !p
.bytes()
.all(|c| c.is_ascii_alphanumeric() || b"._+-".contains(&c))
})
|| pieces[0].split('.').any(|p| !crate::manifest::is_portable_component(p))
{
return Err("Invalid Fabric library coordinate".into());
}
Ok(format!(
"{}/{}/{}/{}-{}.jar",
pieces[0].replace('.', "/"),
pieces[1],
pieces[2],
pieces[1],
pieces[2]
))
}
fn get(client: &Client, url: &str, limit: usize) -> Result<Vec<u8>, String> {
let response = client
.get(url)
.send()
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
let mut bytes = Vec::new();
response
.take((limit + 1) as u64)
.read_to_end(&mut bytes)
.map_err(|e| e.to_string())?;
if bytes.len() > limit {
return Err("Fabric metadata exceeds its size limit".into());
}
Ok(bytes)
}
pub(crate) fn fetch_profile(minecraft: &str, loader: &str) -> Result<VersionJson, String> {
let client =
crate::trusted_http::client(&HOSTS, Duration::from_secs(30)).map_err(|e| e.to_string())?;
// Defence in depth: these values normally already passed manifest validation.
if [minecraft, loader].iter().any(|v| {
!crate::manifest::is_portable_component(v)
|| v.contains('/')
|| !v
.bytes()
.all(|c| c.is_ascii_alphanumeric() || b"._+-".contains(&c))
}) {
return Err("Invalid Fabric version".into());
}
let bytes = get(
&client,
&format!("https://meta.fabricmc.net/v2/versions/loader/{minecraft}/{loader}/profile/json"),
MAX_PROFILE,
)?;
normalize(&client, &bytes, minecraft, loader)
}
fn normalize(
client: &Client,
bytes: &[u8],
minecraft: &str,
loader: &str,
) -> Result<VersionJson, String> {
// Flattening libraries would consume the same key twice; parse the two views explicitly.
let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| e.to_string())?;
let parent = value.get("inheritsFrom").and_then(|v| v.as_str());
let mut version: VersionJson =
serde_json::from_value(value.clone()).map_err(|e| e.to_string())?;
if parent != Some(minecraft)
|| version.id != format!("fabric-loader-{loader}-{minecraft}")
|| version.main_class != "net.fabricmc.loader.impl.launch.knot.KnotClient"
{
return Err("Fabric profile identity mismatch".into());
}
let artifacts: Vec<FabricLibrary> =
serde_json::from_value(value["libraries"].clone()).map_err(|e| e.to_string())?;
if artifacts.is_empty() || artifacts.len() > 32 {
return Err("Invalid Fabric library count".into());
}
for (library, artifact) in version.libraries.iter_mut().zip(artifacts) {
if artifact.url != "https://maven.fabricmc.net/" {
return Err("Untrusted Fabric Maven URL".into());
}
let path = coordinate_path(&artifact.name)?;
let url = format!("https://{MAVEN_HOST}/{path}");
let sha1 = match artifact.sha1 {
Some(hash) => hash,
None => String::from_utf8(get(client, &format!("{url}.sha1"), 128)?)
.map_err(|e| e.to_string())?
.trim()
.to_owned(),
};
let size = match artifact.size {
Some(size) => size,
None => client
.head(&url)
.send()
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?
.headers()
.get(reqwest::header::CONTENT_LENGTH)
.and_then(|h| h.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.ok_or("Fabric library has no size")?,
};
if sha1.len() != 40
|| !sha1.bytes().all(|b| b.is_ascii_hexdigit())
|| size == 0
|| size > 64 * 1024 * 1024
{
return Err("Invalid Fabric library hash or size".into());
}
library.downloads = Some(LibraryDownloads {
artifact: Some(Artifact {
path,
url,
sha1,
size,
}),
});
}
Ok(version)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maven_coordinates_cannot_escape_library_directory() {
assert_eq!(
coordinate_path("net.fabricmc:fabric-loader:0.19.5").unwrap(),
"net/fabricmc/fabric-loader/0.19.5/fabric-loader-0.19.5.jar"
);
for bad in [
"x:y:../evil",
"a..b:c:1",
"x:/tmp:1",
"x:y:1:extra",
"x:y:\\evil",
"x:y:..",
"CON:y:1",
"a:y.:1",
"a:AUX:1",
] {
assert!(coordinate_path(bad).is_err(), "{bad}");
}
}
#[test]
fn loader_identity_and_maven_origin_are_checked_before_downloads() {
let client = Client::new();
let base = serde_json::json!({"id":"fabric-loader-0.19.5-26.2", "inheritsFrom":"26.2", "mainClass":"net.fabricmc.loader.impl.launch.knot.KnotClient", "libraries":[{"name":"net.fabricmc:fabric-loader:0.19.5", "url":"https://maven.fabricmc.net/", "sha1":"a".repeat(40), "size":42}]});
assert!(normalize(
&client,
&serde_json::to_vec(&base).unwrap(),
"26.2",
"0.19.5"
)
.is_ok());
for (field, value) in [
("inheritsFrom", "1.21.1"),
("mainClass", "attacker.Main"),
("id", "wrong"),
] {
let mut bad = base.clone();
bad[field] = value.into();
assert!(normalize(
&client,
&serde_json::to_vec(&bad).unwrap(),
"26.2",
"0.19.5"
)
.is_err());
}
let mut bad = base;
bad["libraries"][0]["url"] = "https://attacker.invalid/".into();
assert!(normalize(
&client,
&serde_json::to_vec(&bad).unwrap(),
"26.2",
"0.19.5"
)
.is_err());
}
#[test]
#[ignore = "downloads official Fabric profile metadata"]
fn live_resolves_fabric_26_2() {
let profile = fetch_profile("26.2", "0.19.5").unwrap();
assert!(profile
.libraries
.iter()
.all(|library| library.downloads.is_some()));
}
}
+90 -15
View File
@@ -1,11 +1,10 @@
//! Builds and spawns the real `java` invocation for a merged launch
//! profile. The `${auth_*}` placeholders are filled from a `PlayerIdentity`,
//! which is either a real Microsoft-authenticated session (`msa::LoginResult`)
//! or an explicit offline account (`PlayerIdentity::Offline`). Offline mode is
//! never silently substituted for a Microsoft session.
//! profile. The `${auth_*}` placeholders use only the identity bound to the
//! server-issued admission ticket. Its one-use proof stays out of arguments
//! and files; only the Java child's environment receives it.
use crate::admission::Admission;
use crate::mojang::{self, MergedVersion};
use crate::session::PlayerIdentity;
use sha2::{Digest, Sha256};
use std::{
collections::{HashMap, HashSet},
@@ -44,7 +43,7 @@ pub struct LaunchRequest<'a> {
/// the shared `game_dir`.
pub profile_dir: &'a Path,
pub merged: &'a MergedVersion,
pub identity: &'a PlayerIdentity,
pub admission: &'a Admission,
pub memory_mb: u16,
pub log_path: &'a Path,
}
@@ -183,6 +182,10 @@ fn write_jvm_argfile(path: &Path, arguments: &[String]) -> io::Result<()> {
/// that (see `lib.rs`'s launch command, which watches it on a background
/// thread and emits an event).
pub fn launch(request: &LaunchRequest) -> Result<Child, LaunchError> {
Ok(build_command(request)?.spawn()?)
}
fn build_command(request: &LaunchRequest) -> Result<Command, LaunchError> {
fs::create_dir_all(request.profile_dir)?;
let natives_dir = mojang::natives_directory(request.game_dir, &request.merged.id);
fs::create_dir_all(&natives_dir)?;
@@ -193,7 +196,8 @@ pub fn launch(request: &LaunchRequest) -> Result<Child, LaunchError> {
let classpath = build_classpath(request.game_dir, request.merged, &client_jar);
let mut vars: HashMap<&str, String> = HashMap::new();
vars.insert("auth_player_name", request.identity.name().to_string());
let identity = request.admission.identity();
vars.insert("auth_player_name", identity.name().to_string());
// NeoForge's inherited JVM profile uses `${version_name}.jar` in
// `-DignoreList`. The actual client jar belongs to the vanilla parent
// (`1.21.1.jar`), not to the child profile (`neoforge-...`), so this
@@ -203,14 +207,11 @@ pub fn launch(request: &LaunchRequest) -> Result<Child, LaunchError> {
vars.insert("game_directory", request.profile_dir.display().to_string());
vars.insert("assets_root", assets_root.display().to_string());
vars.insert("assets_index_name", request.merged.asset_index.id.clone());
vars.insert("auth_uuid", request.identity.uuid());
vars.insert(
"auth_access_token",
request.identity.access_token().to_string(),
);
vars.insert("auth_uuid", identity.uuid());
vars.insert("auth_access_token", identity.access_token().to_string());
vars.insert("clientid", launcher_client_id(request.game_dir)?);
vars.insert("auth_xuid", request.identity.xuid().to_string());
vars.insert("user_type", request.identity.user_type().to_string());
vars.insert("auth_xuid", identity.xuid().to_string());
vars.insert("user_type", identity.user_type().to_string());
vars.insert("version_type", "ShaCraft Launcher".to_string());
vars.insert("natives_directory", natives_dir.display().to_string());
vars.insert("launcher_name", "ShaCraft Launcher".to_string());
@@ -227,6 +228,7 @@ pub fn launch(request: &LaunchRequest) -> Result<Child, LaunchError> {
let game_args = mojang::resolve_arguments(&request.merged.game_arguments, &no_features);
let mut command = Command::new(request.java_executable);
request.admission.configure_child(&mut command);
let memory_argument = format!("-Xmx{}M", request.memory_mb);
if cfg!(windows) {
let argfile = request.profile_dir.join(".shacraft-jvm.args");
@@ -243,19 +245,92 @@ pub fn launch(request: &LaunchRequest) -> Result<Child, LaunchError> {
for argument in game_args {
command.arg(substitute(&argument, &vars));
}
// This endpoint is native-owned; a manifest cannot redirect game admission.
if request.admission.server_id() == "minigames" {
command.args(["--quickPlayMultiplayer", "135.106.154.86:25568"]);
}
command.current_dir(request.profile_dir);
let log_file = fs::File::create(request.log_path)?;
command.stdout(Stdio::from(log_file.try_clone()?));
command.stderr(Stdio::from(log_file));
Ok(command.spawn()?)
Ok(command)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn launch_arguments_and_written_files_never_contain_admission_secrets() {
use crate::admission::{AdmissionKey, PRIVATE_KEY_ENV, TICKET_ENV};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
let directory =
std::env::temp_dir().join(format!("shacraft-launch-proof-{}", random_uuid_v4()));
let game_dir = directory.join("game");
let profile_dir = directory.join("profile");
let log_path = directory.join("game.log");
for server_id in ["aoc", "minigames"] {
let vanilla: mojang::VersionJson = serde_json::from_value(serde_json::json!({
"id": "1.21.1",
"mainClass": "net.minecraft.client.main.Main",
"arguments": {
"game": ["--username", "${auth_player_name}", "--uuid", "${auth_uuid}", "--accessToken", "${auth_access_token}"],
"jvm": ["-cp", "${classpath}", "-Dlauncher=${launcher_name}"]
},
"assetIndex": {"id": "17", "sha1": "0".repeat(40), "size": 1, "url": "https://piston-meta.mojang.com/assets"},
"downloads": {"client": {"sha1": "0".repeat(40), "size": 1, "url": "https://piston-data.mojang.com/client.jar"}}
})).unwrap();
let merged = mojang::merge_versions(&vanilla, None).unwrap();
let response = serde_json::from_value(serde_json::json!({
"ticket_id": URL_SAFE_NO_PAD.encode([73_u8; 32]), "mc_username": "Ticket_Name",
"server_id": server_id, "expires_in_seconds": 600
}))
.unwrap();
let admission = AdmissionKey::generate(server_id).unwrap().bind(response).unwrap();
let request = LaunchRequest {
java_executable: Path::new("java"),
game_dir: &game_dir,
profile_dir: &profile_dir,
merged: &merged,
admission: &admission,
memory_mb: 6144,
log_path: &log_path,
};
let command = build_command(&request).unwrap();
let env: HashMap<_, _> = command.get_envs().collect();
let proof = [TICKET_ENV, PRIVATE_KEY_ENV]
.map(|name| env[std::ffi::OsStr::new(name)].unwrap().to_str().unwrap());
let arguments: Vec<_> = command
.get_args()
.map(|arg| arg.to_string_lossy())
.collect();
assert!(arguments
.windows(2)
.any(|args| args == ["--username", "Ticket_Name"]));
assert!(arguments
.windows(2)
.any(|args| args == ["--accessToken", "0"]));
assert_eq!(arguments.windows(2).any(|pair| pair == ["--quickPlayMultiplayer", "135.106.154.86:25568"]), server_id == "minigames");
for secret in proof {
assert!(arguments.iter().all(|argument| !argument.contains(secret)));
for path in [
log_path.clone(),
game_dir.join(".shacraft-client-id"),
profile_dir.join(".shacraft-jvm.args"),
] {
if path.exists() {
assert!(!fs::read_to_string(path).unwrap().contains(secret));
}
}
}
drop(command);
}
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn generates_rfc4122_version_4_uuids() {
let id = random_uuid_v4();
+33 -1
View File
@@ -1,4 +1,8 @@
mod admission;
#[cfg(target_os = "linux")]
mod deb_updater;
mod download;
mod fabric;
mod java;
mod launch;
mod manifest;
@@ -13,13 +17,36 @@ mod settings;
mod shacraft_account;
mod storage;
mod trusted_http;
mod updater;
mod commands;
mod operations;
pub fn run() {
#[cfg(target_os = "linux")]
if let Some(code) = deb_updater::run_helper_if_requested() {
std::process::exit(code);
}
use tauri::Manager;
tauri::Builder::default()
.manage(operations::LauncherOperations::default())
.manage(updater::LauncherUpdater::default())
.plugin(tauri_plugin_updater::Builder::new().build())
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
// Do not interrupt the short package replacement step. A
// download can safely be abandoned before any file changes.
if let Some(updater) = window.try_state::<updater::LauncherUpdater>() {
if updater
.state
.lock()
.is_ok_and(|state| state.stage == updater::Stage::Installing)
{
api.prevent_close();
}
}
}
})
.invoke_handler(tauri::generate_handler![
commands::host::native_host,
commands::host::detect_java,
@@ -34,12 +61,17 @@ pub fn run() {
commands::shacraft::get_shacraft_account,
commands::shacraft::shacraft_logout,
commands::shacraft::shacraft_start_link,
commands::shacraft::shacraft_claim_nickname,
commands::shacraft::shacraft_link_status,
commands::account::start_microsoft_login,
commands::account::get_account,
commands::account::logout,
commands::game::ensure_game_installed,
commands::game::launch_game
commands::game::launch_game,
commands::updater::get_launcher_update_status,
commands::updater::check_launcher_update,
commands::updater::install_launcher_update,
commands::updater::restart_launcher_after_update
])
.run(tauri::generate_context!())
.expect("error while running ShaCraft Launcher");
+1 -1
View File
@@ -91,7 +91,7 @@ fn validate(manifest: &Manifest) -> Result<(), ManifestError> {
));
}
if !is_version(&manifest.minecraft.version)
|| manifest.minecraft.loader.kind.trim().is_empty()
|| !matches!(manifest.minecraft.loader.kind.as_str(), "neoforge" | "fabric" | "vanilla")
|| !is_version(&manifest.minecraft.loader.version)
{
return Err(ManifestError::Invalid(
+2 -1
View File
@@ -58,6 +58,7 @@ pub fn library_http_client() -> Result<Client, reqwest::Error> {
MOJANG_HOSTS[2],
MOJANG_HOSTS[3],
crate::neoforge::NEOFORGE_HOST,
crate::fabric::MAVEN_HOST,
],
std::time::Duration::from_secs(10 * 60),
)
@@ -67,7 +68,7 @@ pub fn library_http_client() -> Result<Client, reqwest::Error> {
/// own fixed Maven. The profile itself comes from the SHA-256-verified
/// NeoForge installer, never from the ShaCraft manifest.
fn is_allowed_library_host(url: &str) -> bool {
is_allowed_host(url) || crate::neoforge::is_allowed_host(url)
is_allowed_host(url) || crate::neoforge::is_allowed_host(url) || crate::trusted_http::allows(url, &[crate::fabric::MAVEN_HOST])
}
#[derive(Debug)]
+72 -2
View File
@@ -7,11 +7,42 @@ use std::sync::{
Arc,
};
#[derive(Default)]
#[derive(Clone, Default)]
pub(crate) struct LauncherOperations {
pub installation: Operation,
pub account: Operation,
pub shacraft_account: Operation,
// Held from launch scheduling until Child::wait completes, not just spawn.
pub game: Operation,
}
impl LauncherOperations {
/// Acquire every mutation gate without waiting. Partial acquisition is
/// rolled back by RAII, so a failed update cannot strand an account gate.
pub fn acquire_update(&self) -> Result<UpdatePermits, String> {
let installation = self.installation.acquire("Установка игры")?;
let account = self.account.acquire("Вход в аккаунт")?;
let shacraft_account = self
.shacraft_account
.acquire("Операция с аккаунтом ShaCraft")?;
let game = self
.game
.acquire("Игра")
.map_err(|_| "Закройте Minecraft перед обновлением лаунчера.".to_string())?;
Ok(UpdatePermits {
_installation: installation,
_account: account,
_shacraft_account: shacraft_account,
_game: game,
})
}
}
pub(crate) struct UpdatePermits {
_installation: Permit,
_account: Permit,
_shacraft_account: Permit,
_game: Permit,
}
#[derive(Clone, Default)]
@@ -36,7 +67,7 @@ impl Drop for Permit {
#[cfg(test)]
mod tests {
use super::Operation;
use super::{LauncherOperations, Operation};
#[test]
fn rejects_overlap_and_releases_on_worker_error() {
@@ -49,4 +80,43 @@ mod tests {
assert!(worker().is_err());
assert!(operation.acquire("Installation").is_ok());
}
#[test]
fn running_game_blocks_update_and_partial_locks_are_released() {
let operations = LauncherOperations::default();
let game = operations.game.acquire("game").unwrap();
assert!(operations.acquire_update().is_err());
assert!(operations.installation.acquire("install").is_ok());
assert!(operations.account.acquire("account").is_ok());
assert!(operations
.shacraft_account
.acquire("ShaCraft account")
.is_ok());
drop(game);
assert!(operations.acquire_update().is_ok());
}
#[test]
fn update_excludes_game_and_accounts_until_permit_drop() {
let operations = LauncherOperations::default();
let permit = operations.acquire_update().unwrap();
assert!(operations.installation.acquire("install").is_err());
assert!(operations.account.acquire("account").is_err());
assert!(operations
.shacraft_account
.acquire("ShaCraft account")
.is_err());
assert!(operations.game.acquire("game").is_err());
assert!(operations.acquire_update().is_err());
drop(permit);
assert!(operations.acquire_update().is_ok());
}
#[test]
fn account_operation_blocks_update_without_stranding_installation() {
let operations = LauncherOperations::default();
let _account = operations.shacraft_account.acquire("account").unwrap();
assert!(operations.acquire_update().is_err());
assert!(operations.installation.acquire("install").is_ok());
}
}
+35
View File
@@ -185,6 +185,41 @@ mod tests {
time::{SystemTime, UNIX_EPOCH},
};
#[test]
#[ignore = "downloads the public signed admission mod into a temporary directory"]
fn live_admission_mod_is_restored_when_missing_or_corrupt() {
let mut manifest = crate::remote::fetch_manifest("aeronautics").unwrap();
manifest.files.retain(|file| {
file.path.starts_with("mods/shacraft-admission") && file.path.ends_with(".jar")
});
assert_eq!(
manifest.files.len(),
1,
"exactly one admission mod is required"
);
assert!(matches!(manifest.files[0].policy, FilePolicy::Managed));
let root = std::env::temp_dir().join(format!(
"shacraft-live-admission-{}-{}",
process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
assert_eq!(inspect(&root, &manifest).unwrap().missing_files, 1);
super::sync(&root, &manifest).unwrap();
assert!(inspect(&root, &manifest).unwrap().up_to_date);
let target = root.join(&manifest.files[0].path);
fs::write(&target, b"corrupt fixture").unwrap();
assert!(!inspect(&root, &manifest).unwrap().up_to_date);
super::sync(&root, &manifest).unwrap();
assert!(inspect(&root, &manifest).unwrap().up_to_date);
fs::remove_file(&target).unwrap();
super::sync(&root, &manifest).unwrap();
assert!(inspect(&root, &manifest).unwrap().up_to_date);
fs::remove_dir_all(root).unwrap();
}
fn manifest(hash: String, size: u64) -> Manifest {
Manifest {
schema_version: 1,
+4
View File
@@ -13,6 +13,8 @@ use std::{
const AERONAUTICS_MANIFEST: &str =
"https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest";
const MINIGAMES_MANIFEST: &str = "https://shacraft.ru/api/launcher/v2/profiles/minigames/signed-manifest";
const MINIGAMES_ONLINE: &str = "https://shacraft.ru/api/online/minigames";
const AERONAUTICS_ONLINE: &str = "https://shacraft.ru/api/online/aoc";
const MANIFEST_PUBLIC_KEY: &str = "2S3FRdZj4Xw5nJpZ3IhqVITBg3nTH9AtGSo1Ew9+qVQ=";
const MANIFEST_KEY_ID: &str = "2026-09-06";
@@ -71,6 +73,7 @@ impl fmt::Display for RemoteError {
pub fn fetch_manifest(profile_id: &str) -> Result<Manifest, RemoteError> {
let url = match profile_id {
"aeronautics" => AERONAUTICS_MANIFEST,
"minigames" => MINIGAMES_MANIFEST,
_ => return Err(RemoteError::UnknownProfile),
};
let client = Client::builder()
@@ -125,6 +128,7 @@ fn fetch_manifest_bytes(client: &Client, url: &str) -> Result<Vec<u8>, RemoteErr
pub fn fetch_server_status(profile_id: &str) -> Result<ServerStatus, RemoteError> {
let url = match profile_id {
"aeronautics" => AERONAUTICS_ONLINE,
"minigames" => MINIGAMES_ONLINE,
_ => return Err(RemoteError::UnknownProfile),
};
let client = Client::builder()
+122 -13
View File
@@ -6,7 +6,12 @@
use reqwest::blocking::{Client, Response};
use reqwest::redirect::Policy;
use serde::{Deserialize, Serialize};
use std::{fmt, fs, io, path::Path, time::Duration};
use std::{
fmt, fs,
io::{self, Read},
path::Path,
time::Duration,
};
const API_ORIGIN: &str = "https://shacraft.ru";
const SESSION_FILE: &str = "shacraft-session";
@@ -62,7 +67,6 @@ pub enum AccountError {
Api(String),
Io(io::Error),
InvalidSession,
NoLinkedNickname,
}
impl fmt::Display for AccountError {
@@ -72,9 +76,6 @@ impl fmt::Display for AccountError {
Self::Api(message) => formatter.write_str(message),
Self::Io(error) => write!(formatter, "Не удалось сохранить сессию: {error}"),
Self::InvalidSession => formatter.write_str("Сессия ShaCraft истекла — войдите снова"),
Self::NoLinkedNickname => {
formatter.write_str("Сначала привяжите игровой ник к серверу Aeronautics")
}
}
}
}
@@ -221,18 +222,96 @@ pub fn link_status(data_dir: &Path, challenge_id: i64) -> Result<LinkStatus, Acc
response.json::<LinkStatus>().map_err(AccountError::Network)
}
pub fn aeronautics_nickname(data_dir: &Path) -> Result<String, AccountError> {
get_account(data_dir)?
.links
.into_iter()
.find(|link| link.server_id == "aoc")
.map(|link| link.mc_username)
.ok_or(AccountError::NoLinkedNickname)
const ADMISSION_ENDPOINT: &str = "/api/launcher/v2/admission/tickets";
/// Error responses at this boundary never echo arbitrary response bodies: a
/// misconfigured proxy/service must not copy credentials into UI diagnostics.
fn admission_error(status: reqwest::StatusCode) -> AccountError {
use reqwest::StatusCode;
match status {
StatusCode::UNAUTHORIZED => AccountError::InvalidSession,
StatusCode::FORBIDDEN => AccountError::Api(
"Нет разрешения на вход в Aeronautics. Проверьте привязку ника и доступ к серверу в аккаунте ShaCraft.".into()),
StatusCode::CONFLICT => AccountError::Api(
"Этот ник уже занят или зарезервирован. Если это ваш игровой ник, обратитесь в поддержку ShaCraft.".into()),
StatusCode::NOT_FOUND | StatusCode::SERVICE_UNAVAILABLE => AccountError::Api(
"Вход через ShaCraft Launcher пока не настроен на сервере. Повторите попытку позже.".into()),
StatusCode::TOO_MANY_REQUESTS => AccountError::Api(
"Слишком много запросов входа. Подождите немного и повторите попытку.".into()),
_ => AccountError::Api(format!("Не удалось получить разрешение ShaCraft: HTTP {status}")),
}
}
fn checked_admission_response(
data_dir: &Path,
response: Response,
) -> Result<Response, AccountError> {
if response.status().is_success() {
return Ok(response);
}
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
let _ = fs::remove_file(session_path(data_dir));
}
Err(admission_error(response.status()))
}
/// The server reserves a free nickname atomically for this account. Existing
/// player names remain reserved for administrator-assisted migration.
pub fn claim_nickname(data_dir: &Path, nickname: &str) -> Result<Account, AccountError> {
let token = load_session(data_dir)?;
let response = client()?
.post(format!("{API_ORIGIN}/api/launcher/v2/admission/nickname"))
.bearer_auth(token)
.json(&serde_json::json!({"server_id": "aoc", "mc_username": nickname}))
.send()
.map_err(AccountError::Network)?;
checked_admission_response(data_dir, response)?
.json()
.map_err(AccountError::Network)
}
/// Called only after installation, immediately before Java spawn. Nothing in
/// this response is exposed to the webview or persisted with account settings.
pub(crate) fn issue_admission(
data_dir: &Path,
server_id: &'static str,
) -> Result<crate::admission::Admission, AccountError> {
let token = load_session(data_dir)?;
let key = crate::admission::AdmissionKey::generate(server_id)
.map_err(|message| AccountError::Api(message.into()))?;
let response = client()?
.post(format!("{API_ORIGIN}{ADMISSION_ENDPOINT}"))
.bearer_auth(token)
.json(&key.request())
.send()
.map_err(AccountError::Network)?;
let response = checked_admission_response(data_dir, response)?;
// The expected object is under 256 bytes; bound the remote allocation and
// use a fixed parse error without response values or secret-bearing bodies.
let mut body = zeroize::Zeroizing::new(Vec::new());
response.take(4097).read_to_end(&mut body).map_err(|_| {
AccountError::Api(
"Не удалось прочитать разрешение на вход. Повторите попытку позже.".into(),
)
})?;
let invalid = || {
AccountError::Api(
"Сервер вернул некорректное разрешение на вход. Повторите попытку позже.".into(),
)
};
if body.len() > 4096 {
return Err(invalid());
}
let payload = serde_json::from_slice(&body).map_err(|_| invalid())?;
key.bind(payload)
.map_err(|message| AccountError::Api(message.into()))
}
#[cfg(test)]
mod tests {
use super::{load_session, save_session, session_path};
use super::{
admission_error, issue_admission, load_session, save_session, session_path, AccountError,
};
use std::{
fs, process,
time::{SystemTime, UNIX_EPOCH},
@@ -275,4 +354,34 @@ mod tests {
);
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn admission_without_session_never_falls_back_to_legacy_nickname() {
let directory = temporary_directory();
crate::settings::save(&directory, crate::settings::LauncherSettings::default()).unwrap();
assert!(matches!(
issue_admission(&directory, "aoc"),
Err(AccountError::InvalidSession)
));
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn admission_unavailable_and_revoked_session_have_actionable_errors() {
for status in [
reqwest::StatusCode::NOT_FOUND,
reqwest::StatusCode::SERVICE_UNAVAILABLE,
] {
assert!(admission_error(status)
.to_string()
.contains("пока не настроен"));
}
assert!(matches!(
admission_error(reqwest::StatusCode::UNAUTHORIZED),
AccountError::InvalidSession
));
assert!(admission_error(reqwest::StatusCode::CONFLICT)
.to_string()
.contains("зарезервирован"));
}
}
+853
View File
@@ -0,0 +1,853 @@
//! Launcher releases form a separate trust boundary from modpack manifests.
//! Only this module chooses update URLs. The webview receives display data and
//! progress, never an updater resource, destination, signature or public key.
use crate::operations::{Operation, UpdatePermits};
use base64::{engine::general_purpose::STANDARD, Engine};
use minisign_verify::{PublicKey, Signature};
use serde::Serialize;
use serde_json::Value;
use std::{
sync::{Arc, Mutex},
time::Duration,
};
use tauri::{AppHandle, Manager, Runtime};
use tauri_plugin_updater::{Update, UpdaterBuilder, UpdaterExt};
use url::Url;
pub(crate) const UPDATE_ENDPOINT: &str = "https://shacraft.ru/launcher/updates/stable.json";
pub(crate) const MAX_METADATA_BYTES: usize = 192 * 1024;
const MAX_PAYLOAD_BYTES: usize = 64 * 1024;
pub(crate) const MAX_ARTIFACT_BYTES: usize = 256 * 1024 * 1024;
const BAD_METADATA: &str =
"Не удалось подтвердить подлинность сведений об обновлении. Повторите проверку позже.";
const BAD_SIGNATURE: &str = "Подпись обновления не прошла проверку. Установка отменена.";
#[derive(Clone, Copy, Serialize, PartialEq, Eq, Debug)]
#[serde(rename_all = "lowercase")]
pub(crate) enum InstallationKind {
Appimage,
Deb,
Other,
}
#[derive(Clone, Copy, Default, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub(crate) enum Stage {
#[default]
Idle,
Checking,
Available,
Downloading,
Installing,
Ready,
}
#[derive(Default)]
pub(crate) struct UpdateState {
pub stage: Stage,
pub candidate: Option<Update>,
// Hold all mutation gates until the user restarts into the installed app.
pub restart_permits: Option<UpdatePermits>,
}
#[derive(Clone, Default)]
pub(crate) struct LauncherUpdater {
pub operation: Operation,
pub state: Arc<Mutex<UpdateState>>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct UpdateStatus {
pub current_version: String,
pub supported: bool,
pub installation_kind: InstallationKind,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
pub stage: Stage,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct UpdateProgress {
pub stage: Stage,
pub downloaded_bytes: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub total_bytes: Option<u64>,
}
impl LauncherUpdater {
pub fn status<R: Runtime>(&self, app: &AppHandle<R>) -> Result<UpdateStatus, String> {
let reason = unsupported_reason(app);
let state = self
.state
.lock()
.map_err(|_| "Состояние обновления недоступно.".to_string())?;
Ok(UpdateStatus {
current_version: app.package_info().version.to_string(),
supported: reason.is_none(),
installation_kind: installation_kind(app),
reason,
stage: state.stage,
version: state
.candidate
.as_ref()
.map(|update| update.version.clone()),
notes: state
.candidate
.as_ref()
.and_then(|update| update.body.clone()),
})
}
pub fn set_stage(&self, stage: Stage) -> Result<(), String> {
self.state
.lock()
.map_err(|_| "Состояние обновления недоступно.".to_string())?
.stage = stage;
Ok(())
}
}
pub(crate) fn installation_kind<R: Runtime>(app: &AppHandle<R>) -> InstallationKind {
#[cfg(target_os = "linux")]
{
let env = app.env();
if let (Some(image), Some(directory), Ok(executable)) = (
env.appimage.as_ref(),
env.appdir.as_ref(),
std::env::current_exe(),
) {
if linux_appimage_supported(image.as_ref(), directory.as_ref(), &executable) {
return InstallationKind::Appimage;
}
}
if crate::deb_updater::installed_binary_supported() {
return InstallationKind::Deb;
}
}
let _ = app;
InstallationKind::Other
}
pub(crate) fn unsupported_reason<R: Runtime>(app: &AppHandle<R>) -> Option<String> {
#[cfg(target_os = "linux")]
match installation_kind(app) {
InstallationKind::Appimage => return None,
InstallationKind::Deb => return crate::deb_updater::unsupported_reason(),
InstallationKind::Other => return Some("Для автообновления установите deb-пакет или запустите AppImage с shacraft.ru/help#launcher.".into()),
}
#[cfg(not(target_os = "linux"))]
let _ = app;
#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
return Some("Для этой платформы доступна только ручная установка обновлений.".into());
#[cfg(not(target_os = "linux"))]
None
}
#[cfg(target_os = "linux")]
fn linux_appimage_supported(
image: &std::path::Path,
directory: &std::path::Path,
executable: &std::path::Path,
) -> bool {
use std::io::Read;
if !image.is_absolute()
|| !directory.is_absolute()
|| !executable.starts_with(directory)
|| !image.is_file()
{
return false;
}
let mut header = [0_u8; 11];
std::fs::File::open(image)
.and_then(|mut file| file.read_exact(&mut header))
.is_ok()
&& &header[..4] == b"\x7fELF"
&& &header[8..11] == b"AI\x02"
}
pub(crate) fn public_key<R: Runtime>(app: &AppHandle<R>) -> Result<String, String> {
app.config()
.plugins
.0
.get("updater")
.and_then(|value| value.get("pubkey"))
.and_then(Value::as_str)
.filter(|key| !key.is_empty())
.map(str::to_owned)
.ok_or_else(|| "В этой сборке отсутствует ключ проверки обновлений.".into())
}
/// Resolve the startup AppImage path once. Release metadata and IPC never
/// select an installation destination; symlink launch shortcuts remain usable.
pub(crate) fn installation_path<R: Runtime>(
app: &AppHandle<R>,
) -> Result<Option<std::path::PathBuf>, String> {
#[cfg(target_os = "linux")]
{
if installation_kind(app) == InstallationKind::Deb {
return Ok(None);
}
let image = app
.env()
.appimage
.ok_or("Запустите AppImage, чтобы обновить лаунчер.")?;
return std::fs::canonicalize(image)
.map(Some)
.map_err(|_| "Файл AppImage перемещён или недоступен. Запустите его снова.".into());
}
#[cfg(not(target_os = "linux"))]
{
let _ = app;
Ok(None)
}
}
pub(crate) fn trusted_builder<R: Runtime>(app: &AppHandle<R>) -> Result<UpdaterBuilder, String> {
app.updater_builder()
.endpoints(vec![Url::parse(UPDATE_ENDPOINT).expect("fixed update URL")])
.map_err(|_| BAD_METADATA.to_string())
.map(|builder| {
builder
.timeout(Duration::from_secs(20))
.configure_client(|client| {
client
.https_only(true)
.redirect(reqwest_updater::redirect::Policy::none())
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(20))
.danger_accept_invalid_certs(false)
.danger_accept_invalid_hostnames(false)
})
})
}
fn http_client(timeout: Duration) -> Result<reqwest::Client, String> {
reqwest::Client::builder()
.https_only(true)
.redirect(reqwest::redirect::Policy::none())
.connect_timeout(Duration::from_secs(10))
.timeout(timeout)
.user_agent(concat!("ShaCraft-Launcher/", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|_| "Не удалось подключиться к серверу обновлений.".into())
}
fn checked_length(current: usize, next: usize, maximum: usize) -> Result<usize, String> {
current
.checked_add(next)
.filter(|size| *size <= maximum)
.ok_or_else(|| "Размер ответа сервера обновлений превышает допустимый.".into())
}
async fn bounded_response(
mut response: reqwest::Response,
maximum: usize,
mut progress: impl FnMut(u64, Option<u64>),
) -> Result<Vec<u8>, String> {
if !response.status().is_success() || response.status() == reqwest::StatusCode::NO_CONTENT {
return Err("Сервер обновлений временно недоступен. Повторите попытку позже.".into());
}
let total = response.content_length();
if total.is_some_and(|length| length > maximum as u64) {
return Err("Размер ответа сервера обновлений превышает допустимый.".into());
}
let mut bytes = Vec::new();
while let Some(chunk) = response.chunk().await.map_err(|_| {
"Загрузка обновления прервалась. Проверьте соединение и повторите попытку.".to_string()
})? {
checked_length(bytes.len(), chunk.len(), maximum)?;
bytes.extend_from_slice(&chunk);
progress(bytes.len() as u64, total);
}
if total.is_some_and(|length| length != bytes.len() as u64) {
return Err("Обновление загружено не полностью. Повторите попытку.".into());
}
Ok(bytes)
}
/// Same Minisign format and verification semantics as Tauri's updater. The
/// signed metadata and artifact each need a valid signature under the embedded
/// release key. A signed old artifact cannot be labelled as a new version.
pub(crate) fn verify_signature(
bytes: &[u8],
encoded_signature: &str,
encoded_key: &str,
) -> Result<(), String> {
if encoded_signature.len() > 4096 || encoded_key.len() > 4096 {
return Err(BAD_SIGNATURE.into());
}
let key_bytes = STANDARD.decode(encoded_key).map_err(|_| BAD_SIGNATURE)?;
let signature_bytes = STANDARD
.decode(encoded_signature)
.map_err(|_| BAD_SIGNATURE)?;
let key = PublicKey::decode(std::str::from_utf8(&key_bytes).map_err(|_| BAD_SIGNATURE)?)
.map_err(|_| BAD_SIGNATURE)?;
let signature =
Signature::decode(std::str::from_utf8(&signature_bytes).map_err(|_| BAD_SIGNATURE)?)
.map_err(|_| BAD_SIGNATURE)?;
key.verify(bytes, &signature, true)
.map_err(|_| BAD_SIGNATURE.into())
}
pub(crate) fn verified_metadata(raw: &Value, key: &str) -> Result<Value, String> {
let object = raw.as_object().ok_or(BAD_METADATA)?;
if object.len() != 6 {
return Err(BAD_METADATA.into());
}
let encoded = raw
.get("signedPayload")
.and_then(Value::as_str)
.ok_or(BAD_METADATA)?;
if encoded.len() > MAX_PAYLOAD_BYTES * 4 / 3 + 4 {
return Err(BAD_METADATA.into());
}
let payload = STANDARD.decode(encoded).map_err(|_| BAD_METADATA)?;
if payload.len() > MAX_PAYLOAD_BYTES {
return Err(BAD_METADATA.into());
}
let signature = raw
.get("metadataSignature")
.and_then(Value::as_str)
.ok_or(BAD_METADATA)?;
verify_signature(&payload, signature, key).map_err(|_| BAD_METADATA)?;
let parsed: Value = serde_json::from_slice(&payload).map_err(|_| BAD_METADATA)?;
let signed = parsed.as_object().ok_or(BAD_METADATA)?;
if signed.len() != 4 {
return Err(BAD_METADATA.into());
}
for field in ["version", "notes", "pub_date", "platforms"] {
if !signed.contains_key(field) || signed.get(field) != object.get(field) {
return Err(BAD_METADATA.into());
}
}
Ok(parsed)
}
pub(crate) fn newer_version(metadata: &Value, current: &str) -> Result<bool, String> {
let announced = metadata
.get("version")
.and_then(Value::as_str)
.ok_or(BAD_METADATA)?;
let version = semver::Version::parse(announced).map_err(|_| BAD_METADATA)?;
// Stable channel rejects prerelease/build aliases and noncanonical spellings.
if !version.pre.is_empty() || !version.build.is_empty() || version.to_string() != announced {
return Err(BAD_METADATA.into());
}
let current = semver::Version::parse(current).map_err(|_| BAD_METADATA)?;
Ok(version > current)
}
fn require_platform(metadata: &Value, kind: InstallationKind) -> Result<String, String> {
let os = if cfg!(target_os = "macos") {
"darwin"
} else {
std::env::consts::OS
};
let target = format!("{os}-{}", std::env::consts::ARCH);
let platforms = metadata
.get("platforms")
.and_then(Value::as_object)
.ok_or(BAD_METADATA)?;
#[cfg(target_os = "linux")]
let targets = match kind {
InstallationKind::Deb => vec![format!("{target}-deb")],
InstallationKind::Appimage => vec![format!("{target}-appimage"), target],
InstallationKind::Other => {
return Err("Формат установленного лаунчера не поддерживает обновление.".into())
}
};
#[cfg(not(target_os = "linux"))]
let targets = {
let _ = kind;
["nsis", "msi", "app"]
.iter()
.map(|bundle| format!("{target}-{bundle}"))
.chain(std::iter::once(target.clone()))
.collect::<Vec<_>>()
};
targets
.into_iter()
.find(|target| platforms.contains_key(target))
.ok_or_else(|| "Обновление для вашего формата установки пока не опубликовано.".into())
}
pub(crate) fn validate_download_url(
url: &Url,
version: &str,
kind: InstallationKind,
) -> Result<(), String> {
let prefix = format!("/downloads/shacraft-launcher/{version}/");
let filename = url.path().strip_prefix(&prefix).ok_or(BAD_METADATA)?;
if url.scheme() != "https"
|| url.host_str() != Some("shacraft.ru")
|| url.port().is_some()
|| !url.username().is_empty()
|| url.password().is_some()
|| url.query().is_some()
|| url.fragment().is_some()
|| filename.is_empty()
|| !filename.as_bytes()[0].is_ascii_alphanumeric()
|| !filename
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte))
{
return Err(BAD_METADATA.into());
}
let correct_extension = if cfg!(target_os = "linux") {
match kind {
InstallationKind::Appimage => filename.ends_with(".AppImage"),
InstallationKind::Deb => filename.ends_with(".deb"),
InstallationKind::Other => false,
}
} else if cfg!(target_os = "macos") {
filename.ends_with(".app.tar.gz")
} else if cfg!(target_os = "windows") {
filename.ends_with(".exe") || filename.ends_with(".msi")
} else {
false
};
if !correct_extension {
return Err(BAD_METADATA.into());
}
Ok(())
}
fn candidate_kind(update: &Update) -> InstallationKind {
if cfg!(target_os = "linux") {
if update.target == format!("linux-{}-deb", std::env::consts::ARCH) {
InstallationKind::Deb
} else {
InstallationKind::Appimage
}
} else {
InstallationKind::Other
}
}
/// Fetch and authenticate a bounded static manifest before asking the vendored
/// upstream plugin's small offline constructor to create an Update. Its normal
/// HTTP check is intentionally unused because it buffers unbounded JSON.
pub(crate) async fn check_candidate(
builder: UpdaterBuilder,
key: &str,
current: &str,
kind: InstallationKind,
) -> Result<Option<Update>, String> {
let response = http_client(Duration::from_secs(20))?
.get(UPDATE_ENDPOINT)
.send()
.await
.map_err(|_| {
"Не удалось проверить обновления. Проверьте подключение к интернету.".to_string()
})?;
let bytes = bounded_response(response, MAX_METADATA_BYTES, |_, _| {}).await?;
let raw: Value = serde_json::from_slice(&bytes).map_err(|_| BAD_METADATA)?;
let metadata = verified_metadata(&raw, key)?;
let target = require_platform(&metadata, kind)?;
if !newer_version(&metadata, current)? {
return Ok(None);
}
#[cfg(target_os = "linux")]
let builder = builder.target(target);
#[cfg(not(target_os = "linux"))]
let _ = target;
let update = builder
.build()
.map_err(updater_error)?
.check_metadata(raw.clone())
.map_err(updater_error)?
.ok_or(BAD_METADATA)?;
if update.raw_json != raw
|| update.current_version != current
|| Some(update.version.as_str()) != metadata.get("version").and_then(Value::as_str)
{
return Err(BAD_METADATA.into());
}
// Retain the exact signed envelope with the native-only candidate.
verified_metadata(&update.raw_json, key)?;
validate_download_url(
&update.download_url,
&update.version,
candidate_kind(&update),
)?;
Ok(Some(update))
}
pub(crate) async fn download_verified(
update: &Update,
key: &str,
progress: impl FnMut(u64, Option<u64>),
) -> Result<Vec<u8>, String> {
validate_download_url(
&update.download_url,
&update.version,
candidate_kind(update),
)?;
verified_metadata(&update.raw_json, key)?;
let response = http_client(Duration::from_secs(600))?
.get(update.download_url.clone())
.send()
.await
.map_err(|_| {
"Не удалось загрузить обновление. Проверьте подключение и повторите попытку."
.to_string()
})?;
let bytes = bounded_response(response, MAX_ARTIFACT_BYTES, progress).await?;
verify_signature(&bytes, &update.signature, key)?;
Ok(bytes)
}
/// Keep verification adjacent to the only call that can replace the app. This
/// also protects against an accidental mutation of bytes after downloading.
pub(crate) fn install_verified(
update: &Update,
bytes: &[u8],
key: &str,
destination: Option<&std::path::Path>,
) -> Result<(), String> {
validate_download_url(
&update.download_url,
&update.version,
candidate_kind(update),
)?;
verify_signature(bytes, &update.signature, key)?;
#[cfg(target_os = "linux")]
{
if candidate_kind(update) == InstallationKind::Deb {
return crate::deb_updater::install(update, bytes);
}
let destination = destination.ok_or("Файл AppImage недоступен.")?;
install_appimage_atomic(destination, bytes).map_err(|_| {
"Не удалось заменить AppImage. Проверьте свободное место и права на папку лаунчера."
.into()
})
}
#[cfg(not(target_os = "linux"))]
{
let _ = destination;
update.install(bytes).map_err(updater_error)
}
}
/// Tauri 2.11 moves the old AppImage away before writing the new one. Use our
/// atomic-file primitive on Linux so interruption during writing leaves the
/// old executable intact. Other OS installers stay with the official plugin.
#[cfg(target_os = "linux")]
fn install_appimage_atomic(destination: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
use std::{
fs,
io::{self, Write},
os::unix::fs::PermissionsExt,
};
if bytes.len() < 11 || &bytes[..4] != b"\x7fELF" || &bytes[8..11] != b"AI\x02" {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"release is not a type-2 AppImage",
));
}
let metadata = fs::symlink_metadata(destination)?;
if !metadata.is_file() || metadata.file_type().is_symlink() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"AppImage must be a regular file",
));
}
let parent =
fs::File::open(destination.parent().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "AppImage has no parent")
})?)?;
let mut output = crate::storage::AtomicFile::new(destination)?;
output.writer().write_all(bytes)?;
output.writer().set_permissions(fs::Permissions::from_mode(
metadata.permissions().mode() & 0o777,
))?;
output.commit()?;
parent.sync_all()
}
pub(crate) fn updater_error(error: tauri_plugin_updater::Error) -> String {
match error {
tauri_plugin_updater::Error::TargetNotFound(_) | tauri_plugin_updater::Error::TargetsNotFound(_) =>
"Обновление для вашей платформы пока не опубликовано.".into(),
tauri_plugin_updater::Error::Io(_) | tauri_plugin_updater::Error::TempDirNotOnSameMountPoint =>
"Не удалось заменить файл лаунчера. Проверьте свободное место и права на папку приложения.".into(),
tauri_plugin_updater::Error::Minisign(_) | tauri_plugin_updater::Error::Base64(_) |
tauri_plugin_updater::Error::SignatureUtf8(_) => BAD_SIGNATURE.into(),
_ => "Не удалось установить обновление. Повторите попытку или скачайте лаунчер с shacraft.ru.".into(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn artifact_url() -> &'static str {
if cfg!(target_os = "linux") {
"https://shacraft.ru/downloads/shacraft-launcher/0.2.0/ShaCraft_0.2.0.AppImage"
} else if cfg!(target_os = "macos") {
"https://shacraft.ru/downloads/shacraft-launcher/0.2.0/ShaCraft_0.2.0.app.tar.gz"
} else {
"https://shacraft.ru/downloads/shacraft-launcher/0.2.0/ShaCraft_0.2.0.exe"
}
}
#[test]
fn download_policy_pins_origin_version_plain_path_and_package_type() {
assert!(validate_download_url(
&Url::parse(artifact_url()).unwrap(),
"0.2.0",
InstallationKind::Appimage
)
.is_ok());
for value in [
artifact_url().replace("https:", "http:"),
artifact_url().replace("shacraft.ru/", "evil.example/"),
artifact_url().replace("shacraft.ru/", "shacraft.ru:8443/"),
artifact_url().replace("https://", "https://user@"),
format!("{}?url=x", artifact_url()),
format!("{}#x", artifact_url()),
artifact_url().replace("/0.2.0/", "/0.1.0/"),
artifact_url().replace("ShaCraft_", "%53haCraft_"),
artifact_url().replace("ShaCraft_", "nested/ShaCraft_"),
format!("{}.sh", artifact_url()),
] {
assert!(
validate_download_url(
&Url::parse(&value).unwrap(),
"0.2.0",
InstallationKind::Appimage
)
.is_err(),
"{value}"
);
}
}
#[cfg(target_os = "linux")]
#[test]
fn linux_selects_package_family_without_deb_fallback() {
let base = format!("linux-{}", std::env::consts::ARCH);
let legacy = serde_json::json!({"platforms": {base.clone(): {}}});
assert_eq!(
require_platform(&legacy, InstallationKind::Appimage).unwrap(),
base
);
assert!(require_platform(&legacy, InstallationKind::Deb).is_err());
let exact_image = format!("{base}-appimage");
let exact_deb = format!("{base}-deb");
let all = serde_json::json!({"platforms": {base.clone(): {}, exact_image.clone(): {}, exact_deb.clone(): {}}});
assert_eq!(
require_platform(&all, InstallationKind::Appimage).unwrap(),
exact_image
);
assert_eq!(
require_platform(&all, InstallationKind::Deb).unwrap(),
exact_deb
);
let deb = Url::parse(
"https://shacraft.ru/downloads/shacraft-launcher/0.2.0/ShaCraft_0.2.0_amd64.deb",
)
.unwrap();
assert!(validate_download_url(&deb, "0.2.0", InstallationKind::Deb).is_ok());
assert!(validate_download_url(&deb, "0.2.0", InstallationKind::Appimage).is_err());
assert!(validate_download_url(
&Url::parse(artifact_url()).unwrap(),
"0.2.0",
InstallationKind::Deb
)
.is_err());
}
#[test]
fn stable_channel_never_downgrades_or_installs_equal_aliases() {
assert!(newer_version(&serde_json::json!({"version":"0.2.0"}), "0.1.3").unwrap());
for version in ["0.1.2", "0.1.3"] {
assert!(!newer_version(&serde_json::json!({"version":version}), "0.1.3").unwrap());
}
for version in ["v0.2.0", "0.2.0-test", "0.2.0+extra", "00.2.0", "../0.2.0"] {
assert!(newer_version(&serde_json::json!({"version":version}), "0.1.3").is_err());
}
}
#[test]
fn streaming_size_limit_handles_missing_length_and_overflow() {
assert_eq!(checked_length(3, 5, 8).unwrap(), 8);
assert!(checked_length(3, 6, 8).is_err());
assert!(checked_length(usize::MAX, 1, usize::MAX).is_err());
}
#[test]
fn missing_or_oversized_metadata_proof_is_rejected() {
assert!(verified_metadata(&serde_json::json!({"version":"0.2.0"}), "").is_err());
let raw = serde_json::json!({"version":"0.2.0","notes":"","pub_date":"","platforms":{},
"signedPayload":"A".repeat(MAX_PAYLOAD_BYTES * 2),"metadataSignature":""});
assert!(verified_metadata(&raw, "").is_err());
}
fn fixture() -> Value {
// Public test key/signatures only. The ephemeral private key was
// discarded by the publisher tests and never enters this repository.
serde_json::from_str(include_str!("../tests/fixtures/updater-signed.json")).unwrap()
}
#[test]
fn genuine_metadata_and_artifact_signatures_pass_and_tampering_fails() {
let fixture = fixture();
let key = fixture["publicKey"].as_str().unwrap();
let raw = &fixture["metadata"];
assert!(verified_metadata(raw, key).is_ok());
let signature = raw["platforms"]["linux-x86_64"]["signature"]
.as_str()
.unwrap();
let bytes = fixture["artifactText"].as_str().unwrap().as_bytes();
assert!(verify_signature(bytes, signature, key).is_ok());
let mut corrupt = bytes.to_vec();
corrupt[0] ^= 1;
assert!(verify_signature(&corrupt, signature, key).is_err());
assert!(
verify_signature(bytes, &STANDARD.encode("invalid minisign signature"), key).is_err()
);
assert!(
verify_signature(bytes, signature, &STANDARD.encode("invalid public key")).is_err()
);
}
#[test]
fn relabelling_a_signed_old_artifact_or_changing_signed_fields_fails() {
let fixture = fixture();
let key = fixture["publicKey"].as_str().unwrap();
for field in ["version", "notes", "pub_date", "platforms"] {
let mut raw = fixture["metadata"].clone();
raw[field] = Value::String("tampered".into());
assert!(verified_metadata(&raw, key).is_err(), "{field}");
}
let mut raw = fixture["metadata"].clone();
let mut payload: Value = serde_json::from_slice(
&STANDARD
.decode(raw["signedPayload"].as_str().unwrap())
.unwrap(),
)
.unwrap();
raw["version"] = Value::String("99.0.0".into());
payload["version"] = Value::String("99.0.0".into());
raw["signedPayload"] =
Value::String(STANDARD.encode(serde_json::to_vec(&payload).unwrap()));
assert!(verified_metadata(&raw, key).is_err());
}
#[test]
fn unavailable_platform_is_not_reported_as_latest() {
assert!(require_platform(
&serde_json::json!({"platforms":{}}),
InstallationKind::Appimage
)
.is_err());
}
#[cfg(target_os = "linux")]
#[test]
fn linux_support_requires_actual_appimage_and_matching_appdir() {
let directory = tempfile::tempdir().unwrap();
let image = directory.path().join("launcher.AppImage");
let appdir = directory.path().join(".mount_test");
let binary = appdir.join("usr/bin/shacraft-launcher");
std::fs::write(&image, b"\x7fELF\x02\x01\x01\0AI\x02rest").unwrap();
assert!(linux_appimage_supported(&image, &appdir, &binary));
assert!(!linux_appimage_supported(
&image,
&appdir,
&directory.path().join("raw-binary")
));
std::fs::write(&image, b"not a valid AppImage").unwrap();
assert!(!linux_appimage_supported(&image, &appdir, &binary));
}
#[cfg(target_os = "linux")]
#[test]
fn appimage_replacement_preserves_old_on_error_and_retains_executable_mode() {
use std::os::unix::fs::PermissionsExt;
let directory = tempfile::tempdir().unwrap();
let image = directory.path().join("launcher.AppImage");
let old = b"\x7fELF\x02\x01\x01\0AI\x02old";
let new = b"\x7fELF\x02\x01\x01\0AI\x02new";
std::fs::write(&image, old).unwrap();
std::fs::set_permissions(&image, std::fs::Permissions::from_mode(0o751)).unwrap();
assert!(install_appimage_atomic(&image, b"wrong package").is_err());
assert_eq!(std::fs::read(&image).unwrap(), old);
assert!(install_appimage_atomic(&directory.path().join("missing"), new).is_err());
assert_eq!(std::fs::read(&image).unwrap(), old);
install_appimage_atomic(&image, new).unwrap();
assert_eq!(std::fs::read(&image).unwrap(), new);
assert_eq!(
std::fs::metadata(&image).unwrap().permissions().mode() & 0o777,
0o751
);
assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 1);
}
/// Read-only production HTTPS requests; replacement occurs ONLY in a new
/// temporary copy of the path supplied by the operator. No QA switches or
/// alternative endpoints are compiled into a distributed launcher.
#[cfg(target_os = "linux")]
#[test]
#[ignore = "requires published signed update and SHACRAFT_UPDATER_TEST_IMAGE pointing to an old AppImage"]
fn live_signed_update_replaces_only_temporary_copy() {
use sha2::{Digest, Sha256};
let source = std::path::PathBuf::from(
std::env::var_os("SHACRAFT_UPDATER_TEST_IMAGE").expect("old AppImage path"),
);
assert!(source.is_file());
let directory = tempfile::tempdir().unwrap();
let destination = directory.path().join("isolated-old.AppImage");
std::fs::copy(&source, &destination).unwrap();
let old_hash = Sha256::digest(std::fs::read(&destination).unwrap());
let config: Value = serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
let mut context = tauri::test::mock_context(tauri::test::noop_assets());
context
.config_mut()
.plugins
.0
.insert("updater".into(), config["plugins"]["updater"].clone());
context.package_info_mut().version = "0.1.2".parse().unwrap();
let app = tauri::test::mock_builder()
.plugin(tauri_plugin_updater::Builder::new().build())
.build(context)
.unwrap();
let key = public_key(app.handle()).unwrap();
let builder = trusted_builder(app.handle())
.unwrap()
.executable_path(&destination);
tauri::async_runtime::block_on(async {
let update = check_candidate(builder, &key, "0.1.2", InstallationKind::Appimage)
.await
.unwrap()
.expect("newer published version");
let mut bytes = download_verified(&update, &key, |_, _| {}).await.unwrap();
let new_hash = Sha256::digest(&bytes);
assert_ne!(old_hash, new_hash);
bytes[0] ^= 1;
assert!(install_verified(&update, &bytes, &key, Some(&destination)).is_err());
assert_eq!(
old_hash,
Sha256::digest(std::fs::read(&destination).unwrap())
);
bytes[0] ^= 1;
install_verified(&update, &bytes, &key, Some(&destination)).unwrap();
assert_eq!(
new_hash,
Sha256::digest(std::fs::read(&destination).unwrap())
);
assert_eq!(old_hash, Sha256::digest(std::fs::read(&source).unwrap()));
println!(
"Verified signed update {} and tamper rejection; replaced only isolated copy",
update.version
);
});
}
}
+23 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ShaCraft Launcher",
"version": "0.1.1",
"version": "0.1.6",
"identifier": "ru.shacraft.launcher",
"build": {
"beforeDevCommand": "npm run dev",
@@ -28,6 +28,7 @@
},
"bundle": {
"active": true,
"createUpdaterArtifacts": true,
"targets": "all",
"icon": [
"icons/32x32.png",
@@ -35,6 +36,26 @@
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
],
"linux": {
"deb": {
"depends": [
"libwebkit2gtk-4.1-0",
"libgtk-3-0",
"pkexec"
]
}
}
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDk0N0MzODEwMjg1OUVCNDEKUldSQjYxa29FRGg4bEdKSkFWUzZUNDZhRFN4cGIwL0FvVnl0blhrOWtSMWhSOWxHMkU1aGs5L2oK",
"endpoints": [
"https://shacraft.ru/launcher/updates/stable.json"
],
"windows": {
"installMode": "passive"
}
}
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"publicKey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXkgM0FGNTgxMjY4QTBERUU5NgpSV1NXN2cyS0pvSDFPczhocEIzTmp1bjM1TnRRWWE1QnIyck00bDRmZW1sQlphQXY2MTZvTWcwZgo=",
"metadata": {
"notes": "Проверка обновления",
"platforms": {
"linux-x86_64": {
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIG1pbmlzaWduIHNlY3JldCBrZXkKUlVTVzdnMktKb0gxT2dZZmwrT0V0b0pYbDdYU3dSang1TXJNZXdtTDZvUVNvU1FMU2ZsajJkL2d4bXlOVDdQNmt4eEExeUtGcG1zckNWcEhPVXd4TnBJOU9ublZWbHk0NFFjPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTk1ODU1CWZpbGU6Zml4dHVyZS5BcHBJbWFnZQloYXNoZWQKdWxLSklQR3pabTcxdkNaUmM3d3FHbTRRSDI5dzY4UEU0QXY3NE9MazVWdGRPallpOTQ0a2RSK1AyRDBaeEZ5eGdERFgvbmVtZEluUXc4UFdUOWV6Qnc9PQo=",
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.3/fixture.AppImage"
}
},
"pub_date": "2026-09-10T00:00:00Z",
"version": "0.1.3",
"signedPayload": "eyJub3RlcyI6ItCf0YDQvtCy0LXRgNC60LAg0L7QsdC90L7QstC70LXQvdC40Y8iLCJwbGF0Zm9ybXMiOnsibGludXgteDg2XzY0Ijp7InNpZ25hdHVyZSI6ImRXNTBjblZ6ZEdWa0lHTnZiVzFsYm5RNklITnBaMjVoZEhWeVpTQm1jbTl0SUcxcGJtbHphV2R1SUhObFkzSmxkQ0JyWlhrS1VsVlRWemRuTWt0S2IwZ3hUMmRaWm13clQwVjBiMHBZYkRkWVUzZFNhbmcxVFhKTlpYZHRURFp2VVZOdlUxRk1VMlpzYWpKa0wyZDRiWGxPVkRkUU5tdDRlRUV4ZVV0R2NHMXpja05XY0VoUFZYZDRUbkJKT1U5dWJsWldiSGswTkZGalBRcDBjblZ6ZEdWa0lHTnZiVzFsYm5RNklIUnBiV1Z6ZEdGdGNEb3hOemc0T1RrMU9EVTFDV1pwYkdVNlptbDRkSFZ5WlM1QmNIQkpiV0ZuWlFsb1lYTm9aV1FLZFd4TFNrbFFSM3BhYlRjeGRrTmFVbU0zZDNGSGJUUlJTREk1ZHpZNFVFVTBRWFkzTkU5TWF6VldkR1JQYWxscE9UUTBhMlJTSzFBeVJEQmFlRVo1ZUdkRVJGZ3ZibVZ0WkVsdVVYYzRVRmRVT1dWNlFuYzlQUW89IiwidXJsIjoiaHR0cHM6Ly9zaGFjcmFmdC5ydS9kb3dubG9hZHMvc2hhY3JhZnQtbGF1bmNoZXIvMC4xLjMvZml4dHVyZS5BcHBJbWFnZSJ9fSwicHViX2RhdGUiOiIyMDI2LTA5LTEwVDAwOjAwOjAwWiIsInZlcnNpb24iOiIwLjEuMyJ9",
"metadataSignature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIG1pbmlzaWduIHNlY3JldCBrZXkKUlVTVzdnMktKb0gxT3YrcXkzN0haeGZoeHBML0pMdXJYSXRjSE1vQ2VPZkg3bFpHZjRHbWVzOG1wdlJLcWRxUlJIaW11NElydkcxMk5jWStEMGtZSEI5UXAwak1TLzdjRXdnPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTk1ODU1CWZpbGU6cGF5bG9hZC5qc29uCWhhc2hlZApEMGVQWFJCQW9RVTFaRnNDbG8xMnZOekpHcy9Pb0xEN0hHaDFiMkxJQ294WDBkeEszY0s2aGl3QWMzWEtFdmtIakRxOEx4VlF5UHJoZnpYSDJ1ZEdBQT09Cg=="
},
"artifactText": "isolated ShaCraft updater fixture; not an executable"
}
+23 -6
View File
@@ -1,4 +1,5 @@
import { useCallback, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { FeedbackDialog, type Feedback } from './components/FeedbackDialog'
import { Library } from './components/Library'
import { RecoveryCodesModal } from './components/RecoveryCodesModal'
import { PlayDock } from './components/PlayDock'
@@ -8,6 +9,7 @@ import { Titlebar } from './components/Titlebar'
import { servers } from './data/servers'
import { useAccount } from './hooks/useAccount'
import { useLauncher } from './hooks/useLauncher'
import { useLauncherUpdate } from './hooks/useLauncherUpdate'
import { useServerStatus } from './hooks/useServerStatus'
import { useSettings } from './hooks/useSettings'
import { isNative } from './services/native'
@@ -18,6 +20,7 @@ export function App() {
const [selected, setSelected] = useState(servers[0])
const [settingsOpen, setSettingsOpen] = useState(false)
const [windowError, setWindowError] = useState<string | null>(null)
const [errorFeedback, setErrorFeedback] = useState<Feedback | null>(null)
const preferences = useSettings()
const session = useAccount()
const launcher = useLauncher()
@@ -31,13 +34,19 @@ export function App() {
const access = launchAccess(session.account)
const checking = desktop && (!profile || profile.status === 'checking')
const settingsBlocked = !preferences.loaded || preferences.saving || !!preferences.error
const disabled = !desktop || busy || session.busy || access === 'loading' ||
const updater = useLauncherUpdate(busy || session.busy || preferences.saving || session.recoveryCodes.length > 0)
const updateLocked = updater.locksOperations
const disabled = !desktop || busy || updateLocked || session.busy || access === 'loading' ||
(access === 'ready' && (checking || settingsBlocked || !launcher.eventsReady))
const repairDisabled = !desktop || busy || checking
const repairDisabled = !desktop || busy || updateLocked || checking
const error = launcher.game.error ?? preferences.error ?? windowError ?? launcher.environmentError ?? session.error ?? profile?.error ?? null
useEffect(() => {
if (error) setErrorFeedback({ kind: 'error', title: 'Ошибка лаунчера', message: error })
}, [error])
let label = 'Играть'
if (!desktop) label = 'В приложении'
else if (updateLocked) label = updater.state.phase === 'ready' || updater.state.phase === 'restarting' ? 'Перезапустите лаунчер' : 'Обновляем лаунчер…'
else if (operation.phase === 'running') label = 'Игра запущена'
else if (operation.phase === 'launching') label = 'Запускаем…'
else if (operation.phase === 'installing') label = operation.progress ? `${installStageLabels[operation.progress.stage]}…` : 'Подготовка…'
@@ -61,7 +70,7 @@ export function App() {
<Titlebar host={launcher.host} onError={setWindowError} />
<div className="workspace" inert={session.recoveryCodes.length > 0}>
<Library selected={selected} profiles={launcher.profiles} account={session.account}
native={desktop} locked={busy || session.busy} onSelect={setSelected} onSettings={() => setSettingsOpen(true)} />
native={desktop} locked={busy || updateLocked || session.busy} onSelect={setSelected} onSettings={() => setSettingsOpen(true)} />
<ServerStage server={selected} status={serverStatus}>
<PlayDock server={selected} operation={operation} profile={profile}
memoryGb={preferences.settings.memoryMb / 1024} native={desktop}
@@ -70,9 +79,17 @@ export function App() {
onPrimary={primary} onRepair={() => { if (!repairDisabled) void launcher.repair(selected.profileId) }} />
</ServerStage>
</div>
<SettingsDrawer open={settingsOpen && !session.recoveryCodes.length} locked={busy} preferences={preferences}
session={session} host={launcher.host} java={launcher.java} onClose={closeSettings} />
{desktop && (updater.state.status?.version || updateLocked) && !settingsOpen && !session.recoveryCodes.length &&
<button className="update-banner" onClick={() => setSettingsOpen(true)}>
<span className="update-banner-dot" />
{updater.state.phase === 'ready' || updater.state.phase === 'restarting' ? 'Обновление установлено · перезапустить'
: updateLocked ? 'Обновляем ShaCraft Launcher…' : `ShaCraft Launcher ${updater.state.status?.version} · обновить`}
</button>}
<SettingsDrawer open={settingsOpen && !session.recoveryCodes.length} locked={busy || updateLocked} preferences={preferences}
session={session} updater={updater} host={launcher.host} java={launcher.java} onClose={closeSettings} />
<RecoveryCodesModal codes={session.recoveryCodes} onAcknowledge={session.acknowledgeRecoveryCodes} />
<FeedbackDialog feedback={session.recoveryCodes.length ? null : session.feedback ?? errorFeedback}
onDismiss={() => { session.dismissFeedback(); setErrorFeedback(null) }} />
</div>
)
}
+4 -4
View File
@@ -41,15 +41,15 @@ export function AccountSettings({ session, locked }: { session: ReturnType<typeo
</form>
)}
{account && !linkedNickname && (
<form onSubmit={(event) => { event.preventDefault(); if (!disabled) void session.startLink(nickname) }}>
<form noValidate onSubmit={(event) => { event.preventDefault(); if (!disabled) void session.startLink(nickname) }}>
<label className="text-setting">
<span><strong>Игровой ник</strong><small>Aeronautics</small></span>
<span><strong>Игровой ник</strong><small>Общий ник ShaCraft</small></span>
<input value={nickname} minLength={3} maxLength={16} pattern="[A-Za-z0-9_]{3,16}" required
disabled={disabled || session.linking} onChange={(event) => setNickname(event.target.value)} placeholder="Player" />
<small>Подтвердите владение ником на сервере Aeronautics.</small>
<small>Свободный ник закрепляется за аккаунтом. Для старого игрового ника обратитесь к администратору.</small>
</label>
<button className="setting-row" type="submit" disabled={disabled || session.linking}>
<span>{session.linking ? 'Ожидаем подтверждения…' : 'Привязать ник'}</span>
<span>{session.linking ? 'Ожидаем подтверждения…' : busy ? 'Создаём проверку…' : 'Привязать ник'}</span>
</button>
</form>
)}
+34
View File
@@ -0,0 +1,34 @@
import { useEffect, useRef } from 'react'
export interface Feedback {
kind: 'info' | 'success' | 'error'
title: string
message: string
}
export function FeedbackDialog({ feedback, onDismiss }: { feedback: Feedback | null; onDismiss: () => void }) {
const dialog = useRef<HTMLDialogElement>(null)
const visible = feedback !== null
useEffect(() => {
const element = dialog.current
if (!visible || !element) return
const previous = document.activeElement
element.showModal()
return () => {
element.close()
if (previous instanceof HTMLElement && previous.isConnected) previous.focus()
}
}, [visible])
if (!feedback) return null
return (
<dialog ref={dialog} className={`feedback-dialog ${feedback.kind}`} aria-labelledby="feedback-title"
aria-describedby="feedback-message" onCancel={(event) => { event.preventDefault(); onDismiss() }}
onKeyDown={(event) => event.stopPropagation()}>
<div aria-live={feedback.kind === 'error' ? 'assertive' : 'polite'} aria-atomic="true">
<h2 id="feedback-title">{feedback.title}</h2>
<p id="feedback-message">{feedback.message}</p>
</div>
<button type="button" autoFocus onClick={onDismiss}>Понятно</button>
</dialog>
)
}
+56
View File
@@ -0,0 +1,56 @@
import { ArrowDownToLine, RefreshCw } from 'lucide-react'
import type { useLauncherUpdate } from '../hooks/useLauncherUpdate'
import { isNative } from '../services/native'
import { updatePercent } from '../state/updater'
export function LauncherUpdateSettings({ updater }: { updater: ReturnType<typeof useLauncherUpdate> }) {
const { state, blocked, eventsReady, eventError } = updater
const { phase, status, error } = state
const percent = updatePercent(state.progress)
const working = phase === 'downloading' || phase === 'installing'
const ready = phase === 'ready' || phase === 'restarting'
const checking = phase === 'loading' || phase === 'checking'
const deb = status?.installationKind === 'deb'
return <section className="launcher-update" aria-labelledby="launcher-update-title">
<div className="launcher-update-heading">
<h3 id="launcher-update-title">ShaCraft Launcher</h3>
{status?.currentVersion && <span>{status.currentVersion}</span>}
</div>
<div className="launcher-update-status" aria-live="polite">
{!isNative() ? <p>Обновления доступны в приложении лаунчера.</p>
: ready ? <p className="update-success">Обновление установлено. Перезапустите лаунчер.</p>
: working ? <p>{phase === 'installing' ? deb
? 'Подтвердите установку в системном окне и дождитесь завершения.'
: 'Проверяем подпись и устанавливаем…'
: `Скачиваем обновление${percent === null ? '…' : ` · ${percent}%`}`}</p>
: checking ? <p>Проверяем обновления…</p>
: status?.supported === false ? <p>{status.reason || 'Для этой установки обновление доступно вручную на shacraft.ru/help#launcher.'}</p>
: status?.version ? <p className="update-success">Доступна версия {status.version}</p>
: state.checked && !error ? <p>У вас последняя версия.</p>
: <p>Проверка новой версии лаунчера.</p>}
{working && <progress aria-label={phase === 'installing' ? 'Установка обновления лаунчера' : 'Загрузка обновления лаунчера'} max={100} value={phase === 'installing' ? undefined : percent ?? undefined} />}
</div>
{status?.notes && status.version && !working && !ready && <details className="update-notes">
<summary>Что нового</summary><p>{status.notes.slice(0, 1600)}</p>
</details>}
{error && <p className="status-error update-error" role="status">{error}</p>}
{eventError && <p className="status-error update-error" role="status">{eventError} Перезапустите лаунчер, чтобы включить установку обновлений.</p>}
{deb && status?.supported && status.version && !working && !ready &&
<p className="update-hint">Для обновления deb потребуется подтверждение администратора в системном окне.</p>}
{isNative() && <div className="update-actions">
{ready ? <button className="update-primary" disabled={blocked || phase === 'restarting'} onClick={() => void updater.restart()}>
<RefreshCw />{phase === 'restarting' ? 'Перезапускаем…' : 'Перезапустить лаунчер'}
</button> : <>
{status?.supported && status.version && <button className="update-primary"
disabled={blocked || working || checking || !eventsReady} onClick={() => void updater.install()}>
<ArrowDownToLine />{working ? 'Обновляем…' : 'Обновить'}
</button>}
{status?.supported !== false && <button className="update-check" disabled={checking || working} onClick={() => void updater.check()}>
{checking ? 'Проверяем…' : error ? 'Повторить проверку' : 'Проверить обновления'}
</button>}
</>}
</div>}
{blocked && status?.supported && status.version && !working && <p className="update-hint">Завершите игру и текущие операции, чтобы обновить лаунчер.</p>}
</section>
}
+2 -2
View File
@@ -24,7 +24,7 @@ export function Library({ selected, profiles, account, locked, native, onSelect,
<button className="rail-button active" aria-label="Настройки" onClick={onSettings}><Settings /></button>
</nav>
<aside className="library-panel">
<div className="library-heading"><p>Сборки</p><span>{servers.length} доступна</span></div>
<div className="library-heading"><p>Сборки</p><span>{servers.length} сборки</span></div>
<div className="server-list">
{servers.map((server) => {
const profile = profiles[server.profileId]
@@ -33,7 +33,7 @@ export function Library({ selected, profiles, account, locked, native, onSelect,
return (
<button key={server.id} className={`server-row ${selected.id === server.id ? 'selected' : ''}`}
aria-pressed={selected.id === server.id} disabled={locked} onClick={() => onSelect(server)}>
<span className={`server-glyph ${server.id}`} aria-hidden="true">A</span>
<span className={`server-glyph ${server.id}`} aria-hidden="true">{server.name.slice(0, 1)}</span>
<span className="server-copy"><strong>{server.name}</strong><small>{status}</small></span>
<ChevronRight size={16} />
</button>
+1 -1
View File
@@ -17,7 +17,7 @@ export function ServerStage({ server, status, children }: { server: Server; stat
<p>{server.kicker}</p><h1>{server.name}</h1><h2>{server.subtitle}</h2>
<dl className="hero-meta">
<div><dt>Загрузчик</dt><dd>{server.loader}</dd></div>
<div><dt>Java</dt><dd>Версия 21</dd></div>
<div><dt>Java</dt><dd>Версия {server.id === 'minigames' ? 25 : 21}</dd></div>
</dl>
</section>
{children}
+7 -3
View File
@@ -1,8 +1,10 @@
import { useEffect, useRef } from 'react'
import { FolderOpen, Wrench, X } from 'lucide-react'
import { AccountSettings } from './AccountSettings'
import { LauncherUpdateSettings } from './LauncherUpdateSettings'
import type { useAccount } from '../hooks/useAccount'
import type { useSettings } from '../hooks/useSettings'
import type { useLauncherUpdate } from '../hooks/useLauncherUpdate'
import type { JavaInstallation, NativeHost } from '../types/launcher'
interface SettingsDrawerProps {
@@ -12,10 +14,11 @@ interface SettingsDrawerProps {
java: JavaInstallation | null | undefined
preferences: ReturnType<typeof useSettings>
session: ReturnType<typeof useAccount>
updater: ReturnType<typeof useLauncherUpdate>
onClose: () => void
}
export function SettingsDrawer({ open, locked, host, java, preferences, session, onClose }: SettingsDrawerProps) {
export function SettingsDrawer({ open, locked, host, java, preferences, session, updater, onClose }: SettingsDrawerProps) {
const { settings, loaded, saving, error } = preferences
const closeButton = useRef<HTMLButtonElement>(null)
useEffect(() => {
@@ -25,7 +28,7 @@ export function SettingsDrawer({ open, locked, host, java, preferences, session,
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose()
if (event.key !== 'Tab') return
const elements = closeButton.current?.closest('aside')?.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled)')
const elements = closeButton.current?.closest('aside')?.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled), summary')
const first = elements?.[0]
const last = elements?.[elements.length - 1]
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus() }
@@ -44,9 +47,10 @@ export function SettingsDrawer({ open, locked, host, java, preferences, session,
<aside className={`settings-drawer ${open ? 'open' : ''}`} inert={!open} aria-hidden={!open}
role="dialog" aria-modal={open ? true : undefined} aria-labelledby="settings-title">
<div className="drawer-title">
<div><p>Настройки</p><h2 id="settings-title">Игра</h2></div>
<div><p>Настройки</p><h2 id="settings-title">Лаунчер и игра</h2></div>
<button ref={closeButton} onClick={onClose} aria-label="Закрыть настройки"><X /></button>
</div>
<LauncherUpdateSettings updater={updater} />
<label className="range-setting">
<span><strong>Оперативная память</strong><b>{settings.memoryMb / 1024} ГБ</b></span>
<input type="range" min="3" max="12" step="1" value={settings.memoryMb / 1024} disabled={!loaded || locked}
+17 -1
View File
@@ -3,11 +3,15 @@ import { test } from 'node:test'
import { renderToStaticMarkup } from 'react-dom/server'
import { AccountSettings } from './AccountSettings'
import { RecoveryCodesModal } from './RecoveryCodesModal'
import { ServerStage } from './ServerStage'
import { Library } from './Library'
import { servers } from '../data/servers'
import type { useAccount } from '../hooks/useAccount'
function session(): ReturnType<typeof useAccount> {
return {
account: null, error: null, busy: false, recoveryCodes: [], linkMessage: null,
feedback: null, dismissFeedback: () => {},
linking: false, linkedNickname: null, authenticate: async () => true,
logout: async () => {}, startLink: async () => {}, clearError: () => {},
acknowledgeRecoveryCodes: () => {},
@@ -30,7 +34,7 @@ test('linked identity is displayed read-only while an unlinked account offers ve
doesNotMatch(linked, /<input|Привязать ник/)
const unlinked = renderToStaticMarkup(<AccountSettings session={{ ...session(), account: { username: 'website_login', links: [] } }} locked={false} />)
match(unlinked, /Привязать ник/)
match(unlinked, /Aeronautics/)
match(unlinked, /Общий ник ShaCraft/)
})
test('recovery codes render only until explicitly acknowledged', () => {
@@ -42,3 +46,15 @@ test('recovery codes render only until explicitly acknowledged', () => {
match(html, /Я сохранил коды/)
equal(renderToStaticMarkup(<RecoveryCodesModal codes={[]} onAcknowledge={() => {}} />), '')
})
test('both profiles remain selectable and display their own runtime requirements', () => {
const library = renderToStaticMarkup(<Library selected={servers[1]!} profiles={{}} account={null}
locked={false} native={false} onSelect={() => {}} onSettings={() => {}} />)
match(library, /Aeronautics/)
match(library, /Minigames/)
equal((library.match(/class="server-row/g) ?? []).length, 2)
const stage = (index: number) => renderToStaticMarkup(<ServerStage server={servers[index]!} status={null}>{null}</ServerStage>)
match(stage(0), /Версия 21/)
match(stage(1), /Версия 25/)
match(stage(1), /Fabric 0.19.5/)
})
+75
View File
@@ -0,0 +1,75 @@
import { doesNotMatch, match } from 'node:assert/strict'
import { test } from 'node:test'
import { renderToStaticMarkup } from 'react-dom/server'
import { LauncherUpdateSettings } from './LauncherUpdateSettings'
import type { useLauncherUpdate } from '../hooks/useLauncherUpdate'
import { initialUpdaterState, updaterReducer, updateBlocksOperations, type UpdaterState } from '../state/updater'
function render(state: UpdaterState): string {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window')
const previousTauri = Object.getOwnPropertyDescriptor(globalThis, 'isTauri')
Object.defineProperty(globalThis, 'window', { configurable: true, value: { isTauri: true } })
Object.defineProperty(globalThis, 'isTauri', { configurable: true, value: true })
try {
const updater: ReturnType<typeof useLauncherUpdate> = {
state, blocked: false, eventsReady: true, eventError: null,
locksOperations: updateBlocksOperations(state), check: async () => {},
install: async () => {}, restart: async () => {},
}
return renderToStaticMarkup(<LauncherUpdateSettings updater={updater} />)
} finally {
if (previous) Object.defineProperty(globalThis, 'window', previous)
else Reflect.deleteProperty(globalThis, 'window')
if (previousTauri) Object.defineProperty(globalThis, 'isTauri', previousTauri)
else Reflect.deleteProperty(globalThis, 'isTauri')
}
}
const available = updaterReducer(initialUpdaterState, { type: 'loaded', checked: true,
status: { currentVersion: '0.1.4', supported: true, installationKind: 'deb', version: '0.1.5' } })
test('deb announces system authorization before installation without collecting credentials', () => {
const html = render(available)
match(html, /Для обновления deb потребуется подтверждение администратора в системном окне/)
match(html, /class="update-primary"><[^>]+.*Обновить<\/button>/)
doesNotMatch(html, /<input|type="password"|Перезапустить лаунчер/)
})
test('deb installation requests system confirmation and prevents duplicate install or check', () => {
const downloading = updaterReducer(available, { type: 'install' })
const installing = updaterReducer(downloading, { type: 'progress',
progress: { stage: 'installing', downloadedBytes: 10, totalBytes: 10 } })
const html = render(installing)
match(html, /Подтвердите установку в системном окне и дождитесь завершения/)
match(html, /aria-label="Установка обновления лаунчера"/)
match(html, /class="update-primary" disabled=""/)
match(html, /class="update-check" disabled=""/)
doesNotMatch(html, /Для обновления deb потребуется|<input|type="password"/)
})
test('cancelled deb authorization remains visible and permits explicit retry without claiming success', () => {
const downloading = updaterReducer(available, { type: 'install' })
const installing = updaterReducer(downloading, { type: 'progress',
progress: { stage: 'installing', downloadedBytes: 10 } })
const cancelled = updaterReducer(installing, { type: 'failed', error: 'Установка отменена в системном окне.' })
const html = render(cancelled)
match(html, /role="status">Установка отменена в системном окне/)
match(html, /class="update-primary">/)
match(html, /Повторить проверку/)
doesNotMatch(html, /disabled=""|Обновление установлено|Перезапустить лаунчер/)
const retry = render(updaterReducer(cancelled, { type: 'install' }))
match(retry, /Скачиваем обновление/)
doesNotMatch(retry, /Установка отменена/)
})
test('AppImage and older native status retain their installation copy without administrator hints', () => {
for (const installationKind of ['appimage', undefined] as const) {
const status = { ...available.status!, installationKind }
const downloading = updaterReducer({ ...available, status }, { type: 'install' })
const installing = updaterReducer(downloading, { type: 'progress',
progress: { stage: 'installing', downloadedBytes: 10 } })
const html = render(installing)
match(html, /Проверяем подпись и устанавливаем/)
doesNotMatch(html, /администратора|системном окне/)
}
})
+5
View File
@@ -12,4 +12,9 @@ export const servers: readonly [Server, ...Server[]] = [
loader: 'NeoForge 21.1.248',
profileId: 'aeronautics',
},
{
id: 'minigames', kicker: 'Лобби и арены', name: 'Minigames',
subtitle: 'Небесные острова. Сражения на аренах SMASH.',
version: '26.2 · Fabric 0.19.5', loader: 'Fabric 0.19.5', profileId: 'minigames',
},
]
+35 -59
View File
@@ -1,24 +1,23 @@
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { Feedback } from '../components/FeedbackDialog'
import { createRequestScope, errorMessage } from '../services/async'
import { isNative, native } from '../services/native'
import { linkedNickname, validCredentials } from '../state/account'
import { isValidNickname } from '../state/settings'
import type { ShaCraftAccount } from '../types/launcher'
interface PendingLink {
challengeId: number
expiresAt: number
isCurrent: () => boolean
}
export function useAccount() {
// undefined = restoring saved account; null = signed out.
const [account, setAccount] = useState<ShaCraftAccount | null | undefined>(isNative() ? undefined : null)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([])
const [challenge, setChallenge] = useState<PendingLink | null>(null)
const [linkMessage, setLinkMessage] = useState<string | null>(null)
const [feedback, setFeedback] = useState<Feedback | null>(null)
const reportLink = useCallback((message: string, kind: Feedback['kind'] = 'info') => {
setLinkMessage(message)
setFeedback({ kind, title: kind === 'error' ? 'Не удалось привязать ник' : 'Привязка игрового ника', message })
}, [])
const pending = useRef(false)
const requests = useRef(createRequestScope())
@@ -35,44 +34,6 @@ export function useAccount() {
return () => { active = false; requests.current.invalidate() }
}, [])
useEffect(() => {
if (!challenge) return
let active = true
let timer: ReturnType<typeof setTimeout> | undefined
const current = () => active && challenge.isCurrent()
const poll = async () => {
if (!current()) return
if (Date.now() >= challenge.expiresAt) {
setChallenge(null)
setLinkMessage('Срок проверки истёк. Начните привязку ещё раз.')
return
}
try {
const result = await native.linkStatus(challenge.challengeId)
if (!current()) return
if (result.status === 'verified') {
const refreshed = await native.getAccount()
if (!current()) return
setAccount(refreshed)
setChallenge(null)
setLinkMessage(linkedNickname(refreshed) ? 'Ник подтверждён.' : 'Не удалось подтвердить привязку. Войдите снова.')
} else if (result.status === 'expired' || result.status === 'conflict') {
setChallenge(null)
setLinkMessage(result.detail || 'Проверка завершилась. Попробуйте ещё раз.')
} else {
// One request at a time; dispose and logout cancel future polling.
timer = setTimeout(() => { void poll() }, 3_000)
}
} catch (reason) {
if (!current()) return
setChallenge(null)
setLinkMessage(errorMessage(reason, 'Не удалось проверить ник'))
}
}
timer = setTimeout(() => { void poll() }, 3_000)
return () => { active = false; clearTimeout(timer) }
}, [challenge])
const authenticate = async (username: string, password: string, register: boolean) => {
if (pending.current || account === undefined) return false
if (!validCredentials(username, password)) {
@@ -92,7 +53,6 @@ export function useAccount() {
const result = await native.authenticate(username, password, register)
if (!currentRequest()) return false
setAccount(result.account)
setChallenge(null)
setLinkMessage(null)
setRecoveryCodes(result.recoveryCodes)
return true
@@ -109,7 +69,6 @@ export function useAccount() {
if (!isNative() || pending.current) return
pending.current = true
requests.current.invalidate()
setChallenge(null)
setLinkMessage(null)
setBusy(true)
setError(null)
@@ -126,26 +85,42 @@ export function useAccount() {
}
const startLink = async (nickname: string) => {
if (!isNative() || pending.current || challenge || !account) return
if (pending.current) return
if (!isNative()) {
reportLink('Привязка доступна в приложении лаунчера.', 'error')
return
}
if (!account) {
reportLink('Войдите в аккаунт ShaCraft, затем повторите привязку.', 'error')
return
}
nickname = nickname.trim()
if (!isValidNickname(nickname)) {
setLinkMessage('Ник: 3–16 латинских букв, цифр или _')
reportLink('Ник: 3–16 латинских букв, цифр или _', 'error')
return
}
pending.current = true
setBusy(true)
setLinkMessage('Создаём проверку…')
setError(null)
reportLink('Проверяем аккаунт и закрепляем ник…')
requests.current.invalidate()
const currentRequest = requests.current.capture()
try {
const started = await native.startLink(nickname)
const refreshed = await native.getAccount()
if (!currentRequest()) return
setLinkMessage(started.registered_on_server
? 'Зайдите на Aeronautics с этим ником и выполните /login.'
: 'Зайдите на Aeronautics с этим ником и выполните /register.')
setChallenge({ challengeId: started.challenge_id,
expiresAt: Date.now() + started.expires_in_seconds * 1000, isCurrent: currentRequest })
setAccount(refreshed)
if (!refreshed) {
reportLink('Сессия завершена или аккаунт удалён. Войдите в ShaCraft снова; если аккаунт удалён, создайте новый.', 'error')
return
}
const linkedAccount = await native.claimNickname(nickname)
if (!currentRequest()) return
setAccount(linkedAccount)
const confirmed = linkedNickname(linkedAccount)
reportLink(confirmed ? `Ник ${confirmed} закреплён за аккаунтом. Теперь можно запускать игру.`
: 'Не удалось получить закреплённый ник. Войдите снова.', confirmed ? 'success' : 'error')
} catch (reason) {
setLinkMessage(errorMessage(reason, 'Не удалось начать привязку'))
if (currentRequest()) reportLink(errorMessage(reason, 'Не удалось начать привязку'), 'error')
} finally {
pending.current = false
setBusy(false)
@@ -153,7 +128,8 @@ export function useAccount() {
}
return {
account, error, busy, recoveryCodes, linkMessage, linking: challenge !== null,
account, error, busy, recoveryCodes, linkMessage, linking: false,
feedback, dismissFeedback: () => setFeedback(null),
linkedNickname: linkedNickname(account), authenticate, logout, startLink,
clearError: () => setError(null),
acknowledgeRecoveryCodes: () => setRecoveryCodes([]),
+83
View File
@@ -0,0 +1,83 @@
import { useEffect, useReducer, useRef, useState } from 'react'
import { errorMessage, singleFlight } from '../services/async'
import { isNative, native, watchLauncherUpdate } from '../services/native'
import { initialUpdaterState, updateBlocksOperations, updaterReducer } from '../state/updater'
// StrictMode re-runs effects; share the pending native check without installing
// anything. Manual checks always make a fresh request.
const startupCheck = singleFlight(async () => {
const status = await native.updateStatus()
if (!status.supported || (status.stage && ['downloading', 'installing', 'ready'].includes(status.stage))) return { status, checked: false, error: null }
try {
return { status: await native.checkUpdate(), checked: true, error: null }
} catch (error) {
return { status, checked: false, error: errorMessage(error, 'Не удалось проверить обновления. Повторите попытку.') }
}
})
export function useLauncherUpdate(blocked: boolean) {
const [state, dispatch] = useReducer(updaterReducer, initialUpdaterState)
const [eventsReady, setEventsReady] = useState(false)
const [eventError, setEventError] = useState<string | null>(null)
const pending = useRef(false)
const mounted = useRef(false)
useEffect(() => {
if (!isNative()) return
let active = true
mounted.current = true
const subscription = watchLauncherUpdate((progress) => {
if (active) dispatch({ type: 'progress', progress })
})
void subscription.ready.then(() => {
if (active) { setEventsReady(true); setEventError(null) }
}).catch((reason: unknown) => {
if (active) setEventError(errorMessage(reason, 'Не удалось подключить события обновления. Перезапустите лаунчер.'))
})
pending.current = true
void startupCheck().then((result) => {
if (active) dispatch({ type: 'loaded', ...result })
}).catch((reason: unknown) => {
if (active) dispatch({ type: 'failed', error: errorMessage(reason, 'Не удалось проверить обновления. Повторите попытку.') })
}).finally(() => { if (active) pending.current = false })
return () => { active = false; mounted.current = false; subscription.dispose() }
}, [])
const check = async () => {
if (!isNative() || pending.current || updateBlocksOperations(state)) return
pending.current = true
dispatch({ type: 'check' })
try {
const status = await native.checkUpdate()
if (mounted.current) dispatch({ type: 'loaded', status, checked: true })
} catch (reason) {
if (mounted.current) dispatch({ type: 'failed', error: errorMessage(reason, 'Не удалось проверить обновления. Повторите попытку.') })
} finally { pending.current = false }
}
const install = async () => {
if (!isNative() || pending.current || blocked || !eventsReady || state.phase !== 'idle' ||
!state.status?.supported || !state.status.version) return
pending.current = true
dispatch({ type: 'install' })
try {
await native.installUpdate()
if (mounted.current) dispatch({ type: 'ready' })
} catch (reason) {
if (mounted.current) dispatch({ type: 'failed', error: errorMessage(reason, 'Не удалось установить обновление. Повторите попытку.') })
} finally { pending.current = false }
}
const restart = async () => {
if (!isNative() || pending.current || blocked || state.phase !== 'ready') return
pending.current = true
dispatch({ type: 'restart' })
try {
await native.restartAfterUpdate()
} catch (reason) {
if (mounted.current) dispatch({ type: 'failed', error: errorMessage(reason, 'Не удалось перезапустить лаунчер. Закройте его и откройте снова.') })
} finally { pending.current = false }
}
return { state, eventsReady, eventError, blocked, locksOperations: updateBlocksOperations(state), check, install, restart }
}
+12
View File
@@ -6,6 +6,7 @@ import type {
GameExitedPayload, InstallProgressPayload, JavaInstallation, LauncherSettings,
LinkChallenge, LinkStatus, NativeHost, ProfileInspection, ServerStatus,
ShaCraftAccount, ShaCraftLoginResult, SyncResult,
LauncherUpdateStatus, LauncherUpdateProgress,
} from '../types/launcher'
export const isNative = () => typeof window !== 'undefined' && isTauri()
@@ -26,10 +27,15 @@ export const native = {
accountRequests.enqueue(() => invoke<ShaCraftLoginResult>('shacraft_authenticate', { username, password, register })),
logout: () => accountRequests.enqueue(() => invoke<void>('shacraft_logout')),
startLink: (nickname: string) => accountRequests.enqueue(() => invoke<LinkChallenge>('shacraft_start_link', { nickname })),
claimNickname: (nickname: string) => accountRequests.enqueue(() => invoke<ShaCraftAccount>('shacraft_claim_nickname', { nickname })),
linkStatus: (challengeId: number) => accountRequests.enqueue(() => invoke<LinkStatus>('shacraft_link_status', { challengeId })),
serverStatus: (profileId: string) => invoke<ServerStatus>('get_server_status', { profileId }),
installGame: (profileId: string) => invoke<void>('ensure_game_installed', { profileId }),
launchGame: (profileId: string) => invoke<void>('launch_game', { profileId }),
updateStatus: () => invoke<LauncherUpdateStatus>('get_launcher_update_status'),
checkUpdate: () => invoke<LauncherUpdateStatus>('check_launcher_update'),
installUpdate: () => invoke<void>('install_launcher_update'),
restartAfterUpdate: () => invoke<void>('restart_launcher_after_update'),
}
export const windowControls = {
@@ -47,3 +53,9 @@ export function watchGame(handlers: {
listen<GameExitedPayload>('game-exited', ({ payload }) => handlers.exited(payload)),
])
}
export function watchLauncherUpdate(progress: (payload: LauncherUpdateProgress) => void) {
return createSubscription([
listen<LauncherUpdateProgress>('launcher-update-progress', ({ payload }) => progress(payload)),
])
}
+1 -1
View File
@@ -1,7 +1,7 @@
import type { ShaCraftAccount } from '../types/launcher'
import { isValidNickname } from './settings'
/** Only the authenticated account's verified Aeronautics link selects a name. */
/** Only the authenticated account's verified aoc link selects the shared network nickname. */
export function linkedNickname(account: ShaCraftAccount | null | undefined): string | null {
const nickname = account?.links.find((link) => link.server_id === 'aoc')?.mc_username
return nickname && isValidNickname(nickname) ? nickname : null
+76
View File
@@ -0,0 +1,76 @@
import { equal, match } from 'node:assert/strict'
import { test } from 'node:test'
import { initialUpdaterState, updateBlocksOperations, updatePercent, updaterReducer } from './updater.ts'
const available = updaterReducer(initialUpdaterState, { type: 'loaded', checked: true,
status: { currentVersion: '0.1.3', supported: true, version: '0.1.4' } })
const downloading = updaterReducer(available, { type: 'install' })
test('checking and available update allow play; installation locks operations until restart', () => {
equal(updateBlocksOperations(available), false)
equal(updateBlocksOperations(updaterReducer(available, { type: 'check' })), false)
equal(updateBlocksOperations(downloading), true)
const ready = updaterReducer(downloading, { type: 'ready' })
equal(updateBlocksOperations(ready), true)
equal(updaterReducer(ready, { type: 'check' }), ready)
equal(updaterReducer(ready, { type: 'install' }), ready)
const restarting = updaterReducer(ready, { type: 'restart' })
equal(updateBlocksOperations(restarting), true)
})
test('an installation failure permits playing and retrying; a restart failure retains the lock', () => {
const failed = updaterReducer(downloading, { type: 'failed', error: 'Подпись не совпадает' })
equal(updateBlocksOperations(failed), false)
equal(failed.status?.version, '0.1.4')
const retry = updaterReducer(failed, { type: 'install' })
equal(retry.error, null)
equal(retry.phase, 'downloading')
const ready = updaterReducer(retry, { type: 'ready' })
const restartError = updaterReducer(updaterReducer(ready, { type: 'restart' }), { type: 'failed', error: 'Перезапустите вручную' })
equal(restartError.phase, 'ready')
match(restartError.error ?? '', /вручную/)
equal(updateBlocksOperations(restartError), true)
})
test('late progress cannot undo completion or a failed installation', () => {
const installing = updaterReducer(downloading, { type: 'progress', progress: { stage: 'installing', downloadedBytes: 20 } })
equal(updaterReducer(installing, { type: 'progress', progress: { stage: 'downloading', downloadedBytes: 10 } }), installing)
const ready = updaterReducer(installing, { type: 'progress', progress: { stage: 'ready', downloadedBytes: 20 } })
equal(updaterReducer(ready, { type: 'progress', progress: { stage: 'installing', downloadedBytes: 20 } }), ready)
const failed = updaterReducer(downloading, { type: 'failed', error: 'Сбой сети' })
equal(updaterReducer(failed, { type: 'ready' }), failed)
equal(updaterReducer(failed, { type: 'progress', progress: { stage: 'ready', downloadedBytes: 20 } }), failed)
})
test('native status restores an update after a webview reload; early events preserve readiness', () => {
for (const stage of ['downloading', 'installing', 'ready'] as const) {
const restored = updaterReducer(initialUpdaterState, { type: 'loaded', checked: false,
status: { ...available.status!, stage } })
equal(restored.phase, stage)
equal(updateBlocksOperations(restored), true)
}
const earlyReady = updaterReducer(initialUpdaterState, { type: 'progress', progress: { stage: 'ready', downloadedBytes: 20 } })
const staleStatus = updaterReducer(earlyReady, { type: 'loaded', checked: false,
status: { ...available.status!, stage: 'downloading' } })
equal(staleStatus.phase, 'ready')
equal(staleStatus.status?.currentVersion, '0.1.3')
})
test('unsupported package and no available version never enter installation', () => {
const unsupported = updaterReducer(initialUpdaterState, { type: 'loaded', checked: false,
status: { currentVersion: '0.1.3', supported: false, reason: 'Используйте AppImage' } })
equal(updaterReducer(unsupported, { type: 'install' }), unsupported)
const current = updaterReducer(initialUpdaterState, { type: 'loaded', checked: true,
status: { currentVersion: '0.1.3', supported: true } })
equal(updaterReducer(current, { type: 'install' }), current)
})
test('unknown, invalid and excessive progress cannot produce misleading percentages', () => {
equal(updatePercent(null), null)
equal(updatePercent({ stage: 'downloading', downloadedBytes: 1 }), null)
equal(updatePercent({ stage: 'downloading', downloadedBytes: NaN, totalBytes: 20 }), null)
equal(updatePercent({ stage: 'downloading', downloadedBytes: 1, totalBytes: Infinity }), null)
equal(updatePercent({ stage: 'downloading', downloadedBytes: 10, totalBytes: 20 }), 50)
equal(updatePercent({ stage: 'downloading', downloadedBytes: 30, totalBytes: 20 }), 100)
equal(updatePercent({ stage: 'downloading', downloadedBytes: -1, totalBytes: 20 }), 0)
})
+59
View File
@@ -0,0 +1,59 @@
import type { LauncherUpdateProgress, LauncherUpdateStatus } from '../types/launcher'
export interface UpdaterState {
phase: 'loading' | 'idle' | 'checking' | 'downloading' | 'installing' | 'ready' | 'restarting'
status: LauncherUpdateStatus | null
progress: LauncherUpdateProgress | null
error: string | null
checked: boolean
}
export const initialUpdaterState: UpdaterState = {
phase: 'loading', status: null, progress: null, error: null, checked: false,
}
type UpdaterAction =
| { type: 'loaded'; status: LauncherUpdateStatus; checked: boolean; error?: string | null }
| { type: 'check' }
| { type: 'install' }
| { type: 'progress'; progress: LauncherUpdateProgress }
| { type: 'ready' }
| { type: 'restart' }
| { type: 'failed'; error: string }
export function updateBlocksOperations(state: UpdaterState): boolean {
return ['downloading', 'installing', 'ready', 'restarting'].includes(state.phase)
}
export function updaterReducer(state: UpdaterState, action: UpdaterAction): UpdaterState {
switch (action.type) {
case 'loaded':
if (updateBlocksOperations(state)) return { ...state, status: action.status }
return { ...state, phase: action.status.stage === 'ready' || action.status.stage === 'downloading' || action.status.stage === 'installing'
? action.status.stage : 'idle', status: action.status,
checked: action.checked, error: action.error ?? null }
case 'check':
return updateBlocksOperations(state) ? state : { ...state, phase: 'checking', error: null }
case 'install':
return state.phase !== 'idle' || !state.status?.supported || !state.status.version ? state
: { ...state, phase: 'downloading', progress: null, error: null }
case 'progress':
if (state.phase !== 'loading' && state.phase !== 'downloading' && state.phase !== 'installing') return state
// A delayed download event cannot revert an installation to downloading.
if (state.phase === 'installing' && action.progress.stage === 'downloading') return state
return { ...state, phase: action.progress.stage, progress: action.progress }
case 'ready':
return state.phase === 'downloading' || state.phase === 'installing'
? { ...state, phase: 'ready', error: null } : state
case 'restart':
return state.phase === 'ready' ? { ...state, phase: 'restarting', error: null } : state
case 'failed':
return { ...state, phase: state.phase === 'ready' || state.phase === 'restarting' ? 'ready' : 'idle', error: action.error }
}
}
export function updatePercent(progress: LauncherUpdateProgress | null): number | null {
if (!progress || !progress.totalBytes || progress.totalBytes <= 0 ||
!Number.isFinite(progress.totalBytes) || !Number.isFinite(progress.downloadedBytes)) return null
return Math.max(0, Math.min(100, Math.round(progress.downloadedBytes / progress.totalBytes * 100)))
}
+33 -1
View File
@@ -130,7 +130,7 @@ button:disabled { cursor: default; }
.settings-drawer.open { transform: translateX(0); }
.drawer-title { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 34px; }
.drawer-title p { color: #737c74; font-size: 11px; margin: 0 0 5px; }
.drawer-title h2 { margin: 0; font-family: 'Unbounded'; font-size: 25px; }
.drawer-title h2 { margin: 0; font-family: 'Unbounded', sans-serif; font-size: 21px; }
.drawer-title button { border: 0; background: #222923; width: 38px; height: 38px; border-radius: 10px; display: grid; place-items: center; cursor: pointer; }
.drawer-title button svg { width: 18px; }
.range-setting { display: grid; background: #191f1a; padding: 18px; border-radius: 13px; margin-bottom: 12px; }
@@ -157,6 +157,30 @@ button:disabled { cursor: default; }
.account-hint { font-size: 11px; line-height: 1.5; color: var(--muted); overflow-wrap: anywhere; }
.recovery-codes { white-space: pre-line; font-size: 15px; max-height: 45vh; overflow: auto; }
.settings-feedback button { padding: 6px 10px; background: var(--panel-2); border: 1px solid var(--line); cursor: pointer; }
.update-banner { position: absolute; top: 70px; right: 28px; z-index: 10; display: flex; align-items: center; gap: 9px; max-width: 440px; padding: 10px 14px; border: 1px solid #325939; border-radius: 9px; background: #17291bdc; color: #b6e5b8; font-size: 11px; cursor: pointer; box-shadow: 0 6px 24px #0003; }
.update-banner:hover { background: #203a25; }
.update-banner-dot { width: 6px; height: 6px; flex: 0 0 6px; border-radius: 50%; background: var(--green); }
.launcher-update { border: 1px solid #2c3e30; border-radius: 12px; padding: 17px; margin-bottom: 24px; background: #17201a; }
.launcher-update-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.launcher-update-heading h3 { margin: 0; font-size: 13px; font-weight: 750; }
.launcher-update-heading > span { color: #a6bba8; font-size: 11px; }
.launcher-update p { font-size: 11px; line-height: 1.65; margin: 10px 0 0; color: #97a89a; }
.launcher-update p.update-success { color: #b0e2b1; }
.launcher-update .update-error { color: #eaae89; }
.launcher-update progress { width: 100%; height: 5px; accent-color: var(--green); margin-top: 12px; }
.update-actions { display: flex; align-items: stretch; flex-direction: column; gap: 9px; margin-top: 15px; }
.update-actions button { display: flex; justify-content: center; align-items: center; gap: 8px; border: 1px solid #35543b; border-radius: 7px; min-height: 35px; padding: 8px 10px; font-size: 11px; cursor: pointer; }
.update-actions button:disabled { cursor: default; opacity: .48; }
.update-actions button svg { width: 14px; height: 14px; }
.update-primary { background: var(--green); color: #102313; font-weight: 800; }
.update-primary:hover:not(:disabled) { background: #91df93; }
.update-check { background: transparent; color: #b6c6b9; }
.update-check:hover:not(:disabled) { background: #263b2c; }
.update-notes { margin-top: 10px; font-size: 11px; color: #b6c6b9; }
.update-notes summary { cursor: pointer; }
.update-notes p { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 160px; overflow: auto; }
.launcher-update .update-hint { color: #a8a58e; }
.status-error, .build-state .status-error, .text-setting > .status-error { color: #eea18f; overflow-wrap: anywhere; }
.build-state .status-error { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
@@ -166,6 +190,14 @@ button:disabled { cursor: default; }
.login-code { font-family: 'Unbounded'; font-size: 28px; letter-spacing: .08em; color: var(--green); background: #191f1a; border-radius: 10px; padding: 14px; margin-bottom: 12px; }
.login-url { color: var(--ice) !important; font-size: 11px !important; word-break: break-all; }
.feedback-dialog { width: min(440px, calc(100vw - 40px)); max-height: calc(100vh - 80px); overflow-y: auto; padding: 28px; margin: auto; color: #e9eee9; background: #151d17; border: 1px solid #456348; border-radius: 16px; box-shadow: 0 24px 90px #0009; }
.feedback-dialog::backdrop { background: #050a07b8; }
.feedback-dialog.error { border-color: #a46c54; }
.feedback-dialog h2 { margin: 0 0 14px; font-size: 19px; }
.feedback-dialog p { margin: 0 0 24px; color: #c1cbc2; font-size: 14px; line-height: 1.65; white-space: pre-wrap; overflow-wrap: anywhere; }
.feedback-dialog button { width: 100%; padding: 12px; border: 0; border-radius: 9px; background: var(--green); color: #102112; font: inherit; font-weight: 650; cursor: pointer; }
.feedback-dialog button:focus-visible { outline: 2px solid #e5ffe8; outline-offset: 4px; }
@media (max-width: 1160px) {
.workspace { grid-template-columns: 58px 224px 1fr; }
.brand { width: 240px; }
+16
View File
@@ -80,3 +80,19 @@ export interface GameExitedPayload {
profileId: string
exitCode: number | null
}
export interface LauncherUpdateStatus {
currentVersion: string
supported: boolean
installationKind?: 'appimage' | 'deb' | 'other'
reason?: string | null
version?: string | null
notes?: string | null
stage?: 'idle' | 'checking' | 'available' | 'downloading' | 'installing' | 'ready'
}
export interface LauncherUpdateProgress {
stage: 'downloading' | 'installing' | 'ready'
downloadedBytes: number
totalBytes?: number | null
}
+6
View File
@@ -0,0 +1,6 @@
{
"git": {
"sha1": "6aa2854f314481a459be1189b02c65a2450789ab"
},
"path_in_vcs": "plugins/updater"
}
+190
View File
@@ -0,0 +1,190 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2021"
rust-version = "1.77.2"
name = "tauri-plugin-updater"
version = "2.11.0"
authors = ["Tauri Programme within The Commons Conservancy"]
build = "build.rs"
links = "tauri-plugin-updater"
exclude = [
"/banner.png",
"/guest-js",
"/package.json",
"/rollup.config.js",
"/tests",
"/tsconfig.json",
]
autolib = false
autobins = false
autoexamples = false
autotests = false
autobenches = false
description = "In-app updates for Tauri applications."
readme = "README.md"
license = "Apache-2.0 OR MIT"
repository = "https://github.com/tauri-apps/plugins-workspace"
[package.metadata.docs.rs]
no-default-features = true
features = ["zip"]
[package.metadata.platforms.support.windows]
level = "full"
notes = ""
[package.metadata.platforms.support.linux]
level = "full"
notes = ""
[package.metadata.platforms.support.macos]
level = "full"
notes = ""
[package.metadata.platforms.support.android]
level = "none"
notes = ""
[package.metadata.platforms.support.ios]
level = "none"
notes = ""
[features]
default = [
"rustls-tls",
"system-proxy",
"zip",
]
native-tls = ["reqwest/native-tls"]
native-tls-vendored = ["reqwest/native-tls-vendored"]
rustls-tls = [
"reqwest/rustls-no-provider",
"dep:rustls",
]
system-proxy = ["reqwest/system-proxy"]
zip = [
"dep:zip",
"dep:tar",
"dep:flate2",
]
[lib]
name = "tauri_plugin_updater"
path = "src/lib.rs"
[dependencies.base64]
version = "0.22"
[dependencies.futures-util]
version = "0.3"
[dependencies.http]
version = "1"
[dependencies.infer]
version = "0.19"
[dependencies.log]
version = "0.4.21"
[dependencies.minisign-verify]
version = "0.2"
[dependencies.percent-encoding]
version = "2.3"
[dependencies.reqwest]
version = "0.13"
features = [
"json",
"stream",
]
default-features = false
[dependencies.rustls]
version = "0.23"
features = ["ring"]
optional = true
default-features = false
[dependencies.semver]
version = "1"
features = ["serde"]
[dependencies.serde]
version = "1"
features = ["derive"]
[dependencies.serde_json]
version = "1"
[dependencies.tauri]
version = "2.10"
default-features = false
[dependencies.tempfile]
version = "3.20"
[dependencies.thiserror]
version = "2"
[dependencies.time]
version = "0.3"
features = [
"parsing",
"formatting",
]
[dependencies.tokio]
version = "1"
[dependencies.url]
version = "2"
[build-dependencies.tauri-plugin]
version = "2.5"
features = ["build"]
[target.'cfg(target_os = "linux")'.dependencies.dirs]
version = "6"
[target.'cfg(target_os = "linux")'.dependencies.flate2]
version = "1"
optional = true
[target.'cfg(target_os = "linux")'.dependencies.tar]
version = "0.4"
optional = true
[target.'cfg(target_os = "macos")'.dependencies.flate2]
version = "1"
[target.'cfg(target_os = "macos")'.dependencies.osakit]
version = "0.3"
features = ["full"]
[target.'cfg(target_os = "macos")'.dependencies.tar]
version = "0.4"
[target.'cfg(target_os = "windows")'.dependencies.windows-sys]
version = "0.60.0"
features = [
"Win32_Foundation",
"Win32_UI_WindowsAndMessaging",
"Win32_UI_Shell",
]
[target.'cfg(target_os = "windows")'.dependencies.zip]
version = "4"
optional = true
default-features = false
+83
View File
@@ -0,0 +1,83 @@
[package]
name = "tauri-plugin-updater"
version = "2.11.0"
description = "In-app updates for Tauri applications."
edition = { workspace = true }
authors = { workspace = true }
license = { workspace = true }
rust-version = { workspace = true }
repository = { workspace = true }
links = "tauri-plugin-updater"
exclude = [
"/banner.png",
"/guest-js",
"/package.json",
"/rollup.config.js",
"/tests",
"/tsconfig.json",
]
[package.metadata.docs.rs]
no-default-features = true
features = ["zip"]
[package.metadata.platforms.support]
windows = { level = "full", notes = "" }
linux = { level = "full", notes = "" }
macos = { level = "full", notes = "" }
android = { level = "none", notes = "" }
ios = { level = "none", notes = "" }
[build-dependencies]
tauri-plugin = { workspace = true, features = ["build"] }
[dependencies]
tauri = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
tokio = "1"
reqwest = { version = "0.13", default-features = false, features = [
"json",
"stream",
] }
rustls = { version = "0.23", default-features = false, features = [
"ring",
], optional = true }
url = { workspace = true }
http = "1"
minisign-verify = "0.2"
time = { version = "0.3", features = ["parsing", "formatting"] }
base64 = "0.22"
semver = { version = "1", features = ["serde"] }
futures-util = "0.3"
tempfile = "3.20"
infer = "0.19"
percent-encoding = "2.3"
[target."cfg(target_os = \"windows\")".dependencies]
zip = { version = "4", default-features = false, optional = true }
windows-sys = { version = "0.60.0", features = [
"Win32_Foundation",
"Win32_UI_WindowsAndMessaging",
"Win32_UI_Shell",
] }
[target."cfg(target_os = \"linux\")".dependencies]
dirs = "6"
tar = { version = "0.4", optional = true }
flate2 = { version = "1", optional = true }
[target."cfg(target_os = \"macos\")".dependencies]
tar = "0.4"
flate2 = "1"
osakit = { version = "0.3", features = ["full"] }
[features]
default = ["rustls-tls", "system-proxy", "zip"]
zip = ["dep:zip", "dep:tar", "dep:flate2"]
native-tls = ["reqwest/native-tls"]
native-tls-vendored = ["reqwest/native-tls-vendored"]
rustls-tls = ["reqwest/rustls-no-provider", "dep:rustls"]
system-proxy = ["reqwest/system-proxy"]
+20
View File
@@ -0,0 +1,20 @@
SPDXVersion: SPDX-2.1
DataLicense: CC0-1.0
PackageName: tauri
DataFormat: SPDXRef-1
PackageSupplier: Organization: The Tauri Programme in the Commons Conservancy
PackageHomePage: https://tauri.app
PackageLicenseDeclared: Apache-2.0
PackageLicenseDeclared: MIT
PackageCopyrightText: 2019-2022, The Tauri Programme in the Commons Conservancy
PackageSummary: <text>Tauri is a rust project that enables developers to make secure
and small desktop applications using a web frontend.
</text>
PackageComment: <text>The package includes the following libraries; see
Relationship information.
</text>
Created: 2019-05-20T09:00:00Z
PackageDownloadLocation: git://github.com/tauri-apps/tauri
PackageDownloadLocation: git+https://github.com/tauri-apps/tauri.git
PackageDownloadLocation: git+ssh://github.com/tauri-apps/tauri.git
Creator: Person: Daniel Thompson-Yvetot
+177
View File
@@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2017 - Present Tauri Apps Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+44
View File
@@ -0,0 +1,44 @@
# ShaCraft metadata-only updater patch
This directory vendors the Rust/build portion of the official
`tauri-plugin-updater` **2.11.0** crate. Files were copied from the local Cargo
registry cache, and every copied file was checked byte-for-byte against the
cached `.crate` archive before applying this patch. No replacement crate was
downloaded for this vendoring step.
Upstream: <https://github.com/tauri-apps/plugins-workspace/tree/6aa2854f314481a459be1189b02c65a2450789ab/plugins/updater>
The published archive's SHA-256 is
`b28d8cabdeb0564f03ae261963de4bc3d98321cd3d213e76a81b7d344e5df606`.
The original registry `.cargo_vcs_info.json`, normalized `Cargo.toml` and
`Cargo.toml.orig` are retained for provenance. Unused upstream JS source, package
lockfile, changelog and Cargo cache marker are omitted; `api-iife.js` is retained
because the official build script references it.
## Local change
Only `src/updater.rs` changes upstream Rust behavior. The new public method
`Updater::check_metadata(&self, raw_json: serde_json::Value) -> Result<Option<Update>>`
is synchronous and performs **no HTTP requests**. It uses the existing release
deserializer, current-version/comparator decision, platform URL/signature
selection and `Update` construction. `Updater::check()` keeps its original
endpoint iteration and validation, then calls this same method. Its accepted
release is parsed again by the helper; endpoint selection and error semantics
are preserved.
ShaCraft fetches a size-limited metadata response and verifies its signature in
its own native trust boundary before passing that exact JSON value to this
method. This avoids asking the upstream `check()` path to fetch and parse a
second, potentially unbounded metadata response. This helper does not itself
authenticate metadata; callers must enforce their own policy.
The upstream package download, Minisign verification and all platform installer
implementations are unchanged by this patch. ShaCraft's own native integration
may choose a separate bounded download and installation path.
## License and maintenance
Upstream is dual licensed **Apache-2.0 OR MIT**. Both full license files,
`LICENSE.spdx`, copyright notices and `SECURITY.md` remain in this directory.
Rebase this small patch when upgrading the dependency, retain upstream notices,
and repeat native updater tests before release.
+103
View File
@@ -0,0 +1,103 @@
![plugin-updater](https://github.com/tauri-apps/plugins-workspace/raw/v2/plugins/updater/banner.png)
In-app updates for Tauri applications.
| Platform | Supported |
| -------- | --------- |
| Linux | ✓ |
| Windows | ✓ |
| macOS | ✓ |
| Android | x |
| iOS | x |
## Install
_This plugin requires a Rust version of at least **1.77.2**_
There are three general methods of installation that we can recommend.
1. Use crates.io and npm (easiest, and requires you to trust that our publishing pipeline worked)
2. Pull sources directly from Github using git tags / revision hashes (most secure)
3. Git submodule install this repo in your tauri project and then use file protocol to ingest the source (most secure, but inconvenient to use)
Install the Core plugin by adding the following to your `Cargo.toml` file:
`src-tauri/Cargo.toml`
```toml
# you can add the dependencies on the `[dependencies]` section if you do not target mobile
[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]
tauri-plugin-updater = "2.0.0"
# alternatively with Git:
tauri-plugin-updater = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
```
You can install the JavaScript Guest bindings using your preferred JavaScript package manager:
```sh
pnpm add @tauri-apps/plugin-updater
# or
npm add @tauri-apps/plugin-updater
# or
yarn add @tauri-apps/plugin-updater
```
## Usage
First you need to register the core plugin with Tauri:
`src-tauri/src/lib.rs`
```rust
fn main() {
tauri::Builder::default()
.setup(|app| {
#[cfg(desktop)]
app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
```
Afterwards all the plugin's APIs are available through the JavaScript guest bindings:
```javascript
import { check } from '@tauri-apps/plugin-updater'
import { relaunch } from '@tauri-apps/plugin-process'
const update = await check()
if (update) {
await update.downloadAndInstall()
// Relaunch the app on macOS and Linux to run the newly install version
await relaunch()
}
```
Note that for these APIs to work you have to properly configure the updater first and generate updater artifacts. Please refer to the [guide on our website](https://v2.tauri.app/plugin/updater/) for this.
## Contributing
PRs accepted. Please make sure to read the Contributing Guide before making a pull request.
## Partners
<table>
<tbody>
<tr>
<td align="center" valign="middle">
<a href="https://crabnebula.dev" target="_blank">
<img src="https://github.com/tauri-apps/plugins-workspace/raw/v2/.github/sponsors/crabnebula.svg" alt="CrabNebula" width="283">
</a>
</td>
</tr>
</tbody>
</table>
For the complete list of sponsors please visit our [website](https://tauri.app#sponsors) and [Open Collective](https://opencollective.com/tauri).
## License
Code: (c) 2015 - Present - The Tauri Programme within The Commons Conservancy.
MIT or MIT/Apache 2.0 where applicable.
+23
View File
@@ -0,0 +1,23 @@
# Security Policy
**Do not report security vulnerabilities through public GitHub issues.**
**Please use the [Private Vulnerability Disclosure](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability) feature of GitHub.**
Include as much of the following information:
- Type of issue (e.g. improper input parsing, privilege escalation, etc.)
- The location of the affected source code (tag/branch/commit or direct URL)
- Any special configuration required to reproduce the issue
- The distribution affected or used to help us with reproduction of the issue
- Step-by-step instructions to reproduce the issue
- Ideally a reproduction repository
- Impact of the issue, including how an attacker might exploit the issue
We prefer to receive reports in English.
## Contact
Please disclose a vulnerability or security relevant issue here: [https://github.com/tauri-apps/plugins-workspace/security/advisories/new](https://github.com/tauri-apps/plugins-workspace/security/advisories/new).
Alternatively, you can also contact us by email via [security@tauri.app](mailto:security@tauri.app).
+1
View File
@@ -0,0 +1 @@
if("__TAURI__"in window){var __TAURI_PLUGIN_UPDATER__=function(t){"use strict";function e(t,e,s,n){if("function"==typeof e?t!==e||!n:!e.has(t))throw new TypeError("Cannot read private member from an object whose class did not declare it");return"m"===s?n:"a"===s?n.call(t):n?n.value:e.get(t)}function s(t,e,s,n,i){if("function"==typeof e||!e.has(t))throw new TypeError("Cannot write private member to an object whose class did not declare it");return e.set(t,s),s}var n,i,a,r,o;"function"==typeof SuppressedError&&SuppressedError;const d="__TAURI_TO_IPC_KEY__";class c{constructor(t){n.set(this,void 0),i.set(this,0),a.set(this,[]),r.set(this,void 0),s(this,n,t||(()=>{})),this.id=function(t,e=!1){return window.__TAURI_INTERNALS__.transformCallback(t,e)}(t=>{const o=t.index;if("end"in t)return void(o==e(this,i,"f")?this.cleanupCallback():s(this,r,o));const d=t.message;if(o==e(this,i,"f")){for(e(this,n,"f").call(this,d),s(this,i,e(this,i,"f")+1);e(this,i,"f")in e(this,a,"f");){const t=e(this,a,"f")[e(this,i,"f")];e(this,n,"f").call(this,t),delete e(this,a,"f")[e(this,i,"f")],s(this,i,e(this,i,"f")+1)}e(this,i,"f")===e(this,r,"f")&&this.cleanupCallback()}else e(this,a,"f")[o]=d})}cleanupCallback(){window.__TAURI_INTERNALS__.unregisterCallback(this.id)}set onmessage(t){s(this,n,t)}get onmessage(){return e(this,n,"f")}[(n=new WeakMap,i=new WeakMap,a=new WeakMap,r=new WeakMap,d)](){return`__CHANNEL__:${this.id}`}toJSON(){return this[d]()}}async function l(t,e={},s){return window.__TAURI_INTERNALS__.invoke(t,e,s)}class h{get rid(){return e(this,o,"f")}constructor(t){o.set(this,void 0),s(this,o,t)}async close(){return l("plugin:resources|close",{rid:this.rid})}}o=new WeakMap;class u extends h{constructor(t){super(t.rid),this.available=!0,this.currentVersion=t.currentVersion,this.version=t.version,this.date=t.date,this.body=t.body,this.rawJson=t.rawJson}async download(t,e){_(e);const s=new c;t&&(s.onmessage=t);const n=await l("plugin:updater|download",{onEvent:s,rid:this.rid,...e});this.downloadedBytes=new h(n)}async install(t){if(!this.downloadedBytes)throw new Error("Update.install called before Update.download");await l("plugin:updater|install",{updateRid:this.rid,bytesRid:this.downloadedBytes.rid,...t}),this.downloadedBytes=void 0}async downloadAndInstall(t,e){_(e);const s=new c;t&&(s.onmessage=t),await l("plugin:updater|download_and_install",{onEvent:s,rid:this.rid,...e})}async close(){await(this.downloadedBytes?.close()),await super.close()}}function _(t){t?.headers&&(t.headers=Array.from(new Headers(t.headers).entries()))}return t.Update=u,t.check=async function(t){_(t);const e=await l("plugin:updater|check",{...t});return e?new u(e):null},t}({});Object.defineProperty(window.__TAURI__,"updater",{value:__TAURI_PLUGIN_UPDATER__})}
+25
View File
@@ -0,0 +1,25 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
const COMMANDS: &[&str] = &["check", "download", "install", "download_and_install"];
fn main() {
tauri_plugin::Builder::new(COMMANDS)
.global_api_script_path("./api-iife.js")
.build();
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap();
let mobile = target_os == "ios" || target_os == "android";
alias("desktop", !mobile);
alias("mobile", mobile);
}
// creates a cfg alias if `has_feature` is true.
// `alias` must be a snake case string.
fn alias(alias: &str, has_feature: bool) {
println!("cargo:rustc-check-cfg=cfg({alias})");
if has_feature {
println!("cargo:rustc-cfg={alias}");
}
}
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-check"
description = "Enables the check command without any pre-configured scope."
commands.allow = ["check"]
[[permission]]
identifier = "deny-check"
description = "Denies the check command without any pre-configured scope."
commands.deny = ["check"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-download"
description = "Enables the download command without any pre-configured scope."
commands.allow = ["download"]
[[permission]]
identifier = "deny-download"
description = "Denies the download command without any pre-configured scope."
commands.deny = ["download"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-download-and-install"
description = "Enables the download_and_install command without any pre-configured scope."
commands.allow = ["download_and_install"]
[[permission]]
identifier = "deny-download-and-install"
description = "Denies the download_and_install command without any pre-configured scope."
commands.deny = ["download_and_install"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-install"
description = "Enables the install command without any pre-configured scope."
commands.allow = ["install"]
[[permission]]
identifier = "deny-install"
description = "Denies the install command without any pre-configured scope."
commands.deny = ["install"]
@@ -0,0 +1,130 @@
## Default Permission
This permission set configures which kind of
updater functions are exposed to the frontend.
#### Granted Permissions
The full workflow from checking for updates to installing them
is enabled.
#### This default permission set includes the following:
- `allow-check`
- `allow-download`
- `allow-install`
- `allow-download-and-install`
## Permission Table
<table>
<tr>
<th>Identifier</th>
<th>Description</th>
</tr>
<tr>
<td>
`updater:allow-check`
</td>
<td>
Enables the check command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`updater:deny-check`
</td>
<td>
Denies the check command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`updater:allow-download`
</td>
<td>
Enables the download command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`updater:deny-download`
</td>
<td>
Denies the download command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`updater:allow-download-and-install`
</td>
<td>
Enables the download_and_install command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`updater:deny-download-and-install`
</td>
<td>
Denies the download_and_install command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`updater:allow-install`
</td>
<td>
Enables the install command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`updater:deny-install`
</td>
<td>
Denies the install command without any pre-configured scope.
</td>
</tr>
</table>
+18
View File
@@ -0,0 +1,18 @@
"$schema" = "schemas/schema.json"
[default]
description = """
This permission set configures which kind of
updater functions are exposed to the frontend.
#### Granted Permissions
The full workflow from checking for updates to installing them
is enabled.
"""
permissions = [
"allow-check",
"allow-download",
"allow-install",
"allow-download-and-install",
]
@@ -0,0 +1,354 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "PermissionFile",
"description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.",
"type": "object",
"properties": {
"default": {
"description": "The default permission set for the plugin",
"anyOf": [
{
"$ref": "#/definitions/DefaultPermission"
},
{
"type": "null"
}
]
},
"set": {
"description": "A list of permissions sets defined",
"type": "array",
"items": {
"$ref": "#/definitions/PermissionSet"
}
},
"permission": {
"description": "A list of inlined permissions",
"default": [],
"type": "array",
"items": {
"$ref": "#/definitions/Permission"
}
}
},
"definitions": {
"DefaultPermission": {
"description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.",
"type": "object",
"required": [
"permissions"
],
"properties": {
"version": {
"description": "The version of the permission.",
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 1.0
},
"description": {
"description": "Human-readable description of what the permission does. Tauri convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
"type": [
"string",
"null"
]
},
"permissions": {
"description": "All permissions this set contains.",
"type": "array",
"items": {
"type": "string"
}
}
}
},
"PermissionSet": {
"description": "A set of direct permissions grouped together under a new name.",
"type": "object",
"required": [
"description",
"identifier",
"permissions"
],
"properties": {
"identifier": {
"description": "A unique identifier for the permission.",
"type": "string"
},
"description": {
"description": "Human-readable description of what the permission does.",
"type": "string"
},
"permissions": {
"description": "All permissions this set contains.",
"type": "array",
"items": {
"$ref": "#/definitions/PermissionKind"
}
}
}
},
"Permission": {
"description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.",
"type": "object",
"required": [
"identifier"
],
"properties": {
"version": {
"description": "The version of the permission.",
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 1.0
},
"identifier": {
"description": "A unique identifier for the permission.",
"type": "string"
},
"description": {
"description": "Human-readable description of what the permission does. Tauri internal convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
"type": [
"string",
"null"
]
},
"commands": {
"description": "Allowed or denied commands when using this permission.",
"default": {
"allow": [],
"deny": []
},
"allOf": [
{
"$ref": "#/definitions/Commands"
}
]
},
"scope": {
"description": "Allowed or denied scoped when using this permission.",
"allOf": [
{
"$ref": "#/definitions/Scopes"
}
]
},
"platforms": {
"description": "Target platforms this permission applies. By default all platforms are affected by this permission.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Target"
}
}
}
},
"Commands": {
"description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.",
"type": "object",
"properties": {
"allow": {
"description": "Allowed command.",
"default": [],
"type": "array",
"items": {
"type": "string"
}
},
"deny": {
"description": "Denied command, which takes priority.",
"default": [],
"type": "array",
"items": {
"type": "string"
}
}
}
},
"Scopes": {
"description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```",
"type": "object",
"properties": {
"allow": {
"description": "Data that defines what is allowed by the scope.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Value"
}
},
"deny": {
"description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Value"
}
}
}
},
"Value": {
"description": "All supported ACL values.",
"anyOf": [
{
"description": "Represents a null JSON value.",
"type": "null"
},
{
"description": "Represents a [`bool`].",
"type": "boolean"
},
{
"description": "Represents a valid ACL [`Number`].",
"allOf": [
{
"$ref": "#/definitions/Number"
}
]
},
{
"description": "Represents a [`String`].",
"type": "string"
},
{
"description": "Represents a list of other [`Value`]s.",
"type": "array",
"items": {
"$ref": "#/definitions/Value"
}
},
{
"description": "Represents a map of [`String`] keys to [`Value`]s.",
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/Value"
}
}
]
},
"Number": {
"description": "A valid ACL number.",
"anyOf": [
{
"description": "Represents an [`i64`].",
"type": "integer",
"format": "int64"
},
{
"description": "Represents a [`f64`].",
"type": "number",
"format": "double"
}
]
},
"Target": {
"description": "Platform target.",
"oneOf": [
{
"description": "MacOS.",
"type": "string",
"enum": [
"macOS"
]
},
{
"description": "Windows.",
"type": "string",
"enum": [
"windows"
]
},
{
"description": "Linux.",
"type": "string",
"enum": [
"linux"
]
},
{
"description": "Android.",
"type": "string",
"enum": [
"android"
]
},
{
"description": "iOS.",
"type": "string",
"enum": [
"iOS"
]
}
]
},
"PermissionKind": {
"type": "string",
"oneOf": [
{
"description": "Enables the check command without any pre-configured scope.",
"type": "string",
"const": "allow-check",
"markdownDescription": "Enables the check command without any pre-configured scope."
},
{
"description": "Denies the check command without any pre-configured scope.",
"type": "string",
"const": "deny-check",
"markdownDescription": "Denies the check command without any pre-configured scope."
},
{
"description": "Enables the download command without any pre-configured scope.",
"type": "string",
"const": "allow-download",
"markdownDescription": "Enables the download command without any pre-configured scope."
},
{
"description": "Denies the download command without any pre-configured scope.",
"type": "string",
"const": "deny-download",
"markdownDescription": "Denies the download command without any pre-configured scope."
},
{
"description": "Enables the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "allow-download-and-install",
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
},
{
"description": "Denies the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "deny-download-and-install",
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
},
{
"description": "Enables the install command without any pre-configured scope.",
"type": "string",
"const": "allow-install",
"markdownDescription": "Enables the install command without any pre-configured scope."
},
{
"description": "Denies the install command without any pre-configured scope.",
"type": "string",
"const": "deny-install",
"markdownDescription": "Denies the install command without any pre-configured scope."
},
{
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
"type": "string",
"const": "default",
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
}
]
}
}
}
+212
View File
@@ -0,0 +1,212 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use crate::{Result, Update, UpdaterExt};
use http::{HeaderMap, HeaderName, HeaderValue};
use serde::Serialize;
use tauri::{ipc::Channel, Manager, Resource, ResourceId, Runtime, Webview};
use std::{str::FromStr, time::Duration};
use url::Url;
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "event", content = "data")]
pub enum DownloadEvent {
#[serde(rename_all = "camelCase")]
Started {
content_length: Option<u64>,
},
#[serde(rename_all = "camelCase")]
Progress {
chunk_length: usize,
},
Finished,
}
#[derive(Serialize, Default)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Metadata {
rid: ResourceId,
current_version: String,
version: String,
date: Option<String>,
body: Option<String>,
raw_json: serde_json::Value,
}
struct DownloadedBytes(pub Vec<u8>);
impl Resource for DownloadedBytes {}
#[tauri::command]
pub(crate) async fn check<R: Runtime>(
webview: Webview<R>,
headers: Option<Vec<(String, String)>>,
timeout: Option<u64>,
proxy: Option<String>,
target: Option<String>,
allow_downgrades: Option<bool>,
) -> Result<Option<Metadata>> {
let mut builder = webview.updater_builder();
if let Some(headers) = headers {
for (k, v) in headers {
builder = builder.header(k, v)?;
}
}
if let Some(timeout) = timeout {
builder = builder.timeout(Duration::from_millis(timeout));
}
if let Some(ref proxy) = proxy {
let url = Url::parse(proxy.as_str())?;
builder = builder.proxy(url);
}
if let Some(target) = target {
builder = builder.target(target);
}
if allow_downgrades.unwrap_or(false) {
builder = builder.version_comparator(|current, update| update.version != current);
}
let updater = builder.build()?;
let update = updater.check().await?;
if let Some(update) = update {
let formatted_date = if let Some(date) = update.date {
let formatted_date = date
.format(&time::format_description::well_known::Rfc3339)
.map_err(|_| crate::Error::FormatDate)?;
Some(formatted_date)
} else {
None
};
let metadata = Metadata {
current_version: update.current_version.clone(),
version: update.version.clone(),
date: formatted_date,
body: update.body.clone(),
raw_json: update.raw_json.clone(),
rid: webview.resources_table().add(update),
};
Ok(Some(metadata))
} else {
Ok(None)
}
}
#[tauri::command]
pub(crate) async fn download<R: Runtime>(
webview: Webview<R>,
rid: ResourceId,
on_event: Channel<DownloadEvent>,
headers: Option<Vec<(String, String)>>,
timeout: Option<u64>,
) -> Result<ResourceId> {
let update = webview.resources_table().get::<Update>(rid)?;
let mut update = (*update).clone();
if let Some(headers) = headers {
let mut map = HeaderMap::new();
for (k, v) in headers {
map.append(HeaderName::from_str(&k)?, HeaderValue::from_str(&v)?);
}
update.headers = map;
}
if let Some(timeout) = timeout {
update.timeout = Some(Duration::from_millis(timeout));
}
let mut first_chunk = true;
let bytes = update
.download(
|chunk_length, content_length| {
if first_chunk {
first_chunk = !first_chunk;
let _ = on_event.send(DownloadEvent::Started { content_length });
}
let _ = on_event.send(DownloadEvent::Progress { chunk_length });
},
|| {
let _ = on_event.send(DownloadEvent::Finished);
},
)
.await?;
Ok(webview.resources_table().add(DownloadedBytes(bytes)))
}
#[tauri::command]
pub(crate) async fn install<R: Runtime>(
webview: Webview<R>,
update_rid: ResourceId,
bytes_rid: ResourceId,
restart_after_install: Option<bool>,
) -> Result<()> {
let update = webview.resources_table().get::<Update>(update_rid)?;
let bytes = webview
.resources_table()
.get::<DownloadedBytes>(bytes_rid)?;
if let Some(restart_after_install) = restart_after_install {
let update = (*update).clone();
update
.restart_after_install(restart_after_install)
.install(&bytes.0)?;
} else {
update.install(&bytes.0)?;
}
let _ = webview.resources_table().close(bytes_rid);
Ok(())
}
#[tauri::command]
pub(crate) async fn download_and_install<R: Runtime>(
webview: Webview<R>,
rid: ResourceId,
on_event: Channel<DownloadEvent>,
headers: Option<Vec<(String, String)>>,
timeout: Option<u64>,
restart_after_install: Option<bool>,
) -> Result<()> {
let update = webview.resources_table().get::<Update>(rid)?;
let mut update = (*update).clone();
if let Some(headers) = headers {
let mut map = HeaderMap::new();
for (k, v) in headers {
map.append(HeaderName::from_str(&k)?, HeaderValue::from_str(&v)?);
}
update.headers = map;
}
if let Some(timeout) = timeout {
update.timeout = Some(Duration::from_millis(timeout));
}
if let Some(restart_after_install) = restart_after_install {
update = update.restart_after_install(restart_after_install);
}
let mut first_chunk = true;
update
.download_and_install(
|chunk_length, content_length| {
if first_chunk {
first_chunk = !first_chunk;
let _ = on_event.send(DownloadEvent::Started { content_length });
}
let _ = on_event.send(DownloadEvent::Progress { chunk_length });
},
|| {
let _ = on_event.send(DownloadEvent::Finished);
},
)
.await?;
Ok(())
}
+179
View File
@@ -0,0 +1,179 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use std::{ffi::OsString, fmt::Display};
use serde::{Deserialize, Deserializer};
use url::Url;
/// Install modes for the Windows update.
#[derive(Debug, PartialEq, Eq, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
#[derive(Default)]
pub enum WindowsUpdateInstallMode {
/// Specifies there's a basic UI during the installation process, including a final dialog box at the end.
BasicUi,
/// The quiet mode means there's no user interaction required.
/// Requires admin privileges if the installer does.
Quiet,
/// Specifies unattended mode, which means the installation only shows a progress bar.
#[default]
Passive,
}
impl WindowsUpdateInstallMode {
/// Returns the associated `msiexec.exe` arguments.
pub fn msiexec_args(&self) -> &'static [&'static str] {
match self {
Self::BasicUi => &["/qb+"],
Self::Quiet => &["/quiet"],
Self::Passive => &["/passive"],
}
}
#[cfg(windows)]
pub(crate) fn msi_restart_after_install_args(&self) -> &'static [&'static str] {
&["AUTOLAUNCHAPP=True"]
}
/// Returns the associated nsis arguments.
pub fn nsis_args(&self) -> &'static [&'static str] {
// `/P`: Passive
// `/S`: Silent
// `/R`: Restart
match self {
Self::Passive => &["/P"],
Self::Quiet => &["/S"],
_ => &[],
}
}
#[cfg(windows)]
pub(crate) fn nsis_restart_after_install_args(&self) -> &'static [&'static str] {
match self {
Self::BasicUi => &[],
_ => &["/R"],
}
}
}
impl Display for WindowsUpdateInstallMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
match self {
Self::BasicUi => "basicUi",
Self::Quiet => "quiet",
Self::Passive => "passive",
}
)
}
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct WindowsConfig {
/// Additional arguments given to the NSIS or WiX installer.
///
/// Note: this applies to both WiX and NSIS installers
#[serde(
default,
alias = "installer-args",
deserialize_with = "deserialize_os_string"
)]
pub installer_args: Vec<OsString>,
/// Updating mode, defaults to `passive` mode.
///
/// See [`WindowsUpdateInstallMode`] for more info.
#[serde(default, alias = "install-mode")]
pub install_mode: WindowsUpdateInstallMode,
}
fn deserialize_os_string<'de, D>(deserializer: D) -> Result<Vec<OsString>, D::Error>
where
D: Deserializer<'de>,
{
Ok(Vec::<String>::deserialize(deserializer)?
.into_iter()
.map(OsString::from)
.collect::<Vec<_>>())
}
/// Updater configuration.
#[derive(Debug, Clone, Default)]
pub struct Config {
/// Dangerously allow using insecure transport protocols for update endpoints.
pub dangerous_insecure_transport_protocol: bool,
/// Dangerously accept invalid TLS certificates for update requests.
pub dangerous_accept_invalid_certs: bool,
/// Dangerously accept invalid hostnames for TLS certificates for update requests.
pub dangerous_accept_invalid_hostnames: bool,
/// Updater endpoints.
pub endpoints: Vec<Url>,
/// Signature public key.
pub pubkey: String,
/// The Windows configuration for the updater.
pub windows: Option<WindowsConfig>,
}
impl<'de> Deserialize<'de> for Config {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Config {
#[serde(default, alias = "dangerous-insecure-transport-protocol")]
pub dangerous_insecure_transport_protocol: bool,
#[serde(default, alias = "dangerous-accept-invalid-certs")]
pub dangerous_accept_invalid_certs: bool,
#[serde(default, alias = "dangerous-accept-invalid-hostnames")]
pub dangerous_accept_invalid_hostnames: bool,
#[serde(default)]
pub endpoints: Vec<Url>,
pub pubkey: String,
pub windows: Option<WindowsConfig>,
}
let config = Config::deserialize(deserializer)?;
validate_endpoints(
&config.endpoints,
config.dangerous_insecure_transport_protocol,
)
.map_err(serde::de::Error::custom)?;
Ok(Self {
dangerous_insecure_transport_protocol: config.dangerous_insecure_transport_protocol,
dangerous_accept_invalid_certs: config.dangerous_accept_invalid_certs,
dangerous_accept_invalid_hostnames: config.dangerous_accept_invalid_hostnames,
endpoints: config.endpoints,
pubkey: config.pubkey,
windows: config.windows,
})
}
}
pub(crate) fn validate_endpoints(
endpoints: &[Url],
dangerous_insecure_transport_protocol: bool,
) -> crate::Result<()> {
if !dangerous_insecure_transport_protocol {
for url in endpoints {
if url.scheme() != "https" {
#[cfg(debug_assertions)]
{
eprintln!("[\x1b[33mWARNING\x1b[0m] The updater endpoint \"{url}\" doesn't use `https` protocol. This is allowed in development but will fail in release builds.");
eprintln!("[\x1b[33mWARNING\x1b[0m] if this is a desired behavior, you can enable `dangerousInsecureTransportProtocol` in the plugin configuration");
}
#[cfg(not(debug_assertions))]
return Err(crate::Error::InsecureTransportProtocol);
}
}
}
Ok(())
}
+105
View File
@@ -0,0 +1,105 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use serde::{Serialize, Serializer};
use thiserror::Error;
/// All errors that can occur while running the updater.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum Error {
/// Endpoints are not sent.
#[error("Updater does not have any endpoints set.")]
EmptyEndpoints,
/// IO errors.
#[error(transparent)]
Io(#[from] std::io::Error),
/// Semver errors.
#[error(transparent)]
Semver(#[from] semver::Error),
/// Serialization errors.
#[error(transparent)]
Serialization(#[from] serde_json::Error),
/// Could not fetch a valid response from the server.
#[error("Could not fetch a valid release JSON from the remote")]
ReleaseNotFound,
/// Unsupported app architecture.
#[error("Unsupported application architecture, expected one of `x86`, `x86_64`, `arm` or `aarch64`.")]
UnsupportedArch,
/// Operating system is not supported.
#[error("Unsupported OS, expected one of `linux`, `darwin` or `windows`.")]
UnsupportedOs,
/// Failed to determine updater package extract path
#[error("Failed to determine updater package extract path.")]
FailedToDetermineExtractPath,
/// Url parsing errors.
#[error(transparent)]
UrlParse(#[from] url::ParseError),
/// `reqwest` crate errors.
#[error(transparent)]
Reqwest(#[from] reqwest::Error),
/// The platform was not found in the updater JSON response.
#[error("the platform `{0}` was not found in the response `platforms` object")]
TargetNotFound(String),
/// Neither the platform nor the fallback platform was found in the updater JSON response.
#[error(
"None of the fallback platforms `{0:?}` were found in the response `platforms` object"
)]
TargetsNotFound(Vec<String>),
/// Download failed
#[error("`{0}`")]
Network(String),
/// `minisign_verify` errors.
#[error(transparent)]
Minisign(#[from] minisign_verify::Error),
/// `base64` errors.
#[error(transparent)]
Base64(#[from] base64::DecodeError),
/// UTF8 Errors in signature.
#[error("The signature {0} could not be decoded, please check if it is a valid base64 string. The signature must be the contents of the `.sig` file generated by the Tauri bundler, as a string.")]
SignatureUtf8(String),
#[cfg(all(target_os = "windows", feature = "zip"))]
/// `zip` errors.
#[error(transparent)]
Extract(#[from] zip::result::ZipError),
/// Temp dir is not on same mount mount. This prevents our updater to rename the AppImage to a temp file.
#[error("temp directory is not on the same mount point as the AppImage")]
TempDirNotOnSameMountPoint,
#[error("binary for the current target not found in the archive")]
BinaryNotFoundInArchive,
#[error("failed to create temporary directory")]
TempDirNotFound,
#[error("Authentication failed or was cancelled")]
AuthenticationFailed,
#[error("Failed to install .deb package")]
DebInstallFailed,
#[error("Failed to install package")]
PackageInstallFailed,
#[error("invalid updater binary format")]
InvalidUpdaterFormat,
#[error(transparent)]
Http(#[from] http::Error),
#[error(transparent)]
InvalidHeaderValue(#[from] http::header::InvalidHeaderValue),
#[error(transparent)]
InvalidHeaderName(#[from] http::header::InvalidHeaderName),
#[error("Failed to format date")]
FormatDate,
/// The configured updater endpoint must use a secure protocol like `https`
#[error("The configured updater endpoint must use a secure protocol like `https`.")]
InsecureTransportProtocol,
#[error(transparent)]
Tauri(#[from] tauri::Error),
}
impl Serialize for Error {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
}
pub type Result<T> = std::result::Result<T, Error>;
+248
View File
@@ -0,0 +1,248 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
//! In-app updates for Tauri applications.
//!
//! Supported platforms: Windows, Linux and macOS.
//!
//! ## Cargo features
//!
//! - **zip** *(enabled by default)*: Adds support for compressed updater bundles from Tauri v1.
//! - **rustls-tls** *(enabled by default)*: Enables TLS functionality provided by `rustls`.
//! - **native-tls**: Enables TLS functionality provided by `native-tls`.
//! - **native-tls-vendored**: Enables the `vendored` feature of `native-tls`.
//! - **system-proxy** *(enabled by default)*: Use Windows and macOS system proxy settings automatically.
#![doc(
html_logo_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png",
html_favicon_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png"
)]
use std::{ffi::OsString, sync::Arc};
use http::{HeaderMap, HeaderName, HeaderValue};
use semver::Version;
use tauri::{
plugin::{Builder as PluginBuilder, TauriPlugin},
Manager, Runtime,
};
mod commands;
mod config;
mod error;
mod updater;
pub use config::Config;
pub use error::{Error, Result};
pub use updater::*;
/// Extensions to [`tauri::App`], [`tauri::AppHandle`], [`tauri::WebviewWindow`], [`tauri::Webview`] and [`tauri::Window`] to access the updater APIs.
pub trait UpdaterExt<R: Runtime> {
/// Gets the updater builder to build and updater
/// that can manually check if an update is available.
///
/// # Examples
///
/// ```no_run
/// use tauri_plugin_updater::UpdaterExt;
/// tauri::Builder::default()
/// .setup(|app| {
/// let handle = app.handle().clone();
/// tauri::async_runtime::spawn(async move {
/// let response = handle.updater_builder().build().unwrap().check().await;
/// });
/// Ok(())
/// });
/// ```
fn updater_builder(&self) -> UpdaterBuilder;
/// Gets the updater to manually check if an update is available.
///
/// # Examples
///
/// ```no_run
/// use tauri_plugin_updater::UpdaterExt;
/// tauri::Builder::default()
/// .setup(|app| {
/// let handle = app.handle().clone();
/// tauri::async_runtime::spawn(async move {
/// let response = handle.updater().unwrap().check().await;
/// });
/// Ok(())
/// });
/// ```
fn updater(&self) -> Result<Updater>;
}
impl<R: Runtime, T: Manager<R>> UpdaterExt<R> for T {
fn updater_builder(&self) -> UpdaterBuilder {
let app = self.app_handle();
let UpdaterState {
config,
target,
version_comparator,
headers,
} = self.state::<UpdaterState>().inner();
let mut builder = UpdaterBuilder::new(app, config.clone()).headers(headers.clone());
if let Some(target) = target {
builder = builder.target(target);
}
#[cfg(windows)]
{
builder = builder.current_exe_args(self.env().args_os);
}
builder.version_comparator = version_comparator.clone();
#[cfg(any(
target_os = "linux",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd"
))]
{
let env = app.env();
if let Some(appimage) = env.appimage {
builder = builder.executable_path(appimage);
}
}
let app_handle = app.app_handle().clone();
builder = builder.on_before_exit(move || {
app_handle.cleanup_before_exit();
});
builder
}
fn updater(&self) -> Result<Updater> {
self.updater_builder().build()
}
}
struct UpdaterState {
target: Option<String>,
config: Config,
version_comparator: Option<VersionComparator>,
headers: HeaderMap,
}
#[derive(Default)]
pub struct Builder {
target: Option<String>,
pubkey: Option<String>,
installer_args: Vec<OsString>,
headers: HeaderMap,
default_version_comparator: Option<VersionComparator>,
}
impl Builder {
pub fn new() -> Self {
Self::default()
}
pub fn target(mut self, target: impl Into<String>) -> Self {
self.target.replace(target.into());
self
}
pub fn pubkey<S: Into<String>>(mut self, pubkey: S) -> Self {
self.pubkey.replace(pubkey.into());
self
}
/// Adds an additional argument to pass to the Windows installer.
pub fn installer_args<I, S>(mut self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<OsString>,
{
self.installer_args.extend(args.into_iter().map(Into::into));
self
}
/// Adds multiple additional arguments to pass to the Windows installer.
pub fn installer_arg<S>(mut self, arg: S) -> Self
where
S: Into<OsString>,
{
self.installer_args.push(arg.into());
self
}
/// Removes all the additional arguments to pass to the Windows installer.
///
/// Note: this only removes the additional arguments added through [`Self::installer_args`],
/// not the ones managed by us (e.g. `/UPDATER` flag passed to the NSIS installer)
pub fn clear_installer_args(mut self) -> Self {
self.installer_args.clear();
self
}
pub fn header<K, V>(mut self, key: K, value: V) -> Result<Self>
where
HeaderName: TryFrom<K>,
<HeaderName as TryFrom<K>>::Error: Into<http::Error>,
HeaderValue: TryFrom<V>,
<HeaderValue as TryFrom<V>>::Error: Into<http::Error>,
{
let key: std::result::Result<HeaderName, http::Error> = key.try_into().map_err(Into::into);
let value: std::result::Result<HeaderValue, http::Error> =
value.try_into().map_err(Into::into);
self.headers.insert(key?, value?);
Ok(self)
}
pub fn headers(mut self, headers: HeaderMap) -> Self {
self.headers = headers;
self
}
pub fn default_version_comparator<
F: Fn(Version, RemoteRelease) -> bool + Send + Sync + 'static,
>(
mut self,
f: F,
) -> Self {
self.default_version_comparator.replace(Arc::new(f));
self
}
pub fn build<R: Runtime>(self) -> TauriPlugin<R, Config> {
let pubkey = self.pubkey;
let target = self.target;
let version_comparator = self.default_version_comparator;
let installer_args = self.installer_args;
let headers = self.headers;
PluginBuilder::<R, Config>::new("updater")
.setup(move |app, api| {
let mut config = api.config().clone();
if let Some(pubkey) = pubkey {
config.pubkey = pubkey;
}
if let Some(windows) = &mut config.windows {
windows.installer_args.extend(installer_args);
}
app.manage(UpdaterState {
target,
config,
version_comparator,
headers,
});
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::check,
commands::download,
commands::install,
commands::download_and_install,
])
.build()
}
}
File diff suppressed because it is too large Load Diff