From bb4b1f80514ba8412e0aa3098b50bd34271348df Mon Sep 17 00:00:00 2001 From: Emil Date: Thu, 10 Sep 2026 02:47:16 +0300 Subject: [PATCH] Add signed launcher self-updates and publish Linux 0.1.3 --- .github/workflows/build.yml | 29 +- .github/workflows/check.yml | 3 +- AGENTS.md | 28 +- PLAN.md | 38 +- README.md | 15 +- docs/launcher-architecture.md | 59 +- docs/launcher-updates.md | 195 ++ package-lock.json | 4 +- package.json | 4 +- scripts/.gitignore | 1 + scripts/publish_launcher_update.py | 290 +++ scripts/tauri-unsigned.json | 5 + scripts/test_publish_launcher_update.py | 156 ++ src-tauri/Cargo.lock | 337 +++- src-tauri/Cargo.toml | 10 +- src-tauri/src/commands/game.rs | 3 + src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/updater.rs | 194 ++ src-tauri/src/lib.rs | 25 +- src-tauri/src/operations.rs | 74 +- src-tauri/src/updater.rs | 727 +++++++ src-tauri/tauri.conf.json | 14 +- src-tauri/tests/fixtures/updater-signed.json | 17 + src/App.tsx | 20 +- src/components/LauncherUpdateSettings.tsx | 51 + src/components/SettingsDrawer.tsx | 10 +- src/hooks/useLauncherUpdate.ts | 83 + src/services/native.ts | 11 + src/state/updater.test.ts | 76 + src/state/updater.ts | 59 + src/styles.css | 26 +- src/types/launcher.ts | 15 + .../tauri-plugin-updater/.cargo_vcs_info.json | 6 + vendor/tauri-plugin-updater/Cargo.toml | 190 ++ vendor/tauri-plugin-updater/Cargo.toml.orig | 83 + vendor/tauri-plugin-updater/LICENSE.spdx | 20 + .../tauri-plugin-updater/LICENSE_APACHE-2.0 | 177 ++ vendor/tauri-plugin-updater/LICENSE_MIT | 21 + vendor/tauri-plugin-updater/PATCH.md | 44 + vendor/tauri-plugin-updater/README.md | 103 + vendor/tauri-plugin-updater/SECURITY.md | 23 + vendor/tauri-plugin-updater/api-iife.js | 1 + vendor/tauri-plugin-updater/build.rs | 25 + .../autogenerated/commands/check.toml | 13 + .../autogenerated/commands/download.toml | 13 + .../commands/download_and_install.toml | 13 + .../autogenerated/commands/install.toml | 13 + .../permissions/autogenerated/reference.md | 130 ++ .../permissions/default.toml | 18 + .../permissions/schemas/schema.json | 354 ++++ vendor/tauri-plugin-updater/src/commands.rs | 212 ++ vendor/tauri-plugin-updater/src/config.rs | 179 ++ vendor/tauri-plugin-updater/src/error.rs | 105 + vendor/tauri-plugin-updater/src/lib.rs | 248 +++ vendor/tauri-plugin-updater/src/updater.rs | 1758 +++++++++++++++++ 55 files changed, 6286 insertions(+), 43 deletions(-) create mode 100644 docs/launcher-updates.md create mode 100644 scripts/.gitignore create mode 100644 scripts/publish_launcher_update.py create mode 100644 scripts/tauri-unsigned.json create mode 100644 scripts/test_publish_launcher_update.py create mode 100644 src-tauri/src/commands/updater.rs create mode 100644 src-tauri/src/updater.rs create mode 100644 src-tauri/tests/fixtures/updater-signed.json create mode 100644 src/components/LauncherUpdateSettings.tsx create mode 100644 src/hooks/useLauncherUpdate.ts create mode 100644 src/state/updater.test.ts create mode 100644 src/state/updater.ts create mode 100644 vendor/tauri-plugin-updater/.cargo_vcs_info.json create mode 100644 vendor/tauri-plugin-updater/Cargo.toml create mode 100644 vendor/tauri-plugin-updater/Cargo.toml.orig create mode 100644 vendor/tauri-plugin-updater/LICENSE.spdx create mode 100644 vendor/tauri-plugin-updater/LICENSE_APACHE-2.0 create mode 100644 vendor/tauri-plugin-updater/LICENSE_MIT create mode 100644 vendor/tauri-plugin-updater/PATCH.md create mode 100644 vendor/tauri-plugin-updater/README.md create mode 100644 vendor/tauri-plugin-updater/SECURITY.md create mode 100644 vendor/tauri-plugin-updater/api-iife.js create mode 100644 vendor/tauri-plugin-updater/build.rs create mode 100644 vendor/tauri-plugin-updater/permissions/autogenerated/commands/check.toml create mode 100644 vendor/tauri-plugin-updater/permissions/autogenerated/commands/download.toml create mode 100644 vendor/tauri-plugin-updater/permissions/autogenerated/commands/download_and_install.toml create mode 100644 vendor/tauri-plugin-updater/permissions/autogenerated/commands/install.toml create mode 100644 vendor/tauri-plugin-updater/permissions/autogenerated/reference.md create mode 100644 vendor/tauri-plugin-updater/permissions/default.toml create mode 100644 vendor/tauri-plugin-updater/permissions/schemas/schema.json create mode 100644 vendor/tauri-plugin-updater/src/commands.rs create mode 100644 vendor/tauri-plugin-updater/src/config.rs create mode 100644 vendor/tauri-plugin-updater/src/error.rs create mode 100644 vendor/tauri-plugin-updater/src/lib.rs create mode 100644 vendor/tauri-plugin-updater/src/updater.rs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 10949e2..be883e8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index ccc3db0..0837f79 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -25,8 +25,9 @@ jobs: - name: Install Linux desktop dependencies run: | sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf + sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf minisign - run: npm ci - run: npm test - run: npm run build + - run: python3 -m unittest discover -s scripts -p 'test_*.py' - run: cargo test --locked --manifest-path src-tauri/Cargo.toml diff --git a/AGENTS.md b/AGENTS.md index bbe52f1..ab4c13a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,6 +63,27 @@ payload are in `/root/shacraft` on the ShaCraft host; see 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//`, and are bounded to 256 MiB. + Stable versions must increase. The native layer owns every candidate. +- Linux self-update is supported for AppImage only. Preserve executable + permissions and use same-directory atomic replacement after verification. + 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 @@ -99,6 +120,8 @@ payload are in `/root/shacraft` on the ShaCraft host; see 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. - `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 @@ -106,8 +129,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 diff --git a/PLAN.md b/PLAN.md index 8106e8a..a9f678c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -17,7 +17,7 @@ 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 при необходимости. @@ -52,13 +52,45 @@ HTTPS verifier из контейнера, подпись manifest и SHA-256 мода проверены. Backend Docker: 404 tests + 48 subtests, включая истечение, отзыв session, чужую identity, whitelist, резервирование имён и атомарное погашение. -- [ ] Полный вход в production Aeronautics через установленный лаунчер - подтвердить отдельно; изолированный мир не заменяет проверку полного модпака. +- [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 остаются отдельными задачами. + ## Связанные серверные риски Серверный план находится в `/root/shacraft/PLAN.md`. Для admission обязательны diff --git a/README.md b/README.md index ac41dd6..7f15368 100644 --- a/README.md +++ b/README.md @@ -37,14 +37,27 @@ 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 и dev-бинарник обновляются вручную. +С версии 0.1.2 нужен один ручной переход на новый AppImage. Пакеты Windows/macOS +с этим механизмом ещё требуют публикации и проверки установки. + ## Навигация - [Архитектура](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) — инструкции для следующего разработчика/агента. diff --git a/docs/launcher-architecture.md b/docs/launcher-architecture.md index 76da6ac..b626c60 100644 --- a/docs/launcher-architecture.md +++ b/docs/launcher-architecture.md @@ -22,9 +22,12 @@ 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. +Linux AppImage replacement is supported; the first upgrade from 0.1.2 is manual. +Windows/macOS packages still require publication and actual installation tests. +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 @@ -143,7 +146,8 @@ proof with a shared key embedded in distributed binaries. 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. @@ -173,6 +177,36 @@ 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. + +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; @@ -180,8 +214,9 @@ 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 @@ -201,3 +236,15 @@ 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. diff --git a/docs/launcher-updates.md b/docs/launcher-updates.md new file mode 100644 index 0000000..d86a6d3 --- /dev/null +++ b/docs/launcher-updates.md @@ -0,0 +1,195 @@ +# 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//`. +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. On Linux, automatic +replacement is for AppImage installations; deb installations and 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 current 0.1.3 release follows 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` (`.AppImage`), `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. + +## 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`. 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 the Linux artifact already exists at + `/tmp/shacraft-release/downloads/0.1.3/ShaCraft.Launcher_0.1.3_amd64.AppImage` + 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.3/ShaCraft.Launcher_0.1.3_amd64.AppImage + ``` + +2. Prepare a deterministic payload after verifying every package signature. + Repeat `--artifact PLATFORM=FILENAME` for each tested platform included in this + release. Do not list a deb, dmg, nonexistent package or untested architecture: + + ```bash + python3 scripts/publish_launcher_update.py prepare \ + --version 0.1.3 \ + --downloads-root /tmp/shacraft-release/downloads \ + --artifact linux-x86_64=ShaCraft.Launcher_0.1.3_amd64.AppImage \ + --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. Unit tests, +packaging or a browser mock alone do not establish successful installation. +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. +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/). diff --git a/package-lock.json b/package-lock.json index e7daf76..f15dd31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "shacraft-launcher-ui", - "version": "0.1.2", + "version": "0.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "shacraft-launcher-ui", - "version": "0.1.2", + "version": "0.1.3", "dependencies": { "@tauri-apps/api": "2.11.1", "lucide-react": "1.41.0", diff --git a/package.json b/package.json index 00bf7db..9959d51 100644 --- a/package.json +++ b/package.json @@ -2,13 +2,13 @@ "name": "shacraft-launcher-ui", "license": "MIT", "private": true, - "version": "0.1.2", + "version": "0.1.3", "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", "preview": "vite preview", "tauri": "tauri", "tauri:dev": "tauri dev", diff --git a/scripts/.gitignore b/scripts/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/scripts/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/scripts/publish_launcher_update.py b/scripts/publish_launcher_update.py new file mode 100644 index 0000000..46fd8af --- /dev/null +++ b/scripts/publish_launcher_update.py @@ -0,0 +1,290 @@ +#!/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 stat +import subprocess +import tempfile + +ORIGIN = "https://shacraft.ru/downloads/shacraft-launcher/" +PLATFORMS = { + "linux-x86_64": (".AppImage",), + "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 + + +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 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") + 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) + 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() diff --git a/scripts/tauri-unsigned.json b/scripts/tauri-unsigned.json new file mode 100644 index 0000000..55a9946 --- /dev/null +++ b/scripts/tauri-unsigned.json @@ -0,0 +1,5 @@ +{ + "bundle": { + "createUpdaterArtifacts": false + } +} diff --git a/scripts/test_publish_launcher_update.py b/scripts/test_publish_launcher_update.py new file mode 100644 index 0000000..21472c8 --- /dev/null +++ b/scripts/test_publish_launcher_update.py @@ -0,0 +1,156 @@ +"""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 tempfile +import unittest + +import publish_launcher_update as publisher + +MINISIGN = os.environ.get("SHACRAFT_TEST_MINISIGN", "minisign") + + +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") + 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"), + ): + 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"}') + + +@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(b"isolated ShaCraft updater fixture; not an executable") + 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()) + + +if __name__ == "__main__": + unittest.main() diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 57c2285..54a9e12 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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,14 +3361,17 @@ dependencies = [ [[package]] name = "shacraft-launcher" -version = "0.1.2" +version = "0.1.3" dependencies = [ "base64 0.22.1", "ed25519-dalek", "flate2", "getrandom 0.3.4", "md-5", + "minisign-verify", "reqwest 0.12.28", + "reqwest 0.13.4", + "semver", "serde", "serde_json", "sha1", @@ -3231,9 +3379,11 @@ dependencies = [ "tar", "tauri", "tauri-build", + "tauri-plugin-updater", + "tempfile", "url", "zeroize", - "zip", + "zip 2.4.2", ] [[package]] @@ -3257,6 +3407,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" @@ -3479,7 +3645,7 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", @@ -3546,7 +3712,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", - "jni", + "jni 0.21.1", "libc", "log", "mime", @@ -3642,6 +3808,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" @@ -3652,7 +3865,7 @@ dependencies = [ "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2", "objc2-ui-kit", "objc2-web-kit", @@ -3675,7 +3888,7 @@ checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2", "objc2-app-kit", @@ -3742,6 +3955,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" @@ -4419,6 +4645,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" @@ -4676,6 +4911,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" @@ -4709,13 +4953,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" @@ -4746,6 +5007,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" @@ -4758,6 +5025,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" @@ -4770,12 +5043,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" @@ -4788,6 +5073,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" @@ -4800,6 +5091,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" @@ -4812,6 +5109,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" @@ -4824,6 +5127,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" @@ -4888,7 +5197,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2", @@ -5045,6 +5354,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" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b87aa88..6fa1ac0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "shacraft-launcher" -version = "0.1.2" +version = "0.1.3" description = "ShaCraft Minecraft launcher" authors = ["ShaCraft"] license = "MIT" @@ -24,8 +24,16 @@ ed25519-dalek = { version = "2", features = ["pkcs8"] } getrandom = "0.3" zeroize = "1" 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"] } +tempfile = "3" diff --git a/src-tauri/src/commands/game.rs b/src-tauri/src/commands/game.rs index 9c07ccf..489a7be 100644 --- a/src-tauri/src/commands/game.rs +++ b/src-tauri/src/commands/game.rs @@ -163,6 +163,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> { @@ -219,7 +220,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 { diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 6f82e5a..9d9db7e 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -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}; diff --git a/src-tauri/src/commands/updater.rs b/src-tauri/src/commands/updater.rs new file mode 100644 index 0000000..e77eb71 --- /dev/null +++ b/src-tauri/src/commands/updater.rs @@ -0,0 +1,194 @@ +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 { + updater.status(&app) +} + +#[tauri::command] +pub(crate) async fn check_launcher_update( + app: AppHandle, + updater: State<'_, LauncherUpdater>, +) -> Result { + 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(), + ) + .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()); + } + } + app.restart() +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b7c1adc..f76e759 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -14,13 +14,32 @@ mod settings; mod shacraft_account; mod storage; mod trusted_http; +mod updater; mod commands; mod operations; pub fn run() { + 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::() { + 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, @@ -41,7 +60,11 @@ pub fn run() { 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"); diff --git a/src-tauri/src/operations.rs b/src-tauri/src/operations.rs index 5e73c32..578935e 100644 --- a/src-tauri/src/operations.rs +++ b/src-tauri/src/operations.rs @@ -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 { + 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()); + } } diff --git a/src-tauri/src/updater.rs b/src-tauri/src/updater.rs new file mode 100644 index 0000000..cff00e2 --- /dev/null +++ b/src-tauri/src/updater.rs @@ -0,0 +1,727 @@ +//! 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"; +const MAX_METADATA_BYTES: usize = 192 * 1024; +const MAX_PAYLOAD_BYTES: usize = 64 * 1024; +const MAX_ARTIFACT_BYTES: usize = 256 * 1024 * 1024; +const BAD_METADATA: &str = + "Не удалось подтвердить подлинность сведений об обновлении. Повторите проверку позже."; +const BAD_SIGNATURE: &str = "Подпись обновления не прошла проверку. Установка отменена."; + +#[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, + // Hold all mutation gates until the user restarts into the installed app. + pub restart_permits: Option, +} + +#[derive(Clone, Default)] +pub(crate) struct LauncherUpdater { + pub operation: Operation, + pub state: Arc>, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UpdateStatus { + pub current_version: String, + pub supported: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, + pub stage: Stage, + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub notes: Option, +} + +#[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, +} + +impl LauncherUpdater { + pub fn status(&self, app: &AppHandle) -> Result { + 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(), + 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 unsupported_reason(app: &AppHandle) -> Option { + #[cfg(target_os = "linux")] + { + let env = app.env(); + let valid = match ( + env.appimage.as_ref(), + env.appdir.as_ref(), + std::env::current_exe(), + ) { + (Some(image), Some(directory), Ok(executable)) => linux_appimage_supported( + std::path::Path::new(image), + std::path::Path::new(directory), + &executable, + ), + _ => false, + }; + if !valid { + return Some("Автообновление в Linux доступно в AppImage. Установите AppImage с shacraft.ru и запускайте его.".into()); + } + } + #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))] + return Some("Для этой платформы доступна только ручная установка обновлений.".into()); + 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(app: &AppHandle) -> Result { + 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( + app: &AppHandle, +) -> Result, String> { + #[cfg(target_os = "linux")] + { + 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(app: &AppHandle) -> Result { + 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::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 { + 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), +) -> Result, 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. +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()) +} + +fn verified_metadata(raw: &Value, key: &str) -> Result { + 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) +} + +fn newer_version(metadata: &Value, current: &str) -> Result { + 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) -> Result<(), 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)?; + let available = platforms.contains_key(&target) + || ["appimage", "nsis", "msi", "app"] + .iter() + .any(|bundle| platforms.contains_key(&format!("{target}-{bundle}"))); + if !available { + return Err("Обновление для вашей платформы пока не опубликовано.".into()); + } + Ok(()) +} + +fn validate_download_url(url: &Url, version: &str) -> 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") { + filename.ends_with(".AppImage") + } 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(()) +} + +/// 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, +) -> Result, 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)?; + require_platform(&metadata)?; + if !newer_version(&metadata, current)? { + return Ok(None); + } + 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)?; + Ok(Some(update)) +} + +pub(crate) async fn download_verified( + update: &Update, + key: &str, + progress: impl FnMut(u64, Option), +) -> Result, String> { + validate_download_url(&update.download_url, &update.version)?; + 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)?; + verify_signature(bytes, &update.signature, key)?; + #[cfg(target_os = "linux")] + { + 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").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").is_err(), + "{value}" + ); + } + } + + #[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":{}})).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") + .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 + ); + }); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 685af1b..7313a4b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ShaCraft Launcher", - "version": "0.1.2", + "version": "0.1.3", "identifier": "ru.shacraft.launcher", "build": { "beforeDevCommand": "npm run dev", @@ -28,6 +28,7 @@ }, "bundle": { "active": true, + "createUpdaterArtifacts": true, "targets": "all", "icon": [ "icons/32x32.png", @@ -36,5 +37,16 @@ "icons/icon.icns", "icons/icon.ico" ] + }, + "plugins": { + "updater": { + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDk0N0MzODEwMjg1OUVCNDEKUldSQjYxa29FRGg4bEdKSkFWUzZUNDZhRFN4cGIwL0FvVnl0blhrOWtSMWhSOWxHMkU1aGs5L2oK", + "endpoints": [ + "https://shacraft.ru/launcher/updates/stable.json" + ], + "windows": { + "installMode": "passive" + } + } } } diff --git a/src-tauri/tests/fixtures/updater-signed.json b/src-tauri/tests/fixtures/updater-signed.json new file mode 100644 index 0000000..5b34831 --- /dev/null +++ b/src-tauri/tests/fixtures/updater-signed.json @@ -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" +} \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 7ca9da1..b52deea 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,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' @@ -33,9 +34,11 @@ 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 }) @@ -43,6 +46,7 @@ export function App() { 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]}…` : 'Подготовка…' @@ -66,7 +70,7 @@ export function App() {
0}> setSettingsOpen(true)} /> + native={desktop} locked={busy || updateLocked || session.busy} onSelect={setSelected} onSettings={() => setSettingsOpen(true)} /> { if (!repairDisabled) void launcher.repair(selected.profileId) }} />
- + {desktop && (updater.state.status?.version || updateLocked) && !settingsOpen && !session.recoveryCodes.length && + } + { session.dismissFeedback(); setErrorFeedback(null) }} /> diff --git a/src/components/LauncherUpdateSettings.tsx b/src/components/LauncherUpdateSettings.tsx new file mode 100644 index 0000000..c86af45 --- /dev/null +++ b/src/components/LauncherUpdateSettings.tsx @@ -0,0 +1,51 @@ +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 }) { + 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' + + return
+
+

ShaCraft Launcher

+ {status?.currentVersion && {status.currentVersion}} +
+
+ {!isNative() ?

Обновления доступны в приложении лаунчера.

+ : ready ?

Обновление установлено. Перезапустите лаунчер.

+ : working ?

{phase === 'installing' ? 'Проверяем подпись и устанавливаем…' + : `Скачиваем обновление${percent === null ? '…' : ` · ${percent}%`}`}

+ : checking ?

Проверяем обновления…

+ : status?.supported === false ?

{status.reason || 'Для этой установки обновление доступно вручную на shacraft.ru/help#launcher.'}

+ : status?.version ?

Доступна версия {status.version}

+ : state.checked && !error ?

У вас последняя версия.

+ :

Проверка новой версии лаунчера.

} + {working && } +
+ {status?.notes && status.version && !working && !ready &&
+ Что нового

{status.notes.slice(0, 1600)}

+
} + {error &&

{error}

} + {eventError &&

{eventError} Перезапустите лаунчер, чтобы включить установку обновлений.

} + {isNative() &&
+ {ready ? : <> + {status?.supported && status.version && } + {status?.supported !== false && } + } +
} + {blocked && status?.supported && status.version && !working &&

Завершите игру и текущие операции, чтобы обновить лаунчер.

} +
+} diff --git a/src/components/SettingsDrawer.tsx b/src/components/SettingsDrawer.tsx index 04bce7a..0e97c26 100644 --- a/src/components/SettingsDrawer.tsx +++ b/src/components/SettingsDrawer.tsx @@ -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 session: ReturnType + updater: ReturnType 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(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('button:not(:disabled), input:not(:disabled), select:not(:disabled)') + const elements = closeButton.current?.closest('aside')?.querySelectorAll('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,