Author SHA1 Message Date
Emil Shanaty c73057a0fe Merge pull request #2 from emil28092005/codex/reliability-and-onboarding
Cross-platform build (disposable signatures) / CI test bundle / linux-x86_64 (push) Canceled after 0s
Cross-platform build (disposable signatures) / CI test bundle / darwin-aarch64 (push) Canceled after 0s
Cross-platform build (disposable signatures) / CI test bundle / darwin-x86_64 (push) Canceled after 0s
Cross-platform build (disposable signatures) / CI test bundle / windows-x86_64 (push) Canceled after 0s
Launcher checks / check (push) Canceled after 0s
Fix cross-platform CI for signed launcher updates
2026-09-09 21:47:53 +03:00
Emil cacd1cd2bd ci: isolate Ubuntu sources and select Git Bash for Windows tests
Launcher checks / check (push) Canceled after 0s
2026-09-09 20:41:07 +03:00
Emil daefc3f5e7 test: resolve trusted temporary roots in profile and live fixtures 2026-09-09 20:29:04 +03:00
Emil 738399b452 test: preserve signed fixture bytes and canonicalize temporary paths 2026-09-09 20:25:45 +03:00
Emil Shanaty 2d11a328c9 Merge pull request #1 from emil28092005/codex/reliability-and-onboarding
Harden game installation and onboarding; add signed launcher updates
2026-09-09 20:24:43 +03:00
Emil cbc727829c docs: record verified Linux packages and startup limits 2026-09-09 20:16:49 +03:00
Emil 696c6b0e52 fix: bind AppImage updates to the running bundle 2026-09-09 20:10:24 +03:00
Emil b2ac174398 ci: verify signed packages before protected draft publication 2026-09-09 19:55:53 +03:00
Emil 1d08396b80 feat: add signed launcher updates with native lifecycle recovery 2026-09-09 19:48:10 +03:00
Codex 7c0eb20894 docs: describe installation recovery and coordinated proof rollout 2026-09-09 18:59:21 +03:00
Codex 189068be63 feat(ui): expose first-entry verification and explicit legacy mod recovery 2026-09-09 18:59:21 +03:00
Codex 22a78530c0 fix(install): reconcile and repair one verified snapshot under a game lease 2026-09-09 18:58:07 +03:00
Codex cd5c675637 fix(settings): preserve RAM retries and find matching Java on PATH 2026-09-09 18:57:21 +03:00
82 changed files with 9231 additions and 558 deletions
+2
View File
@@ -0,0 +1,2 @@
# Signed updater fixtures must retain their exact committed bytes on every OS.
src-tauri/tests/fixtures/updater/* -text
+49 -39
View File
@@ -1,4 +1,4 @@
name: Cross-platform build
name: Cross-platform build (disposable signatures)
on:
workflow_dispatch:
@@ -8,66 +8,76 @@ on:
permissions:
contents: read
concurrency:
group: build-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
name: ${{ matrix.name }}
name: CI test bundle / ${{ matrix.platform }}
strategy:
fail-fast: false
matrix:
include:
- name: Linux x64
- platform: linux-x86_64
os: ubuntu-22.04
args: --bundles appimage,deb
- name: Windows x64
target: x86_64-unknown-linux-gnu
bundles: appimage,deb
- platform: windows-x86_64
os: windows-2022
args: --bundles nsis,msi
- name: macOS Apple Silicon
target: x86_64-pc-windows-msvc
bundles: nsis,msi
- platform: darwin-aarch64
os: macos-15
args: --target aarch64-apple-darwin --bundles dmg
- name: macOS Intel
target: aarch64-apple-darwin
bundles: dmg,app
- platform: darwin-x86_64
os: macos-15-intel
args: --target x86_64-apple-darwin --bundles dmg
target: x86_64-apple-darwin
bundles: dmg,app
runs-on: ${{ matrix.os }}
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- uses: dtolnay/rust-toolchain@stable
- name: Install Linux system dependencies
with:
targets: ${{ matrix.target }}
- name: Install Linux desktop dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
# Ubuntu 22.04 keeps distro sources here; unrelated vendor repos are not needed.
test -s /etc/apt/sources.list
apt_options=(
-o Dir::Etc::sourcelist=/etc/apt/sources.list
-o Dir::Etc::sourceparts=-
)
sudo apt-get "${apt_options[@]}" --error-on=any update
sudo apt-get "${apt_options[@]}" install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- run: npm ci
- run: npm test
- run: cargo build --locked --release --manifest-path scripts/release-verifier/Cargo.toml
- run: python -m unittest discover -s scripts -p 'release_test.py' -v
- run: cargo test --locked --manifest-path src-tauri/Cargo.toml
- run: npm run tauri:build -- ${{ matrix.args }}
- name: Upload Windows installers
if: runner.os == 'Windows'
uses: actions/upload-artifact@v4
- name: Generate disposable CI key (never a production secret)
run: |
python scripts/release.py ci-key --directory "$RUNNER_TEMP/ci-updater"
python -c "import json,os; open(os.environ['GITHUB_ENV'],'a').write('RELEASE_VERSION='+json.load(open('package.json'))['version']+'\n')"
- name: Build packages and updater bundles
run: npm run tauri:build -- --ci --target '${{ matrix.target }}' --bundles '${{ matrix.bundles }}' --config "$RUNNER_TEMP/ci-updater/updater-build.json"
- name: Collect and verify every test bundle
run: python scripts/release.py collect --bundle 'src-tauri/target/${{ matrix.target }}/release/bundle' --directory ci-packages --platform '${{ matrix.platform }}' --version "$RELEASE_VERSION"
- uses: actions/upload-artifact@v4
with:
name: shacraft-launcher-windows-x64
name: CI-NOT-FOR-RELEASE-${{ matrix.platform }}
if-no-files-found: error
path: |
src-tauri/target/release/bundle/nsis/*.exe
src-tauri/target/release/bundle/msi/*.msi
- name: Upload Linux packages
if: runner.os == 'Linux'
uses: actions/upload-artifact@v4
with:
name: shacraft-launcher-linux-x64
if-no-files-found: error
path: |
src-tauri/target/release/bundle/appimage/*.AppImage
src-tauri/target/release/bundle/deb/*.deb
- name: Upload macOS package
if: runner.os == 'macOS'
uses: actions/upload-artifact@v4
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
retention-days: 7
path: ci-packages/*
+13 -2
View File
@@ -21,12 +21,23 @@ jobs:
with:
node-version: 22
cache: npm
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- uses: dtolnay/rust-toolchain@stable
- 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
# Ubuntu 22.04 keeps distro sources here; unrelated vendor repos are not needed.
test -s /etc/apt/sources.list
apt_options=(
-o Dir::Etc::sourcelist=/etc/apt/sources.list
-o Dir::Etc::sourceparts=-
)
sudo apt-get "${apt_options[@]}" --error-on=any update
sudo apt-get "${apt_options[@]}" install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- run: npm ci
- run: npm test
- run: npm run build
- run: cargo build --locked --release --manifest-path scripts/release-verifier/Cargo.toml
- run: python -m unittest discover -s scripts -p 'release_test.py' -v
- run: cargo test --locked --manifest-path src-tauri/Cargo.toml
+95
View File
@@ -0,0 +1,95 @@
name: Publish verified release (operator only)
on:
workflow_dispatch:
inputs:
version:
description: Version of the complete signed draft
required: true
type: string
tag:
description: Existing draft tag (vVERSION)
required: true
type: string
confirmation:
description: Type publish vVERSION to confirm public publication
required: true
type: string
permissions:
contents: read
actions: read
concurrency:
group: launcher-release
cancel-in-progress: false
jobs:
preflight:
if: github.repository == 'emil28092005/shacraft-launcher' && github.ref == 'refs/heads/main'
runs-on: ubuntu-22.04
outputs:
environment: ${{ steps.gate.outputs.environment }}
commit: ${{ steps.source.outputs.commit }}
env:
RELEASE_VERSION: ${{ inputs.version }}
RELEASE_TAG: ${{ inputs.tag }}
CONFIRMATION: ${{ inputs.confirmation }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Validate publication inputs
run: python -c "import os,sys; sys.path.insert(0,'scripts'); import release; release.version_tag(os.environ['RELEASE_VERSION'],os.environ['RELEASE_TAG']); release.require(os.environ['CONFIRMATION']=='publish '+os.environ['RELEASE_TAG'],'publication confirmation mismatch')"
- name: Require existing independent approval and protected-branch policy
id: gate
env:
GH_TOKEN: ${{ github.token }}
run: python scripts/release_github.py gate launcher-release-publish
- name: Resolve tag using trusted workflow Git commands
id: source
shell: bash
run: |
set -euo pipefail
RELEASE_COMMIT=$(git rev-parse --verify "refs/tags/${RELEASE_TAG}^{commit}")
git merge-base --is-ancestor "$RELEASE_COMMIT" refs/remotes/origin/main
printf 'commit=%s\n' "$RELEASE_COMMIT" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v4
with:
ref: ${{ steps.source.outputs.commit }}
fetch-depth: 0
persist-credentials: false
- name: Check source versions after immutable source validation
run: python scripts/release.py preflight --version "$RELEASE_VERSION" --tag "$RELEASE_TAG" --check-git
publish:
needs: preflight
environment: ${{ needs.preflight.outputs.environment }}
runs-on: ubuntu-22.04
permissions:
contents: write
actions: read
env:
RELEASE_VERSION: ${{ inputs.version }}
RELEASE_TAG: ${{ inputs.tag }}
CONFIRMATION: ${{ inputs.confirmation }}
SHACRAFT_UPDATER_PUBLIC_KEY: ${{ vars.SHACRAFT_UPDATER_PUBLIC_KEY }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.preflight.outputs.commit }}
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- uses: dtolnay/rust-toolchain@stable
- run: cargo build --locked --release --manifest-path scripts/release-verifier/Cargo.toml
- name: Re-download, verify signatures/metadata/assets and explicitly publish
env:
GH_TOKEN: ${{ github.token }}
run: python scripts/release_github.py publish --version "$RELEASE_VERSION" --tag "$RELEASE_TAG" --confirmation "$CONFIRMATION"
+196
View File
@@ -0,0 +1,196 @@
name: Prepare signed release draft
on:
workflow_dispatch:
inputs:
version:
description: Stable version matching all source versions (e.g. 0.2.0)
required: true
type: string
tag:
description: Existing tag on protected main (e.g. v0.2.0)
required: true
type: string
notes:
description: Release notes (maximum 4096 UTF-8 bytes)
default: ''
type: string
permissions:
contents: read
actions: read
concurrency:
group: launcher-release
cancel-in-progress: false
jobs:
preflight:
if: github.repository == 'emil28092005/shacraft-launcher' && github.ref == 'refs/heads/main'
runs-on: ubuntu-22.04
outputs:
environment: ${{ steps.gate.outputs.environment }}
commit: ${{ steps.source.outputs.commit }}
env:
RELEASE_VERSION: ${{ inputs.version }}
RELEASE_TAG: ${{ inputs.tag }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Validate inputs before resolving tag
run: python -c "import os,sys; sys.path.insert(0,'scripts'); import release; release.version_tag(os.environ['RELEASE_VERSION'],os.environ['RELEASE_TAG'])"
- name: Require an existing protected environment before any job references it
id: gate
env:
GH_TOKEN: ${{ github.token }}
run: python scripts/release_github.py gate launcher-release
- name: Resolve tag using trusted workflow Git commands
id: source
shell: bash
run: |
set -euo pipefail
RELEASE_COMMIT=$(git rev-parse --verify "refs/tags/${RELEASE_TAG}^{commit}")
git merge-base --is-ancestor "$RELEASE_COMMIT" refs/remotes/origin/main
printf 'commit=%s\n' "$RELEASE_COMMIT" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v4
with:
ref: ${{ steps.source.outputs.commit }}
fetch-depth: 0
persist-credentials: false
- name: Check source versions, existing tag and main ancestry
run: python scripts/release.py preflight --version "$RELEASE_VERSION" --tag "$RELEASE_TAG" --check-git
build:
needs: preflight
environment: ${{ needs.preflight.outputs.environment }}
strategy:
fail-fast: false
matrix:
include:
- platform: linux-x86_64
os: ubuntu-22.04
target: x86_64-unknown-linux-gnu
bundles: appimage,deb
- platform: windows-x86_64
os: windows-2022
target: x86_64-pc-windows-msvc
bundles: nsis,msi
- platform: darwin-aarch64
os: macos-15
target: aarch64-apple-darwin
bundles: dmg,app
- platform: darwin-x86_64
os: macos-15-intel
target: x86_64-apple-darwin
bundles: dmg,app
runs-on: ${{ matrix.os }}
defaults:
run:
shell: bash
env:
RELEASE_VERSION: ${{ inputs.version }}
RELEASE_TAG: ${{ inputs.tag }}
SHACRAFT_UPDATER_PUBLIC_KEY: ${{ vars.SHACRAFT_UPDATER_PUBLIC_KEY }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.preflight.outputs.commit }}
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install Linux desktop dependencies
if: runner.os == 'Linux'
run: |
# Ubuntu 22.04 keeps distro sources here; unrelated vendor repos are not needed.
test -s /etc/apt/sources.list
apt_options=(
-o Dir::Etc::sourcelist=/etc/apt/sources.list
-o Dir::Etc::sourceparts=-
)
sudo apt-get "${apt_options[@]}" --error-on=any update
sudo apt-get "${apt_options[@]}" install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- run: npm ci
- run: npm test
- run: cargo test --locked --manifest-path src-tauri/Cargo.toml
- run: cargo build --locked --release --manifest-path scripts/release-verifier/Cargo.toml
- run: python -m unittest discover -s scripts -p 'release_test.py' -v
- name: Fail closed on missing, wrong or unpinned signing credentials
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: python scripts/release.py preflight --version "$RELEASE_VERSION" --tag "$RELEASE_TAG" --check-git --signing --config "$RUNNER_TEMP/updater-build.json"
- name: Build all platform packages with updater signatures
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: npm run tauri:build -- --ci --target '${{ matrix.target }}' --bundles '${{ matrix.bundles }}' --config "$RUNNER_TEMP/updater-build.json"
- name: Collect packages, sign manual packages, verify all signatures
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: python scripts/release.py collect --bundle 'src-tauri/target/${{ matrix.target }}/release/bundle' --directory release-packages --platform '${{ matrix.platform }}' --version "$RELEASE_VERSION"
- uses: actions/upload-artifact@v4
with:
name: release-${{ matrix.platform }}
if-no-files-found: error
retention-days: 7
path: release-packages/*
draft:
needs: [preflight, build]
environment: ${{ needs.preflight.outputs.environment }}
runs-on: ubuntu-22.04
permissions:
contents: write
actions: read
env:
RELEASE_VERSION: ${{ inputs.version }}
RELEASE_TAG: ${{ inputs.tag }}
RELEASE_NOTES: ${{ inputs.notes }}
SHACRAFT_UPDATER_PUBLIC_KEY: ${{ vars.SHACRAFT_UPDATER_PUBLIC_KEY }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.preflight.outputs.commit }}
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- uses: dtolnay/rust-toolchain@stable
- run: npm ci
- run: cargo build --locked --release --manifest-path scripts/release-verifier/Cargo.toml
- uses: actions/download-artifact@v4
with:
pattern: release-*
merge-multiple: true
path: release-packages
- name: Generate signed metadata, validate complete set, upload and re-verify DRAFT
env:
GH_TOKEN: ${{ github.token }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
python scripts/release.py preflight --version "$RELEASE_VERSION" --tag "$RELEASE_TAG" --check-git --signing
RELEASE_DATE=$(git show -s --format=%cI HEAD)
RELEASE_DATE=$(python -c "from datetime import datetime,timezone; import sys; print(datetime.fromisoformat(sys.argv[1]).astimezone(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'))" "$RELEASE_DATE")
python scripts/release.py metadata --directory release-packages --version "$RELEASE_VERSION" --tag "$RELEASE_TAG" --date "$RELEASE_DATE" --notes "$RELEASE_NOTES"
python scripts/release_github.py draft --directory release-packages --version "$RELEASE_VERSION" --tag "$RELEASE_TAG"
+2
View File
@@ -10,3 +10,5 @@ src-tauri/gen/
*.p12
*.pfx
*.sig
__pycache__/
+38 -7
View File
@@ -30,8 +30,12 @@ payload are in `/root/shacraft` on the ShaCraft host; see
embedded public key and `keyId` **before** parsing the payload.
- `src-tauri/src/manifest.rs` then validates paths, SHA-256, sizes, HTTPS and
allowed ShaCraft hosts. Do not weaken this whitelist.
- `src-tauri/src/profile.rs` downloads to a temporary sibling file, verifies
size + SHA-256, and atomically replaces only launcher-managed files.
- `profile.rs` / `inventory.rs` stage verified files, journal replacements and
retire only previously owned, unchanged managed files. Unknown files are not
silently adopted or deleted. Legacy mod backup requires explicit selection.
- Play/Repair use one `remote::VerifiedSnapshot` from verification through spawn.
`installation_lock.rs` protects the shared game tree across processes and
retains a PID + start-time lease while Minecraft is alive.
- This ShaCraft manifest is the **only** source of truth for which
Minecraft version / NeoForge version / Java major a profile needs
(`Manifest.minecraft`) and for mod/config files. It never supplies a URL
@@ -45,7 +49,25 @@ payload are in `/root/shacraft` on the ShaCraft host; see
the nickname is fetched from the verified `aoc` account link; the legacy
nickname in `settings.json` is ignored as an identity source. Server-side
whitelist enforcement and LoginSystem remain the final access-control
boundary, including for old launcher versions.
boundary, including for old launcher versions. The explicit onboarding command
is the only unlinked launch path: a short-lived server grant goes to the game
child environment only, never IPC responses, argv or files. The signed pack
must contain ShaCraft Game Bridge; it binds the grant to the game session,
requires LoginSystem authentication and an explicit one-time proof command.
Existing links retain legacy provenance; status polling cannot create links.
- Launcher self-update is a separate trust domain in `updater.rs` and
`updater/protocol.rs`: the fixed GitHub repository's `latest.json` bytes are
authenticated with the pinned Tauri/minisign public key before JSON parsing.
Each exact version/platform artifact also requires that signature, SHA-256
and size. Never reuse the ShaCraft mod-manifest key or accept IPC URLs/keys.
- Linux self-update requires the original ordinary AppImage file and the frozen
Tauri `APPDIR` bound to the running `usr/bin/shacraft-launcher`. Extracted or
inherited AppImage context is manual-only; never replace a bare binary.
- `update_guard.rs` retains one launcher-instance OS lock, drains native writes
through `operations::Lifecycle`, and checks the existing detached-game lease.
`launcher-state/pending-update.json` survives installer handoff; only startup
of its exact target version acknowledges it. Never clear it on a timer.
## Layout
@@ -59,7 +81,7 @@ payload are in `/root/shacraft` on the ShaCraft host; see
- `lib.rs` — module/command registration only; `commands/` holds adapters
for account/game/host/preferences/profiles. Unsigned sync/inspect IPC was
removed; only verified remote manifests may drive profile mutations.
- `operations.rs`process-local install/account permits owned by workers.
- `operations.rs`lifecycle and install/account permits owned by workers.
Launch must use the authenticated ShaCraft nickname; no settings fallback.
- `storage.rs` — unique same-directory atomic writes, owner-only Unix files.
- `trusted_http.rs` — HTTPS and exact-host redirect policy per game provider.
@@ -69,7 +91,9 @@ payload are in `/root/shacraft` on the ShaCraft host; see
build on this rather than each rolling their own.
- `mojang.rs` — vanilla Minecraft trust boundary + the generic
`inheritsFrom` version-JSON merge (shared with NeoForge's profile).
- `neoforge.rs` — runs NeoForge's official installer headlessly.
- `neoforge.rs` / `neoforge_repair.rs` — verified official installer, isolated
processor rebuild, checked embedded JSON and generated-output receipts.
Never hash legacy generated artifacts as an initial trusted baseline.
- `runtime.rs` — Java 21 auto-provisioning via Eclipse Adoptium.
- `msa.rs` — Microsoft/Xbox/Minecraft Services login; see
`MSA_CLIENT_ID`'s doc comment before touching login — it is currently a
@@ -85,8 +109,15 @@ 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.
Ubuntu 22.04 dependency steps use only the runner main Ubuntu source list;
keep APT signature/hash checks and fail on index errors. Vendor PPAs are not needed.
- `.github/workflows/build.yml` — four-platform CI packages with disposable test
signing keys, explicitly unusable as production releases.
- Release workflows and `scripts/release.py` implement protected draft → publish
gates; see `docs/updater-release.md`. Never publish assets piecemeal, reuse CI
test keys, or confuse updater signatures with OS signing/notarization.
- `src-tauri/updater-public-key.txt` is the public production trust root.
Private updater keys remain outside all repositories; never commit them.
## Verification
+23 -7
View File
@@ -17,16 +17,32 @@
approval и живой OAuth-тест. Текущий запуск использует ShaCraft identity.
- [ ] Cold install / repair / update / game exit на чистых Windows/Linux/macOS.
Unit tests и web preview не заменяют эти прогоны.
- [ ] Подписанные installer-релизы и подписанное автообновление лаунчера.
- [x] Native самообновление, русский UX, pinned updater key и защищённый release pipeline.
- [x] Локальные AppImage/deb собраны на Ubuntu 26.04 с CI test key; подписи и
форматы проверены. Настоящий AppImage прошёл изолированный startup/lock/marker
smoke. Это не проверка установки, самообновления или совместимости Ubuntu 22.04.
- [ ] Настроить защищённое GitHub environment/production secrets, опубликовать первый
updater-релиз и проверить реальную замену приложения на каждой ОС. Подпись ОС и
Apple notarization — отдельные настройки; CI test keys не предназначены игрокам.
- [ ] Реальная отмена загрузок, журнал с редактированием токенов и retry UX.
- [ ] Выбор каталога профиля и безопасный reset только managed-файлов.
- [ ] Keychain-хранилище refresh token; cross-process exclusion при необходимости.
- [ ] Keychain-хранилище сессии ShaCraft; OS-lock и lease игры уже реализованы.
- [ ] Динамический каталог и новости; реальный Aeronautics онлайн уже
загружается через фиксированный display-only API. Не имитировать данные.
## Связанные серверные риски
## Исправления по handoff (исходники, до выкладки)
Серверный план находится в `/root/shacraft/PLAN.md`. Важные следующие шаги:
одноразовое подтверждение ника внутри игры (NoGravity не связывает игрока
с веб-запросом), enforcement реферальных правил и очередь повторов whitelist.
Не менять этот протокол незаметно в клиентском рефакторинге.
- [x] Один signed snapshot и общий межпроцессный lock на Play/Repair.
- [x] Inventory, journal/recovery, retirement старых неизменённых managed-файлов.
- [x] Явный перенос выбранных legacy-модов в резервную копию.
- [x] NeoForge clean rebuild + provenance receipt; непустая порча обнаруживается.
- [x] Отдельный первый вход с серверным grant и одноразовым proof.
- [x] RAM retry сохраняет намерение; неверный JAVA_HOME не скрывает подходящий PATH.
- [x] Версии в интерфейсе берутся из проверенного manifest.
- [ ] Совместная выкладка backend/Game Bridge/подписанного payload и нового лаунчера.
- [ ] Изолированная игровая проверка LoginSystem + hold + proof, затем beta по ОС.
Сопутствующий серверный код содержит read-only status, nonce proof с legacy
migration и durable grant/revoke outbox. Оплата и доставка whitelist — разные
состояния. Реферальные правила остаются отдельной задачей серверного PLAN;
это исправление не меняет условия покупки. Продакшен не изменён.
+7 -1
View File
@@ -5,11 +5,17 @@ React/TypeScript интерфейс, Rust — файлы, сеть и запус
Реализованы подписанная синхронизация Aeronautics, проверка/восстановление
модов и конфигурации, Java discovery/provisioning, bootstrap Minecraft и
NeoForge, настройки памяти и ника, обработка установки/запуска/выхода.
NeoForge, настройки памяти, обработка установки/запуска/выхода. Проверка
сборки теперь восстанавливает и игровые файлы; старые неизменённые managed-моды
убираются в резервную копию, неизвестные моды разбираются явно пользователем.
Вход выполняется через аккаунт ShaCraft — тот же, что на сайте. Игровой ник
берётся только из подтверждённой привязки Aeronautics, а не из редактируемых
локальных настроек. Пароли не сохраняются; сессию можно отозвать.
Для первого входа без привязки добавлено отдельное действие в настройках
аккаунта: установка → серверное разрешение → LoginSystem → одноразовая команда
подтверждения. Оно требует совместной выкладки backend, Game Bridge и
подписанного модпака; наличие исходников не означает публикацию этого сценария.
Microsoft OAuth-модуль сохранён отдельно, но не используется текущим
сценарием запуска; для его активации потребуются client ID и API approval.
+22
View File
@@ -90,3 +90,25 @@ URL — cannot redirect a download to an attacker-controlled host in any of
these domains. When adding a new game-related download, verify its host is
one of the ones above (or add a new hardcoded constant following the same
pattern) rather than accepting a URL from anywhere else.
## Generated NeoForge artifacts and Java selection
NeoForge 21.1.248's actual installer does not publish processor-output hashes.
The launcher therefore validates the installer SHA-256, extracts its expected
version JSON and a scoped processor recipe, verifies the vanilla input against
Mojang, and runs that installer in a fresh staging tree. Generated JARs must
be readable ZIPs with valid entries. A receipt commits the generated hashes,
installer digest and recipe identity only after successful verified promotion.
This is provenance from a trusted computation, not independent publisher
checksums for generated files. Later corruption invalidates the receipt and
triggers clean reconstruction; a nonempty old file is never enough. Interrupted
promotion has no valid receipt and is repaired on the next preparation.
Only recipe-scoped generated artifacts are promoted; user profile files are
outside this tree. Staging can remain after an abrupt kill and is never trusted
as an installation. The receipt currently lives under the shared game cache.
Java discovery evaluates JAVA_HOME and PATH candidates independently for the
exact required major. A wrong-major JAVA_HOME no longer hides a valid PATH
installation. Only if no matching candidate exists is a managed runtime
provisioned. The UI reads required versions from verified profile metadata.
+169 -13
View File
@@ -6,26 +6,33 @@ The launcher persists local settings, synchronises Aeronautics mod/config
files from the signed ShaCraft v2 manifest, installs the exact Minecraft +
NeoForge version the manifest specifies, and launches the game. A player
signs in with the same local ShaCraft account used on the website. The game
identity is derived only from that account's verified Aeronautics nickname;
identity for ordinary Play is derived only from that account's verified Aeronautics nickname;
the legacy editable nickname setting is not trusted at launch.
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.
The launcher also checks signed GitHub releases for its own updates. AppImage,
Windows x64 installers and native Intel/Apple Silicon macOS app bundles use
explicit install-and-restart; Debian packages use manual package management.
Production publication and OS signing/notarization require operator setup;
see `updater-release.md`. Version 0.1.1 has no updater and must be upgraded
manually once. User-selectable profile directories and managed-only reset
remain unimplemented.
## Data flow
Two independent pipelines feed one launch:
One verified ShaCraft snapshot is fetched inside the installation lock for
each Play/Repair operation. Every stage, the displayed completion metadata and
the spawn use that snapshot; a newly published manifest is used next time.
Two independent trust pipelines feed one launch:
```text
ShaCraft manifest (mods/config + which MC/loader/Java version to use)
signed-manifest endpoint -> Ed25519 verification (remote.rs)
-> manifest schema + URL/path validation (manifest.rs)
-> temporary download, SHA-256 verification, atomic replacement (profile.rs)
-> inventory reconciliation, verified staging, durable apply journal (profile.rs)
Game itself (never controlled by the manifest above)
Mojang version manifest -> SHA-1-verified version JSON (mojang.rs)
@@ -68,7 +75,7 @@ be the system `.minecraft` directory.
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. Production release credentials/protected environments and OS beta validation.
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.
@@ -88,7 +95,10 @@ Browser preview cannot install/launch and does not simulate download progress.
Rust `lib.rs` registers commands from `commands/`. Installation and account
permits in `operations.rs` stay owned by blocking workers until completion.
ShaCraft sessions have a separate gate from the retained Microsoft module.
These are process-local guards, not cross-process locks or cancellation.
An additional OS file lock in `installation_lock.rs` covers all profiles and
the shared game installation across processes. It remains held by the child
watcher; a durable PID/start-time lease also protects a game that outlives its
launcher. This is exclusion, not cancellation.
`storage.rs` provides unique temporary files and atomic replacement; Unix
session files are created owner-only. Windows keeps a recoverable replacement
fallback if the OS refuses direct replacement. `trusted_http.rs` constrains provider
@@ -100,8 +110,154 @@ Hostile same-user TOCTOU is outside this protection; it is not an OS sandbox.
`npm test` covers asynchronous helpers and state transitions;
`npm run build` runs strict TypeScript before Vite. `cargo test --locked`
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.
covers native policy and storage. Push/PR CI repeats checks on Linux. Ubuntu 22.04
CI installs desktop dependencies from the runner main distro source list only,
without indexing unrelated vendor repositories. APT signature/hash checks remain
mandatory, and any distro index failure stops installation.
The package workflow builds and checks Windows x64, Linux x64 and both macOS
architectures with disposable test signing keys. These artifacts cannot be
published as production updater releases. Separate protected workflows assemble
a complete signed release as a draft, re-verify its assets and only then publish.
Native cold-install, real self-update and launch tests are required before
calling a platform release-ready. CI package builds do not prove those flows.
## Reconciliation and recovery
`profiles/.<profile-id>.shacraft-state/` is outside the payload root. It stores
`inventory.json`, `pending.json`, transaction staging/backups/receipts and
explicit legacy-mod backups. The inventory records only files the launcher
actually wrote, with size/hash/policy and signed snapshot digest. An existing
file that already matches is usable, but is not silently claimed as owned.
Seed files are never owned for later removal.
All required downloads are staged and verified before apply. A write-ahead
journal records old/new states. Interrupted apply resumes by checking hashes;
readiness stays false while a journal, stale owned files or conflicts remain.
Old owned files are moved to a backup only when their current contents still
match the recorded version. Changed files remain conflicts; unrelated files
and player worlds, screenshots, options and resourcepacks are preserved.
A manifest is not authority to overwrite an unknown colliding local file.
“Разобрать моды” lists legacy/changed JAR candidates without selecting any.
The user explicitly chooses paths; native code rechecks each selected hash
and safe path before moving it to a recoverable backup with a receipt. Unknown
extra mods are informational, not automatically removed or adopted. Readiness
means the declared pack files were checked; it does not certify arbitrary
additional user mods. Afterwards rerun Repair or Play.
`game/cache/neoforge-receipts/<version>.json` records provenance for generated
NeoForge outputs. The verified installer recipe and its embedded version JSON
scope which files can be rebuilt. Missing/invalid receipts, corrupt JSON or a
mismatched generated JAR cause a clean isolated rebuild. No local legacy hash
is accepted as the first baseline. See `game-trust-boundary.md`.
## First entry and account proof v1
The account settings offer “Установить и войти для подтверждения” separately
from ordinary Play. The launcher prepares the exact signed pack first, then
requests and validates an authenticated grant at the fixed ShaCraft origin:
`POST /api/launcher/onboarding/start` and `/validate`. It requires an approved
managed `mods/shacraft-game-bridge-*.jar` entry in that signed snapshot. Until
that payload and server bridge are deployed, this action fails with an explicit
message; ordinary linked Play keeps its existing account gate.
The grant binds account, `aoc`, exact nickname/offline UUID, challenge and TTL
(10 minutes). Only the game child gets `SHACRAFT_ONBOARDING_TOKEN` in its
environment; normal launches remove inherited tokens. The frontend receives
only the explicit proof challenge. The client bridge sends the token once to
the fixed Aeronautics socket. The server bridge holds that connection until
LoginSystem succeeds and the player enters `/shacraft link <id> <code>`.
The bridge uses a server-only bearer secret for fixed HTTPS callbacks. A grant
is not a whitelist grant, password replacement or completed web link.
New nicknames keep the established operator-whitelist → first `/register`
policy; registered names must use their existing `/login` password. Prism can
still perform the explicit proof command without a launcher grant. Existing
account links stay valid with legacy provenance and optional re-verification;
no bulk revocation occurs. GET link status is read-only. Backend implementation,
migrations, game adapter and rollout procedure are in the companion server
repository, `docs/account-proof-v1.md` and `docs/access-delivery.md`.
## Interrupted spawn recovery
`installation-state/writer.lock` is an OS lock file, not a stale-lock sentinel;
never delete it while any launcher/game is running. `game-lease.json` records
`Starting` before spawn and `Running` with PID/start time before releasing the
worker. A known exited child is cleared automatically. A crash in the narrow
spawn/record interval or malformed lease fails closed because the child cannot
be proven absent.
For that explicit error only: close all ShaCraft Launcher and Minecraft
processes (or reboot), verify none remain, and rename `game-lease.json` to a
backup outside `installation-state`. Then open one launcher and run Repair.
Do not remove game/profile trees or an active lock file as a recovery shortcut.
The application data root is shown in settings; it is not system `.minecraft`.
## Local live preparation check
`cargo test --locked --manifest-path src-tauri/Cargo.toml
commands::game::tests::live_cold_install_and_corruption_repair -- --ignored --nocapture`
uses a fresh temporary application directory, real signed/provider downloads
and the official installer. It corrupts generated NeoForge JSON/JAR, repairs
both and repeats a healthy check. It never logs into a game server. It requires
network access and sufficient disk space; the printed directory is retained
for diagnosis. This does not replace Tauri IPC, graphical gameplay or the
client/server proof matrix on each supported OS.
## Launcher self-update boundary (0.2.0)
`updater/protocol.rs` pins `github.com/emil28092005/shacraft-launcher/releases`.
The exact raw `latest.json` response is verified using its detached minisign
signature and the committed updater public key before versions, URLs or notes
are parsed. This key is independent of ShaCraft's Ed25519 mod manifest. Signed
metadata binds a stable version and tag to an exact set of four platform and
four manual package descriptors; every descriptor has an exact repository/tag/
filename, size, SHA-256 and Tauri signature. HTTPS redirects are restricted to
that repository and GitHub's release asset CDN. Stable downgrades, unknown
platforms, missing signatures and incomplete metadata fail closed.
Only native commands check, download, install and open the fixed releases page.
The webview supplies no URLs, public keys, executable arguments, release version
or arbitrary file path. No generic updater plugin permission is granted to it.
The packaged native architecture selects the artifact: Windows preserves MSI
versus NSIS, macOS preserves Intel versus Apple Silicon, Linux only replaces an
AppImage. The original non-symlink AppImage must have the expected native header,
and the frozen Tauri `APPDIR` must contain the actual running executable at
`usr/bin/shacraft-launcher`. Extracted binaries and inherited context from another
AppImage require manual installation. A Debian installation requires the user's
package manager.
Checks run once per application UI lifecycle and on explicit request. They do
not install automatically. The settings drawer shows installed/available
versions, plain-text notes, progress, actionable errors and retry. The explicit
install button includes restart. UI session recovery codes, unsaved/failed
settings and account/game operations inhibit that action; native permits are
the final authority for concurrent writes. Preferences, sessions and game data
live outside the executable and are not migrated or erased by the updater.
`launcher-state/instance.lock` is a process-lifetime OS lock, preventing an idle
second cooperating launcher from retaining old code during replacement. All
native account/settings/game writes hold shared lifecycle permits. Replacement
holds the exclusive permit and `installation-state/writer.lock`, which also
checks a game process's durable PID/start-time lease. A normal window close is
inhibited during download/install. Worker permits survive a dropped IPC future.
Before invoking the platform installer, `pending-update.json` records current
and target versions. The Windows plugin hands off and exits; the marker remains.
Only startup of the exact target version clears it. Old versions and indeterminate
installer failures block native mutations and direct the user to manual recovery.
A corrupt marker opens recovery diagnostics and latches the mutation/launch ban
until application restart, even if that file is removed while the UI is open.
No guessed installer timeout
releases the gate. This cannot retroactively make old 0.1.1 binaries cooperate
with these locks.
First metadata/signature and artifact reads are bounded. Tauri updater 2.11.0
requires its own second check to construct a private installer context; that
check uses the fixed version endpoint, HTTPS host policy and a 30-second timeout,
and its parsed metadata must equal the previously authenticated document.
The plugin's secondary response has no byte-limit API, leaving a memory-use
risk if that trusted release endpoint serves an unexpectedly large response.
Immediately before install, native code re-verifies artifact size/hash/signature;
`Update::install` alone does not perform signature verification.
+307
View File
@@ -0,0 +1,307 @@
# Launcher updater releases
The updater has its own signing key and fixed repository:
`emil28092005/shacraft-launcher`. It is independent of the ShaCraft modpack
manifest key. The committed public key is `src-tauri/updater-public-key.txt`;
the native client and release validation pin that key. An unset/placeholder key
cannot produce a release.
## Packages and signed metadata
The four build targets each produce two distributable files, with canonical names:
- Windows x64: `shacraft-launcher_VERSION_windows-x86_64-setup.exe` (NSIS)
and `shacraft-launcher_VERSION_windows-x86_64.msi`.
- Linux x64: `shacraft-launcher_VERSION_linux-x86_64.AppImage` and
`shacraft-launcher_VERSION_linux-x86_64.deb`.
- macOS arm64: `shacraft-launcher_VERSION_darwin-aarch64.dmg` and
`shacraft-launcher_VERSION_darwin-aarch64.app.tar.gz`.
- macOS x64: `shacraft-launcher_VERSION_darwin-x86_64.dmg` and
`shacraft-launcher_VERSION_darwin-x86_64.app.tar.gz`.
Every package has a detached `.sig` from the Tauri signer. AppImage, NSIS, MSI
and app tar signatures must already exist after `tauri build`; absence is an
error. The collection step signs deb and DMG explicitly. Renaming a package does
not alter its bytes or signature. The macOS app archive contains the `.app`
bundle; the DMG remains the manual installation package. These artifact formats
follow [Tauri's updater documentation](https://v2.tauri.app/plugin/updater/).
`latest.json` contains exactly `schemaVersion` (integer 1), `version`, `tag`,
`notes`, `pub_date`, `platforms` and `manualPackages`. `version` is stable
`MAJOR.MINOR.PATCH`; `tag` is exactly `vVERSION`. Windows MSI limits apply:
major/minor at most 255 and patch at most 65535. `pub_date` uses UTC
`YYYY-MM-DDTHH:MM:SSZ`, derived from the tagged source commit.
`platforms` has exactly `windows-x86_64`, `linux-x86_64`, `darwin-aarch64` and
`darwin-x86_64`. Their primary packages are NSIS, AppImage and the two app archives.
`manualPackages` has exactly `windows-x86_64-msi`, `linux-x86_64-deb`,
`darwin-aarch64-dmg` and `darwin-x86_64-dmg`. Despite the section name, the native
client also selects the MSI descriptor when updating an MSI installation.
Each descriptor has exactly `url`, `signature`, `sha256` and `size`. URLs are
bound to the exact version/tag and canonical filename under
`https://github.com/emil28092005/shacraft-launcher/releases/download/vVERSION/`.
SHA-256 is lowercase hexadecimal; size is a positive integer at most 1 GiB.
Metadata is at most 32 KiB, notes at most 4096 UTF-8 bytes and signatures at most
2048 characters.
The release script writes deterministic UTF-8 JSON with sorted keys, two-space
indentation and a trailing newline. It signs those exact bytes into
`latest.json.sig` using the same updater key. The client verifies this signature
before parsing: an artifact signature alone cannot bind a version or download
URL. The signed metadata binds the complete platform set, versions, filenames,
hashes, sizes and package signatures together.
`scripts/release-verifier` uses `minisign-verify` 0.2.5 and the same verification
sequence as the [Tauri updater 2.11 implementation](https://github.com/tauri-apps/plugins-workspace/blob/v2/plugins/updater/src/updater.rs):
base64-decode the Tauri public key/signature containers, decode the minisign
objects, then verify the exact file bytes including the trusted comment. Missing,
nonempty-but-invalid and mismatched-key signatures all fail. The utility does
not execute installers or replace applications.
## CI and protected draft creation
`check.yml` runs the usual UI/Rust checks plus release tests. `build.yml` builds
all four targets with fresh disposable test keys. It has read-only repository
permissions and receives no production signing secret. Transient build config
and `SHACRAFT_UPDATER_TEST_BUILD=1` select the test key; no committed pin changes.
Artifacts are labelled `CI-NOT-FOR-RELEASE-*`, include `CI_NOT_FOR_RELEASE.txt`,
and cannot pass release validation. Test keys are never uploaded.
Before a release, an operator must configure two existing GitHub environments:
`launcher-release` and `launcher-release-publish`. Both require a reviewer other
than the dispatcher (`prevent_self_review=true`) and permit protected branches
only. `main` must be protected. This is a two-person operation: the dispatcher
cannot approve their own deployment. The workflow gate checks these settings
through GitHub's [environment API](https://docs.github.com/en/rest/deployments/environments)
before exposing an environment name to later jobs. Missing environments or
insufficient API permissions stop the workflow; it never creates an unprotected
replacement or bypasses the check.
Set `SHACRAFT_UPDATER_PUBLIC_KEY` as an environment/repository variable, equal to
the complete committed base64 public key. `launcher-release` alone needs the
`TAURI_SIGNING_PRIVATE_KEY` secret (official Tauri encoded key contents) and,
for an encrypted key, `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`. The password may be
omitted/empty for an unencrypted key; signing is noninteractive. The publish
environment needs only the public variable, never the private key.
The private key must live outside Git, build artifacts and web roots. Keep the
key directory owner-only (0700), its key file owner-only (0600), and retain a
separate secure backup outside Git. Do not print the key, upload it as an
artifact or keep a purported encryption password beside it. Losing the key
breaks updates for installed clients; changing the public key is a coordinated
migration, not routine key regeneration.
To prepare a release:
1. Merge the reviewed source/version change. Package JSON, npm lock, Cargo
package and Tauri config versions must match. Create the exact `vVERSION`
tag on a commit belonging to protected `main`; the workflow never creates or
moves a tag.
2. Manually run **Prepare signed release draft** from `main` with that version,
tag and release notes. Pass the independent environment approval. Every
matrix runner checks the tag/versions and signs a challenge to prove the
supplied private key matches the committed public key before packaging.
3. All four jobs must finish. Only then does the final job assemble all eight
packages, all eight signatures and signed metadata. It verifies this complete
18-file set before creating a GitHub draft, uploads it, downloads it again
and re-verifies the draft. A partial upload remains a draft and cannot pass
publication validation. An existing draft is never silently overwritten.
Before any candidate source runs, trusted workflow Git commands prove that the
tag commit belongs to protected main. Later jobs check out the validated immutable
SHA, not the movable tag. Checkout credentials are removed, and the publication
token exists only in the final explicit publishing step.
The tag/release workflow is manual; pushing a tag does not publish an update.
The scripts do not configure GitHub environments, keys or secrets. Repository
access alone is not authorization to run the publication workflow.
## Explicit final publication
After reviewing the draft and completing platform acceptance, an operator runs
**Publish verified release (operator only)** from `main`, supplies version/tag
and the exact confirmation `publish vVERSION`, then obtains the separate
publish-environment approval. This job has no signing key. It checks the source
versions/tag, current committed public pin, environment policy and monotonic
stable release version; downloads every draft asset and checks all signatures,
hashes, sizes, names and signed metadata again. It also checks asset IDs/sizes/
digests did not change during validation. Only this step changes `draft` to false
and marks the release latest. Failures leave the draft unpublished.
GitHub `latest/download/latest.json` and its detached signature can temporarily
refer to different releases during CDN propagation. The client must reject that
mismatch and retry; it must never accept unsigned metadata as a fallback.
## Acceptance and bootstrap
Updater signatures authenticate update bytes. They are **not** Windows
Authenticode signatures, Apple Developer ID signatures or Apple notarization.
This pipeline does not provision those certificates or claim that SmartScreen
or Gatekeeper will trust a manually downloaded package. Any OS signing/notarization
step must finish before updater signing and metadata hashing. Never modify a
package after its updater signature is created.
Version 0.1.1 has no updater: users must install the first updater-enabled release
manually. Keeping application identifiers and installer families stable is
necessary, but unit tests do not establish upgrade compatibility. Before final
publication, validate actual old-to-new NSIS and MSI installations separately,
including per-user/elevated installation and preservation of account/settings/
Minecraft data. The native updater preserves the MSI/NSIS family.
Validate both macOS architectures on real supported systems, including writable
and protected application locations and process restart. Linux automatic
replacement is for an AppImage running from a writable AppImage location; a deb
installation shows availability and opens the fixed official releases page;
installation then uses the normal OS package installer. Non-AppImage or unwritable installations must not be treated
as successfully self-updated. Verify interrupted download, wrong signature,
current/no-update, relaunch and concurrent game/install behavior on each platform.
Local release tests use temporary synthetic payloads and newly generated
throwaway keys. They cover real signature verification, bit flips, wrong keys,
metadata substitution, exact artifact sets, duplicate fields, version/tag
bindings, CI promotion rejection and missing environment protection. They do
not prove any Windows/macOS installer ran, a live GitHub release was published,
or an end user's application updated successfully.
## Installed package migration and recovery
The settings drawer reports the installed native package family. Automatic
updates preserve it:
- Windows NSIS x64 → NSIS x64, retaining the existing per-user scope and saved
installation location. The signed package must contain an EXE, not MSI bytes.
- Windows MSI x64 → MSI x64, retaining the per-machine installer family. The
pinned UpgradeCode `2058b1df-56a1-51ef-bd48-d296479cd59a` is the exact value
Tauri CLI 2.11.4 derived for the existing 0.1.1 product name; ProductCode can
change for a major upgrade. Administrator permission may be required.
- MSI ↔ NSIS, simultaneous installations of both, renamed products, changed
scopes and manually moved Windows installations have no automatic migration
promise. Close the old application and use an explicit manual installer path;
verify installed-app registrations and account/settings preservation in beta.
- macOS Intel → Intel app archive, Apple Silicon → Apple Silicon app archive.
DMG is the bootstrap/manual distribution. Move the app out of a mounted DMG
into an appropriate Applications directory before use. A protected destination
may require OS permission or a manual replacement; an installation error never
counts as a completed update.
- Linux x64 AppImage → x64 AppImage at its current writable location. The original
ordinary, non-symlink AppImage must exist, and the frozen Tauri `APPDIR` must
match the running `usr/bin/shacraft-launcher`. Extracted AppDirs and an inherited
environment from another AppImage use manual installation. Debian
packages, RPM, bare development binaries and unsupported architectures never
enter the self-replacement path. Install a new deb through the system package
manager; the launcher does not run privileged package-manager commands.
These paths follow the locked [Tauri MSI implementation](https://github.com/tauri-apps/tauri/blob/tauri-cli-v2.11.4/crates/tauri-bundler/src/bundle/windows/msi/mod.rs),
[NSIS installer template](https://github.com/tauri-apps/tauri/blob/tauri-cli-v2.11.4/crates/tauri-bundler/src/bundle/windows/templates/installer.nsi)
and [updater implementation](https://github.com/tauri-apps/plugins-workspace/blob/updater-v2.11.0/plugins/updater/src/updater.rs).
Source inspection and CI packaging do not replace real installation acceptance.
The updater writes `launcher-state/pending-update.json` immediately before the
platform installer. Once handed off, cancellation, power loss and installer
errors can be ambiguous. Windows does not expose an installer PID/completion
result through the plugin, so the launcher never guesses that installation has
finished after a timeout. The exact target version acknowledges the marker on
startup. Until then, recovery mode blocks native game, account, settings and
update mutations. Corrupt marker bytes also open this diagnostic mode, never
normal operation. Its restriction stays latched even if the file is removed
while that process is open. Unix marker creation/acknowledgment syncs the parent
directory as well as file contents.
For an interrupted update:
1. Close Minecraft, all launcher instances and any installer; reboot if their
termination cannot be established. Do not delete active OS lock files.
2. Install the marker's expected target version manually from the fixed official
releases page, preserving the package family above. Replace only the app or
package, keeping the application data directory. Starting that target version
acknowledges a valid marker automatically.
3. If the marker is corrupt, or the target cannot be used, first manually restore
a complete official package of the intended supported version. With every
launcher/game/installer closed, rename only `launcher-state/pending-update.json`
to a backup outside `launcher-state`, then launch again. This is an operator
recovery after a complete package repair, not a shortcut around an active
installer. Keep the backup for diagnosis. Never remove settings, sessions,
profiles, game files, `instance.lock`, `writer.lock` or a live game lease.
Default application data roots (or the configured XDG data home on Linux):
- Windows: `%APPDATA%\ru.shacraft.launcher`.
- macOS: `~/Library/Application Support/ru.shacraft.launcher`.
- Linux: `~/.local/share/ru.shacraft.launcher`.
Network/hash/signature failures before installer entry leave the app intact and
permit an explicit fresh check/retry. They do not create an install handoff or
require marker recovery. A separate “game still running” error must be resolved
by closing the game, not removing its lease.
## Package architecture checks
Before collection/signing, scripts inspect package bytes without running an
installer. AppImage must have an ELF64 little-endian AMD64 header and the Type 2
`AI\x02` marker. The deb ar/control archive must declare `Architecture: amd64`
and the release version. Each macOS app archive must have exactly one
`Info.plist`, the same release version, and a regular main executable with a
thin Mach-O64 CPU type matching its matrix target. DMG checks validate the UDIF
container trailer; they do not mount or inspect the DMG filesystem.
On the Windows build runner, the built launcher must be AMD64 PE32+. NSIS uses
an x86 installer stub even for an x64 application: the checker accepts that
wrapper, uses the runner's 7-Zip to list/extract only the named launcher to
stdout, then requires an x64 payload identical to the built main executable after the
exact Tauri bundle-type stamp described below.
It never executes NSIS. Missing/unsupported 7-Zip inspection fails the build;
there is no silent architecture-check fallback. MSI is checked for a compound
file header and read using WindowsInstaller COM with `MSIDBOPEN_READONLY`:
Template Summary must say `x64` and ProductVersion must match. The MSI check also
checks the built main executable, but does not extract MSI's embedded cabinet or
prove that cabinet's payload matches the build. Real installer acceptance is
still required. These distinctions follow the [PE format](https://learn.microsoft.com/en-us/windows/win32/debug/pe-format)
and [64-bit MSI package requirements](https://learn.microsoft.com/en-us/windows/win32/msi/using-64-bit-windows-installer-packages).
The locked [Tauri CLI 2.11.4 bundler](https://github.com/tauri-apps/tauri/blob/tauri-cli-v2.11.4/crates/tauri-bundler/src/bundle.rs)
replaces the first complete `__TAURI_BUNDLE_TYPE_VAR_UNK` token with
`__TAURI_BUNDLE_TYPE_VAR_NSS` for NSIS (`MSI` for MSI), packages that binary, then
restores the original unpatched/unsigned executable on disk after each bundle.
The NSIS comparator constructs precisely that one replacement in a copy of the
built bytes and compares the entire extracted payload. It does not mask any PE
section, checksum, certificate table, padding or other bytes. A missing marker,
wrong family stamp or any unrelated byte change fails. Both original and
extracted executables must still be AMD64 PE32+.
Authenticode is currently unconfigured. Adding it can also change the PE checksum
and append a certificate table; this comparison intentionally fails until a
separate verified signed-baseline procedure is implemented. Do not broaden the
comparison to ignore all certificate/checksum differences merely to pass CI.
Portable format checks repeat when validating signed release assets. Full NSIS
payload/MSI COM checks run only during collection on the Windows runner; the
Linux draft/publish verifier rechecks their container headers and signatures.
Header/metadata inspection is not a runtime, architecture-emulation or installer
migration test. Synthetic header fixtures exercise these checks; they are never
installed or executed.
## Local package verification, 2026-09-09
Application source `696c6b0` was built on Ubuntu 26.04 x86_64 using a disposable
CI updater key and the visible test-build flag. Actual AppImage and deb packages
passed the same collection checks used by the workflow: expected container,
architecture/version where represented, and cryptographic updater signatures.
The manual-package collector explicitly signs deb/DMG even when a CLI version
also emits their signatures. No production package or release was signed.
The actual AppImage was started with `--appimage-extract-and-run`, isolated XDG
roots and a pending update targeting a different version. The native executable
ran inside its own `APPDIR/usr/bin/shacraft-launcher`, referenced the expected
`APPIMAGE`, held the instance OS lock and preserved the pending marker during
startup and after termination. This checks native startup and the runtime binding;
it does not establish visual/IPC behavior, installation, self-replacement or a
successful updater restart. The deb was inspected, not installed.
This local Ubuntu 26.04 build is not a compatibility result for Ubuntu 22.04 or
other distributions. The four-platform GitHub matrix, real Windows/macOS
installations, old-version migration and end-to-end update acceptance remain
pending. Production signing credentials and release environments are not
configured on GitHub, and no release has been published.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "shacraft-launcher-ui",
"version": "0.1.1",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "shacraft-launcher-ui",
"version": "0.1.1",
"version": "0.2.0",
"dependencies": {
"@tauri-apps/api": "2.11.1",
"lucide-react": "1.41.0",
+2 -2
View File
@@ -2,13 +2,13 @@
"name": "shacraft-launcher-ui",
"license": "MIT",
"private": true,
"version": "0.1.1",
"version": "0.2.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "npm run typecheck && vite build",
"typecheck": "tsc --noEmit",
"test": "tsx --test src/services/async.test.ts src/state/game.test.ts src/state/profiles.test.ts src/state/account.test.ts src/components/account.test.tsx",
"test": "tsx --test src/services/async.test.ts src/state/game.test.ts src/state/profiles.test.ts src/state/account.test.ts src/components/account.test.tsx src/state/updater.test.ts src/components/updater.test.tsx",
"preview": "vite preview",
"tauri": "tauri",
"tauri:dev": "tauri dev",
+1
View File
@@ -0,0 +1 @@
/target/
+23
View File
@@ -0,0 +1,23 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]]
name = "shacraft-release-verifier"
version = "0.1.0"
dependencies = [
"base64",
"minisign-verify",
]
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "shacraft-release-verifier"
version = "0.1.0"
edition = "2021"
publish = false
[dependencies]
base64 = "=0.22.1"
minisign-verify = "=0.2.5"
+32
View File
@@ -0,0 +1,32 @@
//! Uses precisely the Tauri updater 2.11 signature verification primitive.
//! Upstream: plugins/updater/src/updater.rs::verify_signature (MIT/Apache-2.0).
use base64::Engine;
use minisign_verify::{PublicKey, Signature};
use std::{env, fs, process::ExitCode};
fn verify() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = env::args_os().skip(1).collect();
if args.len() != 3 {
return Err("expected PUBLIC_KEY_FILE DATA_FILE SIGNATURE_FILE".into());
}
let decode = |path: &std::ffi::OsStr| -> Result<String, Box<dyn std::error::Error>> {
let encoded = fs::read_to_string(path)?;
let bytes = base64::engine::general_purpose::STANDARD.decode(encoded.trim())?;
Ok(String::from_utf8(bytes)?)
};
let public_key = PublicKey::decode(&decode(&args[0])?)?;
let signature = Signature::decode(&decode(&args[2])?)?;
public_key.verify(&fs::read(&args[1])?, &signature, true)?;
Ok(())
}
fn main() -> ExitCode {
match verify() {
Ok(()) => ExitCode::SUCCESS,
Err(_) => {
// Never echo key material or signer diagnostics into release logs.
eprintln!("Tauri updater signature verification failed");
ExitCode::FAILURE
}
}
}
+415
View File
@@ -0,0 +1,415 @@
#!/usr/bin/env python3
"""Assemble/verify release assets. No network or publication in this module."""
import argparse
import base64
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import tomllib
from datetime import datetime
from pathlib import Path
import release_formats
REPOSITORY = "emil28092005/shacraft-launcher"
ORIGIN = f"https://github.com/{REPOSITORY}/releases/download"
PLATFORMS = {
"windows-x86_64": ("nsis/*.exe", "-setup.exe"),
"linux-x86_64": ("appimage/*.AppImage", ".AppImage"),
"darwin-aarch64": ("macos/*.app.tar.gz", ".app.tar.gz"),
"darwin-x86_64": ("macos/*.app.tar.gz", ".app.tar.gz"),
}
MANUAL = {
"windows-x86_64-msi": ("windows-x86_64", "msi/*.msi", ".msi"),
"linux-x86_64-deb": ("linux-x86_64", "deb/*.deb", ".deb"),
"darwin-aarch64-dmg": ("darwin-aarch64", "dmg/*.dmg", ".dmg"),
"darwin-x86_64-dmg": ("darwin-x86_64", "dmg/*.dmg", ".dmg"),
}
FIELDS = {"url", "signature", "sha256", "size"}
MAX_SIZE = 1024**3
def require(condition, message):
if not condition:
raise ValueError(message)
def version_tag(version, tag):
require(
re.fullmatch(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)", version),
"release version must be stable MAJOR.MINOR.PATCH",
)
major, minor, patch = map(int, version.split("."))
require(major <= 255 and minor <= 255 and patch <= 65535, "version exceeds MSI limits")
require(tag == f"v{version}", "tag/version mismatch")
def filename(version, platform, suffix):
return f"shacraft-launcher_{version}_{platform}{suffix}"
def expected_names(version):
return {filename(version, key, value[1]) for key, value in PLATFORMS.items()} | {
filename(version, platform, suffix) for platform, _, suffix in MANUAL.values()
}
def canonical(data):
return (json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8")
def unique_object(pairs):
result = {}
for key, value in pairs:
require(key not in result, "duplicate JSON field")
result[key] = value
return result
def read_json(path):
return json.loads(path.read_bytes(), object_pairs_hook=unique_object)
def public_key():
value = os.environ.get("SHACRAFT_UPDATER_PUBLIC_KEY", "").strip()
require(bool(value), "SHACRAFT_UPDATER_PUBLIC_KEY is missing")
try:
decoded = base64.b64decode(value, validate=True).decode("utf-8")
lines = decoded.splitlines()
raw = base64.b64decode(lines[1], validate=True)
require(
lines[0].startswith("untrusted comment:") and len(raw) == 42 and raw[:2] == b"Ed",
"invalid Tauri public key",
)
except (ValueError, IndexError, UnicodeError) as error:
raise ValueError("invalid Tauri public key") from error
return value
def verifier_path(root):
return os.environ.get(
"RELEASE_VERIFIER",
str(
root
/ "scripts/release-verifier/target/release"
/ ("shacraft-release-verifier.exe" if os.name == "nt" else "shacraft-release-verifier")
),
)
def verify_signature(root, data, signature):
with tempfile.TemporaryDirectory(prefix="shacraft-verify-") as temporary:
key = Path(temporary) / "public-key"
key.write_text(public_key(), encoding="utf-8")
result = subprocess.run(
[verifier_path(root), str(key), str(data), str(signature)],
capture_output=True,
timeout=120,
check=False,
)
require(result.returncode == 0, f"invalid updater signature: {data.name}")
def signer(root, data):
private = os.environ.get("TAURI_SIGNING_PRIVATE_KEY", "")
require(bool(private.strip()), "TAURI_SIGNING_PRIVATE_KEY is missing")
environment = dict(os.environ)
# An omitted password means an unencrypted key. Never allow a CI prompt;
# encrypted keys without the correct password fail in the signer.
environment.setdefault("TAURI_SIGNING_PRIVATE_KEY_PASSWORD", "")
# Builds accept a key path; signer sign accepts the encoded key contents.
try:
is_key_path = len(private) < 4096 and "\n" not in private and Path(private).is_file()
except OSError:
is_key_path = False
if is_key_path:
environment["TAURI_SIGNING_PRIVATE_KEY"] = Path(private).read_text().strip()
environment.pop("TAURI_SIGNING_PRIVATE_KEY_PATH", None)
result = subprocess.run(
["node", str(root / "node_modules/@tauri-apps/cli/tauri.js"), "signer", "sign", str(data)],
env=environment,
capture_output=True,
timeout=120,
check=False,
)
require(result.returncode == 0, "Tauri signing failed (check protected signing credentials)")
def preflight(root, version, tag, check_git=False, signing=False):
version_tag(version, tag)
versions = [
read_json(root / "package.json")["version"],
read_json(root / "package-lock.json")["version"],
read_json(root / "package-lock.json")["packages"][""]["version"],
read_json(root / "src-tauri/tauri.conf.json")["version"],
tomllib.loads((root / "src-tauri/Cargo.toml").read_text())["package"]["version"],
]
require(all(value == version for value in versions), "source versions disagree with release")
if check_git:
def git(*args):
return subprocess.check_output(["git", *args], cwd=root, text=True).strip()
require(
git("rev-parse", "HEAD") == git("rev-parse", f"refs/tags/{tag}^{{commit}}"),
"checkout does not match existing release tag",
)
subprocess.run(
["git", "merge-base", "--is-ancestor", "HEAD", "origin/main"], cwd=root, check=True
)
if signing:
require(os.environ.get("SHACRAFT_UPDATER_TEST_BUILD") != "1", "CI test keys cannot release")
pinned = (root / "src-tauri/updater-public-key.txt").read_text().strip()
require(public_key() == pinned, "release public key differs from committed updater key")
with tempfile.TemporaryDirectory(prefix="shacraft-key-check-") as temporary:
challenge = Path(temporary) / "key-check"
challenge.write_bytes(f"ShaCraft release key check {tag}\n".encode())
signer(root, challenge)
verify_signature(root, challenge, Path(str(challenge) + ".sig"))
def write_build_config(destination):
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(
canonical(
{
"bundle": {"createUpdaterArtifacts": True},
"plugins": {"updater": {"pubkey": public_key()}},
}
)
)
def ci_key(root, directory):
require(not os.environ.get("TAURI_SIGNING_PRIVATE_KEY"), "CI must not receive production key")
directory.mkdir(parents=True, exist_ok=True)
key = directory / "DISPOSABLE-CI-ONLY.key"
result = subprocess.run(
[
"node",
str(root / "node_modules/@tauri-apps/cli/tauri.js"),
"signer",
"generate",
"--ci",
"--password",
"",
"--write-keys",
str(key),
],
capture_output=True,
timeout=120,
check=False,
)
require(result.returncode == 0, "disposable CI key generation failed")
key.chmod(0o600)
values = {
"TAURI_SIGNING_PRIVATE_KEY": str(key),
"TAURI_SIGNING_PRIVATE_KEY_PASSWORD": "",
"SHACRAFT_UPDATER_PUBLIC_KEY": Path(str(key) + ".pub").read_text().strip(),
"SHACRAFT_UPDATER_TEST_BUILD": "1",
}
os.environ.update(values)
write_build_config(directory / "updater-build.json")
with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as stream:
for name, value in values.items():
require("\n" not in value and "\r" not in value, "invalid CI environment value")
stream.write(f"{name}={value}\n")
def collect(root, bundle, destination, platform, version):
version_tag(version, f"v{version}")
destination.mkdir(parents=True, exist_ok=True)
require(not list(destination.iterdir()), "collection destination must be empty")
entries = [(PLATFORMS[platform][0], PLATFORMS[platform][1], True)]
entries += [
(glob, suffix, suffix == ".msi") for key, glob, suffix in MANUAL.values() if key == platform
]
for glob, suffix, required_signature in entries:
matches = list(bundle.glob(glob))
require(
len(matches) == 1 and matches[0].is_file() and not matches[0].is_symlink(),
f"expected exactly one {platform} {glob}",
)
source = matches[0]
main = bundle.parent / "shacraft-launcher.exe" if platform == "windows-x86_64" else None
release_formats.validate(source, platform, suffix, version, main)
target = destination / filename(version, platform, suffix)
shutil.copyfile(source, target)
source_signature = Path(str(source) + ".sig")
signature = Path(str(target) + ".sig")
if required_signature:
require(
source_signature.is_file() and not source_signature.is_symlink(),
f"missing generated updater signature: {source.name}",
)
shutil.copyfile(source_signature, signature)
else:
signer(root, target) # Sign manual packages explicitly, independent of bundler output.
verify_signature(root, target, signature)
if os.environ.get("SHACRAFT_UPDATER_TEST_BUILD") == "1":
(destination / "CI_NOT_FOR_RELEASE.txt").write_text(
"DISPOSABLE TEST KEY. These CI artifacts are not deployable releases.\n",
encoding="utf-8",
)
def descriptor(root, directory, version, tag, platform, suffix):
name = filename(version, platform, suffix)
path, sig = directory / name, directory / (name + ".sig")
require(
path.is_file() and not path.is_symlink() and sig.is_file() and not sig.is_symlink(),
f"missing or unsafe release asset: {name}",
)
size = path.stat().st_size
require(0 < size <= MAX_SIZE, "invalid package size")
verify_signature(root, path, sig)
release_formats.validate(path, platform, suffix, version)
with path.open("rb") as stream:
digest = hashlib.file_digest(stream, "sha256").hexdigest()
signature = sig.read_text(encoding="utf-8").strip()
require(0 < len(signature) <= 2048, "invalid signature length")
return {"url": f"{ORIGIN}/{tag}/{name}", "signature": signature, "sha256": digest, "size": size}
def metadata(root, directory, version, tag, notes, date):
version_tag(version, tag)
require(len(notes.encode()) <= 4096, "release notes too long")
require(
re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", date), "expected UTC publication date"
)
datetime.fromisoformat(date.replace("Z", "+00:00"))
return {
"schemaVersion": 1,
"version": version,
"tag": tag,
"notes": notes,
"pub_date": date,
"platforms": {
key: descriptor(root, directory, version, tag, key, suffix)
for key, (_, suffix) in PLATFORMS.items()
},
"manualPackages": {
key: descriptor(root, directory, version, tag, platform, suffix)
for key, (platform, _, suffix) in MANUAL.items()
},
}
def verify_release(root, directory, version, tag):
version_tag(version, tag)
names = expected_names(version)
expected = names | {name + ".sig" for name in names} | {"latest.json", "latest.json.sig"}
require(
{path.name for path in directory.iterdir()} == expected,
"release asset set is incomplete or unexpected",
)
path = directory / "latest.json"
require(path.stat().st_size <= 32768, "metadata exceeds limit")
require(
not path.is_symlink() and not (directory / "latest.json.sig").is_symlink(),
"unsafe metadata",
)
verify_signature(
root, path, directory / "latest.json.sig"
) # Verify exact bytes BEFORE parsing.
actual = read_json(path)
require(
isinstance(actual, dict)
and set(actual)
== {"schemaVersion", "version", "tag", "notes", "pub_date", "platforms", "manualPackages"},
"unexpected metadata fields",
)
require(
type(actual["schemaVersion"]) is int and actual["schemaVersion"] == 1,
"unsupported metadata schema",
)
require(actual["version"] == version and actual["tag"] == tag, "signed version/tag mismatch")
require(
isinstance(actual["notes"], str) and isinstance(actual["pub_date"], str),
"invalid metadata text",
)
for section, keys in (("platforms", PLATFORMS), ("manualPackages", MANUAL)):
require(
isinstance(actual[section], dict) and set(actual[section]) == set(keys),
"unexpected platform/package set",
)
for item in actual[section].values():
require(isinstance(item, dict) and set(item) == FIELDS, "unexpected descriptor fields")
require(
type(item["size"]) is int and 0 < item["size"] <= MAX_SIZE,
"invalid descriptor size",
)
require(
all(isinstance(item[name], str) for name in ("url", "signature", "sha256")),
"invalid descriptor text",
)
expected_metadata = metadata(root, directory, version, tag, actual["notes"], actual["pub_date"])
require(
actual == expected_metadata,
"metadata does not match exact platforms, URLs, hashes, sizes or signatures",
)
require(path.read_bytes() == canonical(expected_metadata), "metadata is not canonical")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1])
sub = parser.add_subparsers(dest="command", required=True)
check = sub.add_parser("preflight")
check.add_argument("--version", required=True)
check.add_argument("--tag", required=True)
check.add_argument("--check-git", action="store_true")
check.add_argument("--signing", action="store_true")
check.add_argument("--config", type=Path)
ci = sub.add_parser("ci-key")
ci.add_argument("--directory", required=True, type=Path)
gather = sub.add_parser("collect")
gather.add_argument("--bundle", required=True, type=Path)
gather.add_argument("--directory", required=True, type=Path)
gather.add_argument("--platform", required=True, choices=PLATFORMS)
gather.add_argument("--version", required=True)
for name in ("metadata", "verify"):
item = sub.add_parser(name)
item.add_argument("--directory", required=True, type=Path)
item.add_argument("--version", required=True)
item.add_argument("--tag", required=True)
if name == "metadata":
item.add_argument("--date", required=True)
item.add_argument("--notes", default="")
args = parser.parse_args()
root = args.root.resolve()
if args.command == "preflight":
preflight(root, args.version, args.tag, args.check_git, args.signing)
if args.config:
write_build_config(args.config)
elif args.command == "ci-key":
ci_key(root, args.directory)
elif args.command == "collect":
collect(root, args.bundle, args.directory, args.platform, args.version)
elif args.command == "metadata":
require(os.environ.get("SHACRAFT_UPDATER_TEST_BUILD") != "1", "CI artifacts cannot release")
data = metadata(root, args.directory, args.version, args.tag, args.notes, args.date)
path = args.directory / "latest.json"
require(
not path.exists() and not (args.directory / "latest.json.sig").exists(),
"metadata already exists",
)
path.write_bytes(canonical(data))
signer(root, path)
verify_release(root, args.directory, args.version, args.tag)
else:
verify_release(root, args.directory, args.version, args.tag)
if __name__ == "__main__":
try:
main()
except (ValueError, OSError, subprocess.SubprocessError, KeyError, TypeError) as error:
print(f"Release validation failed: {error}", file=sys.stderr)
sys.exit(1)
+115
View File
@@ -0,0 +1,115 @@
"""No network: reject unsafe draft listings before invoking GitHub download."""
import copy
import unittest
from pathlib import Path
from unittest.mock import patch
import release
import release_github
class DraftAssetGateTests(unittest.TestCase):
def draft(self):
packages = release.expected_names("0.3.0")
names = packages | {name + ".sig" for name in packages} | {"latest.json", "latest.json.sig"}
self.assertEqual(len(names), 18)
return {
"id": 123, "tag_name": "v0.3.0", "draft": True, "prerelease": False,
"assets": [{"name": name, "id": index, "state": "uploaded", "size": 1, "digest": None}
for index, name in enumerate(sorted(names), 1)],
}
def assert_no_download(self, data):
with (patch.object(release_github, "api", return_value=data),
patch.object(release_github, "gh") as github,
patch.object(release, "verify_release") as verify):
with self.assertRaises(ValueError):
release_github.verify_draft(Path("/unused"), "0.3.0", "v0.3.0")
github.assert_not_called()
verify.assert_not_called()
def fake_download(self, listing):
def download(*args):
self.assertEqual(args[:3], ("release", "download", "v0.3.0"))
destination = Path(args[args.index("--dir") + 1])
for asset in listing["assets"]:
(destination / asset["name"]).write_bytes(b"x" * asset["size"])
return download
def test_exact_complete_listing_still_downloads_and_verifies_actual_contents(self):
listing = self.draft()
with (patch.object(release_github, "api", side_effect=[listing, listing]) as api,
patch.object(release_github, "gh", side_effect=self.fake_download(listing)) as github,
patch.object(release, "verify_release") as verify):
self.assertEqual(release_github.verify_draft(Path("/unused"), "0.3.0", "v0.3.0"), listing)
github.assert_called_once()
verify.assert_called_once()
self.assertEqual(api.call_count, 2)
def test_unexpected_traversal_absolute_and_excessive_names_cannot_download(self):
for name in ["extra.exe", "../latest.json", r"..\latest.json", "/tmp/latest.json",
"folder/latest.json", "latest.JSON", "x" * 4096]:
with self.subTest(name=name[:50]):
listing = self.draft()
listing["assets"][0]["name"] = name
self.assert_no_download(listing)
listing = self.draft()
listing["assets"].append({"name": "extra.txt", "id": 100, "state": "uploaded", "size": 1})
self.assert_no_download(listing)
def test_missing_duplicate_and_unfinished_files_cannot_download(self):
for index in range(18):
with self.subTest(missing=index):
listing = self.draft()
listing["assets"].pop(index)
self.assert_no_download(listing)
listing = self.draft()
listing["assets"].append(copy.deepcopy(listing["assets"][0]))
self.assert_no_download(listing)
listing = self.draft()
listing["assets"][0]["state"] = "new"
self.assert_no_download(listing)
def test_every_asset_size_must_be_a_positive_integer_within_its_limit(self):
listing = self.draft()
for index, asset in enumerate(listing["assets"]):
limit = 32768 if asset["name"] == "latest.json" else 8192 if asset["name"].endswith(".sig") else 1024**3
for size in [0, -1, True, False, 1.0, "1", None, limit + 1]:
with self.subTest(name=asset["name"], size=size):
altered = copy.deepcopy(listing)
altered["assets"][index]["size"] = size
self.assert_no_download(altered)
def test_declared_size_boundaries_are_accepted_without_allocating_large_files(self):
listing = self.draft()
for asset in listing["assets"]:
asset["size"] = 32768 if asset["name"] == "latest.json" else 8192 if asset["name"].endswith(".sig") else 1024**3
with (patch.object(release_github, "api", return_value=listing),
patch.object(release_github, "gh", side_effect=RuntimeError("download boundary reached")) as github):
with self.assertRaisesRegex(RuntimeError, "download boundary reached"):
release_github.verify_draft(Path("/unused"), "0.3.0", "v0.3.0")
github.assert_called_once()
def test_valid_listing_does_not_bypass_signature_failure_or_remote_race_checks(self):
listing = self.draft()
with (patch.object(release_github, "api", return_value=listing) as api,
patch.object(release_github, "gh", side_effect=self.fake_download(listing)) as github,
patch.object(release, "verify_release", side_effect=ValueError("invalid updater signature")) as verify):
with self.assertRaisesRegex(ValueError, "invalid updater signature"):
release_github.verify_draft(Path("/unused"), "0.3.0", "v0.3.0")
github.assert_called_once()
verify.assert_called_once()
self.assertEqual(api.call_count, 1)
changed = copy.deepcopy(listing)
changed["assets"][0]["id"] += 100
with (patch.object(release_github, "api", side_effect=[listing, changed]),
patch.object(release_github, "gh", side_effect=self.fake_download(listing)),
patch.object(release, "verify_release") as verify):
with self.assertRaisesRegex(ValueError, "changed during verification"):
release_github.verify_draft(Path("/unused"), "0.3.0", "v0.3.0")
verify.assert_called_once()
if __name__ == "__main__":
unittest.main()
+237
View File
@@ -0,0 +1,237 @@
"""Read-only package checks. Never execute or install a package under inspection."""
import io
import json
import plistlib
import shutil
import struct
import subprocess
import tarfile
from pathlib import Path, PurePosixPath
def require(condition, message):
if not condition:
raise ValueError(f"Package validation failed: {message}")
def pe_machine(data):
require(len(data) >= 64 and data[:2] == b"MZ", "invalid PE DOS header")
offset = struct.unpack_from("<I", data, 60)[0]
require(
64 <= offset <= len(data) - 26 and data[offset : offset + 4] == b"PE\0\0",
"invalid PE header",
)
return struct.unpack_from("<H", data, offset + 4)[0], struct.unpack_from(
"<H", data, offset + 24
)[0]
def pe_x64(data):
require(pe_machine(data) == (0x8664, 0x20B), "launcher executable must be AMD64 PE32+")
def appimage(path):
with path.open("rb") as stream:
data = stream.read(64)
require(
len(data) == 64 and data[:6] == b"\x7fELF\x02\x01", "AppImage must be little-endian ELF64"
)
require(data[8:11] == b"AI\x02", "AppImage must use Type 2 format")
require(struct.unpack_from("<H", data, 18)[0] == 62, "AppImage must target AMD64")
def safe_member(name):
name = name.removeprefix("./")
path = PurePosixPath(name)
require(
not path.is_absolute() and ".." not in path.parts and "\\" not in name,
"unsafe archive member",
)
return name
def mac_app(path, platform, version):
with tarfile.open(path, "r:gz") as archive:
members = {}
for index, item in enumerate(archive):
require(index < 10000, "too many app archive entries")
name = safe_member(item.name).rstrip("/")
require(name not in members, "duplicate app archive member")
members[name] = item
plists = [name for name in members if name.endswith(".app/Contents/Info.plist")]
require(len(plists) == 1, "expected exactly one app Info.plist")
info = members[plists[0]]
require(info.isfile() and 0 < info.size <= 1024 * 1024, "invalid app Info.plist")
details = plistlib.loads(archive.extractfile(info).read())
executable = details.get("CFBundleExecutable", "")
require(
isinstance(executable, str)
and executable not in {"", ".", ".."}
and "/" not in executable
and "\\" not in executable,
"invalid app executable name",
)
require(details.get("CFBundleShortVersionString") == version, "app version mismatch")
main = plists[0].removesuffix("Info.plist") + "MacOS/" + executable
require(main in members and members[main].isfile(), "app executable missing or linked")
data = archive.extractfile(members[main]).read(32)
require(
len(data) == 32 and data[:4] == b"\xcf\xfa\xed\xfe",
"expected a thin little-endian Mach-O64 executable",
)
cpu, _, kind = struct.unpack_from("<III", data, 4)
wanted = 0x0100000C if platform == "darwin-aarch64" else 0x01000007
require(cpu == wanted and kind == 2, "app Mach-O architecture/type mismatch")
def deb(path, version):
control = None
debian_binary = None
with path.open("rb") as stream:
require(stream.read(8) == b"!<arch>\n", "invalid deb ar header")
seen = set()
while header := stream.read(60):
require(len(header) == 60 and header[58:] == b"`\n", "invalid deb ar member")
name = header[:16].decode("ascii").strip().removesuffix("/")
require(name not in seen and len(seen) < 20, "duplicate/excessive deb members")
seen.add(name)
size = int(header[48:58].decode("ascii").strip())
require(
0 <= size <= 1024**3 and stream.tell() + size <= path.stat().st_size,
"invalid/truncated deb member size",
)
if name == "debian-binary":
require(size <= 16, "invalid debian-binary size")
debian_binary = stream.read(size)
elif name in {"control.tar.gz", "control.tar.xz", "control.tar"}:
require(control is None and size <= 8 * 1024**2, "invalid deb control archive")
control = stream.read(size)
require(len(control) == size, "truncated deb control archive")
else:
stream.seek(size, 1)
if size % 2:
require(stream.read(1) == b"\n", "invalid ar padding")
require(
debian_binary == b"2.0\n"
and control is not None
and any(name.startswith("data.tar") for name in seen),
"missing deb version/control/data",
)
with tarfile.open(fileobj=io.BytesIO(control), mode="r:*") as archive:
matches = [item for item in archive if safe_member(item.name) == "control"]
require(
len(matches) == 1 and matches[0].isfile() and matches[0].size <= 1024**2,
"invalid deb control file",
)
text = archive.extractfile(matches[0]).read().decode("utf-8")
fields = {}
for line in text.splitlines():
if line and not line[0].isspace():
key, separator, value = line.partition(":")
require(separator and key not in fields, "invalid/duplicate deb control field")
fields[key] = value.strip()
require(fields.get("Architecture") == "amd64", "deb Architecture must be amd64")
require(fields.get("Version") == version, "deb version mismatch")
def expected_nsis_payload(original):
# tauri-cli-v2.11.4 / tauri-bundler::patch_binary changes only the first
# complete token, then restores the original on-disk main after each bundle.
# Reproduce that exact operation; never mask PE checksums, sections,
# Authenticode certificate tables or arbitrary matching regions.
token = b"__TAURI_BUNDLE_TYPE_VAR_UNK"
patched = b"__TAURI_BUNDLE_TYPE_VAR_NSS"
offset = original.find(token)
require(offset >= 0, "built launcher lacks the expected Tauri bundle-type marker")
return original[:offset] + patched + original[offset + len(token) :]
def nsis_payload(path, built_main):
tool = shutil.which("7z") or r"C:\Program Files\7-Zip\7z.exe"
require(Path(tool).is_file(), "7-Zip is required to inspect NSIS payload")
listing = subprocess.run(
[tool, "l", "-slt", "-sccUTF-8", "--", str(path)],
capture_output=True,
check=False,
timeout=60,
)
require(listing.returncode == 0, "7-Zip cannot inspect NSIS payload")
matches = []
for line in listing.stdout.decode("utf-8", errors="strict").splitlines():
if line.startswith("Path = "):
entry = line.removeprefix("Path = ")
if PurePosixPath(entry.replace("\\", "/")).name == built_main.name:
matches.append(entry)
require(len(matches) == 1, "expected exactly one bundled launcher EXE in NSIS")
extracted = subprocess.run(
[tool, "x", "-so", "-bd", "--", str(path), matches[0]],
capture_output=True,
check=False,
timeout=60,
)
require(extracted.returncode == 0, "7-Zip cannot extract NSIS launcher for inspection")
pe_x64(extracted.stdout)
require(
extracted.stdout == expected_nsis_payload(built_main.read_bytes()),
"NSIS payload differs from the x64 build plus the exact Tauri NSIS marker patch",
)
def windows(path, suffix, version, built_main):
with path.open("rb") as stream:
header = stream.read(4096)
if suffix == ".msi":
require(
len(header) >= 512
and header[:8] == bytes.fromhex("d0cf11e0a1b11ae1")
and header[28:30] == b"\xfe\xff",
"invalid MSI compound-file header",
)
else:
# Tauri's NSIS x64 package legitimately uses an x86-unicode installer stub.
require(
pe_machine(header) in {(0x14C, 0x10B), (0x8664, 0x20B)}, "unsupported NSIS PE wrapper"
)
if built_main is None:
return # Full payload/COM checks run on the Windows collection runner.
require(built_main.is_file() and not built_main.is_symlink(), "built launcher EXE is missing")
pe_x64(built_main.read_bytes())
if suffix != ".msi":
nsis_payload(path, built_main)
else:
script = Path(__file__).with_name("release_msi.ps1")
result = subprocess.run(
[
"powershell.exe",
"-NoProfile",
"-NonInteractive",
"-File",
str(script),
"-PackagePath",
str(path.resolve()),
],
capture_output=True,
check=False,
timeout=60,
)
require(result.returncode == 0, "cannot read MSI summary/properties")
details = json.loads(result.stdout.decode("utf-8-sig"))
require(details.get("template", "").split(";")[0] == "x64", "MSI template must be x64")
require(details.get("version") == version, "MSI ProductVersion mismatch")
def validate(path, platform, suffix, version, built_main=None):
if suffix == ".AppImage":
appimage(path)
elif suffix == ".deb":
deb(path, version)
elif suffix == ".app.tar.gz":
mac_app(path, platform, version)
elif suffix == ".dmg":
with path.open("rb") as stream:
require(path.stat().st_size >= 512, "truncated DMG")
stream.seek(-512, 2)
require(stream.read(4) == b"koly", "invalid DMG UDIF trailer")
else:
windows(path, suffix, version, built_main)
+184
View File
@@ -0,0 +1,184 @@
"""Execute the actual trusted workflow gate against disposable local Git history."""
import os
import re
import shutil
import subprocess
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
ROOT = Path(os.environ.get("RELEASE_TEST_ROOT", Path(__file__).resolve().parents[1]))
WORKFLOWS = [ROOT / ".github/workflows" / name for name in ("release.yml", "release-publish.yml")]
GATE = " - name: Resolve tag using trusted workflow Git commands\n"
def gate_bash(windows=os.name == "nt"):
# Native CreateProcess can resolve bare bash to the System32 WSL launcher
# before Git Bash. Bind tests to the Bash installed with the Git we use.
if windows:
git = shutil.which("git")
if git:
directory = Path(git).resolve().parent
candidates = (
directory / "bash.exe", # Git/bin/git.exe
directory.parent / "bin/bash.exe", # Git/cmd/git.exe
directory.parent.parent / "bin/bash.exe", # Git/mingw64/bin/git.exe
)
for candidate in candidates:
if candidate.is_file():
return str(candidate.resolve())
raise RuntimeError("Git for Windows Bash was not found beside the installed Git")
bash = shutil.which("bash")
if not bash:
raise RuntimeError("Bash is required to execute the release workflow gate")
return str(Path(bash).resolve())
def gate_diagnostic(result):
return f"command={result.args!r}\nstdout={result.stdout!r}\nstderr={result.stderr!r}"
def workflow_gate(workflow):
after = workflow.read_text().split(GATE, 1)[1]
lines = after.split(" run: |\n", 1)[1].splitlines()
body = []
for line in lines:
if not line.startswith(" "):
break
body.append(line[10:])
return "\n".join(body) + "\n"
class ReleaseGateTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory(prefix="shacraft-source-gate-")
self.directory = Path(self.temporary.name)
self.environment = dict(os.environ, GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull)
self.git("init", "--initial-branch=main")
self.git("config", "user.email", "test@example.invalid")
self.git("config", "user.name", "Release Gate Test")
(self.directory / "reviewed.txt").write_text("Reviewed source\n")
self.git("add", ".")
self.git("commit", "-m", "Reviewed main commit")
self.good = self.git("rev-parse", "HEAD")
self.git("update-ref", "refs/remotes/origin/main", self.good)
self.git("tag", "v0.2.0")
self.git("checkout", "-b", "unreviewed")
(self.directory / "scripts").mkdir()
# This validator would falsely accept its own tag if a workflow ran it.
(self.directory / "scripts/release.py").write_text(
"from pathlib import Path\nPath('untrusted-code-ran').write_text('bypassed')\n"
)
self.git("add", ".")
self.git("commit", "-m", "Unreviewed tag with false validator")
self.bad = self.git("rev-parse", "HEAD")
self.git("tag", "v9.9.9")
self.git("checkout", "main")
def tearDown(self):
self.temporary.cleanup()
def git(self, *args):
return subprocess.check_output(["git", *args], cwd=self.directory,
env=self.environment, text=True, stderr=subprocess.DEVNULL).strip()
def run_gate(self, workflow, tag):
output = self.directory / (workflow.stem + "-output")
output.unlink(missing_ok=True)
result = subprocess.run([gate_bash(), "--noprofile", "--norc", "-c", workflow_gate(workflow)], cwd=self.directory,
env=dict(self.environment, RELEASE_TAG=tag, GITHUB_OUTPUT=str(output)),
capture_output=True, text=True, check=False)
return result, output.read_text() if output.exists() else ""
def test_windows_gate_uses_git_bash_even_when_path_contains_wsl_launcher(self):
git_root = self.directory / "Git with spaces"
bash = git_root / "bin/bash.exe"
bash.parent.mkdir(parents=True)
bash.touch()
wsl = self.directory / "System32/bash.exe"
wsl.parent.mkdir()
wsl.touch()
for layout in ("bin", "cmd", "mingw64/bin"):
with self.subTest(layout=layout):
git = git_root / layout / "git.exe"
git.parent.mkdir(parents=True, exist_ok=True)
git.touch()
with patch.object(shutil, "which", side_effect=lambda name: str(git if name == "git" else wsl)) as lookup:
self.assertEqual(gate_bash(windows=True), str(bash.resolve()))
lookup.assert_called_once_with("git")
def test_windows_gate_never_falls_back_to_unrelated_bash(self):
git = self.directory / "Git/cmd/git.exe"
git.parent.mkdir(parents=True)
git.touch()
wsl = self.directory / "System32/bash.exe"
wsl.parent.mkdir()
wsl.touch()
with patch.object(shutil, "which", side_effect=lambda name: str(git if name == "git" else wsl)) as lookup:
with self.assertRaisesRegex(RuntimeError, "Git for Windows Bash"):
gate_bash(windows=True)
lookup.assert_called_once_with("git")
def test_main_tag_emits_immutable_commit(self):
for workflow in WORKFLOWS:
with self.subTest(workflow=workflow.name):
result, output = self.run_gate(workflow, "v0.2.0")
self.assertEqual(result.returncode, 0, gate_diagnostic(result))
self.assertEqual(output, f"commit={self.good}\n")
def test_unreviewed_tag_cannot_replace_its_own_ancestry_validator(self):
for workflow in WORKFLOWS:
with self.subTest(workflow=workflow.name):
result, output = self.run_gate(workflow, "v9.9.9")
self.assertNotEqual(result.returncode, 0, gate_diagnostic(result))
self.assertEqual(output, "")
self.assertFalse((self.directory / "untrusted-code-ran").exists())
def test_missing_or_retargeted_tag_cannot_change_validated_source(self):
for workflow in WORKFLOWS:
with self.subTest(workflow=workflow.name):
result, output = self.run_gate(workflow, "v8.8.8")
self.assertNotEqual(result.returncode, 0, gate_diagnostic(result))
self.assertEqual(output, "")
self.git("tag", "-f", "v0.2.0", self.good)
result, output = self.run_gate(workflow, "v0.2.0")
self.assertEqual(result.returncode, 0, gate_diagnostic(result))
self.git("tag", "-f", "v0.2.0", self.bad)
self.assertEqual(output, f"commit={self.good}\n")
result, fresh_output = self.run_gate(workflow, "v0.2.0")
self.assertNotEqual(result.returncode, 0, gate_diagnostic(result))
self.assertEqual(fresh_output, "")
def test_workflows_execute_candidate_code_only_after_gate_and_checkout_sha(self):
for workflow in WORKFLOWS:
with self.subTest(workflow=workflow.name):
text = workflow.read_text()
preflight = text.split(" preflight:\n", 1)[1].split("\n build:\n", 1)[0].split("\n publish:\n", 1)[0]
before, after = preflight.split(GATE, 1)
self.assertEqual(before.count("uses: actions/checkout@v4"), 1)
self.assertIn("fetch-depth: 0", before)
self.assertNotIn("ref:", before) # Initial checkout is trusted workflow main.
self.assertIn("release.version_tag", before)
self.assertLess(after.index("git merge-base --is-ancestor"), after.index("uses: actions/checkout@v4"))
self.assertIn("ref: ${{ steps.source.outputs.commit }}", after)
self.assertNotIn("ref: ${{ inputs.tag }}", text)
downstream = text[len(text.split(" preflight:\n", 1)[0]) + len(" preflight:\n") + len(preflight):]
self.assertGreater(downstream.count("ref: ${{ needs.preflight.outputs.commit }}"), 0)
self.assertEqual(downstream.count("uses: actions/checkout@v4"), downstream.count("ref: ${{ needs.preflight.outputs.commit }}"))
self.assertEqual(text.count("uses: actions/checkout@v4"), text.count("persist-credentials: false"))
def test_write_token_is_only_exposed_to_explicit_final_publish_step(self):
text = WORKFLOWS[1].read_text().split("\n publish:\n", 1)[1]
job_environment = re.search(r"(?m)^ env:\n((?: .*\n)+)", text)
self.assertIsNotNone(job_environment)
self.assertNotIn("GH_TOKEN", job_environment.group(1))
before, final = text.split(" - name: Re-download, verify signatures/metadata/assets and explicitly publish\n", 1)
self.assertNotIn("GH_TOKEN", before)
self.assertIn("GH_TOKEN: ${{ github.token }}", final)
if __name__ == "__main__":
unittest.main()
+228
View File
@@ -0,0 +1,228 @@
#!/usr/bin/env python3
"""Explicit workflow-only GitHub release operations, with fail-closed gates."""
import argparse
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
import release
ENVIRONMENTS = {"launcher-release", "launcher-release-publish"}
def gh(*args, missing=False):
result = subprocess.run(["gh", *args], capture_output=True, text=True, timeout=900, check=False)
if result.returncode:
try:
body = json.loads(result.stdout)
except ValueError:
body = {}
if missing and str(body.get("status")) == "404":
return None
raise ValueError("GitHub operation failed; no permission or validation bypass is allowed")
return result.stdout
def api(path, missing=False):
value = gh("api", f"repos/{release.REPOSITORY}/{path}", missing=missing)
return None if value is None else json.loads(value)
def validate_environment(data):
release.require(isinstance(data, dict), "protected environment is absent")
policy = data.get("deployment_branch_policy") or {}
release.require(
policy.get("protected_branches") is True and policy.get("custom_branch_policies") is False,
"release environment must allow protected branches only",
)
rules = data.get("protection_rules") or []
reviewers = next((rule for rule in rules if rule.get("type") == "required_reviewers"), {})
release.require(
reviewers.get("prevent_self_review") is True, "environment must prevent self review"
)
allowed = reviewers.get("reviewers") or []
release.require(
any(
item.get("type") in {"User", "Team"}
and type((item.get("reviewer") or {}).get("id")) is int
and item["reviewer"]["id"] > 0
for item in allowed
),
"release environment requires an independent reviewer",
)
def workflow_guard():
release.require(
os.environ.get("GITHUB_REPOSITORY") == release.REPOSITORY, "wrong release repository"
)
release.require(
os.environ.get("GITHUB_EVENT_NAME") == "workflow_dispatch",
"release must be dispatched manually",
)
release.require(
os.environ.get("GITHUB_REF") == "refs/heads/main", "release workflow must run from main"
)
def gate(environment):
workflow_guard()
release.require(environment in ENVIRONMENTS, "unexpected release environment")
release.require(
api("branches/main").get("protected") is True, "main must be a protected branch"
)
validate_environment(api(f"environments/{environment}", missing=True))
# Jobs consume this output only after validation. Never reference a missing
# environment directly: GitHub would create it without protection rules.
if os.environ.get("GITHUB_OUTPUT"):
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as stream:
stream.write(f"environment={environment}\n")
def asset_snapshot(data):
release.require(
data.get("draft") is True and data.get("prerelease") is False,
"expected a stable DRAFT release",
)
assets = data.get("assets") or []
result = {}
for asset in assets:
name = asset["name"]
release.require(
name not in result and asset.get("state") == "uploaded",
"duplicate or incomplete release asset",
)
result[name] = (asset["id"], asset["size"], asset.get("digest"))
return result
def verify_draft(root, version, tag):
release.version_tag(version, tag)
before = api(f"releases/tags/{tag}")
release.require(before.get("tag_name") == tag, "draft tag mismatch")
snapshot = asset_snapshot(before)
packages = release.expected_names(version)
expected = packages | {name + ".sig" for name in packages} | {"latest.json", "latest.json.sig"}
release.require(set(snapshot) == expected, "draft asset set is incomplete or unexpected")
# Reject remote names and declared sizes before gh writes any asset locally.
# This is only a pre-download bound; signatures and actual bytes are still
# checked below, followed by the remote identity/race check.
for name, (_, size, _) in snapshot.items():
limit = (
32 * 1024 if name == "latest.json"
else 8 * 1024 if name.endswith(".sig")
else release.MAX_SIZE
)
release.require(type(size) is int and 0 < size <= limit, "invalid remote draft asset size")
with tempfile.TemporaryDirectory(prefix="shacraft-draft-check-") as temporary:
directory = Path(temporary)
gh("release", "download", tag, "--repo", release.REPOSITORY, "--dir", str(directory))
release.verify_release(root, directory, version, tag)
release.require(
set(snapshot) == {p.name for p in directory.iterdir()},
"draft assets changed during download",
)
for path in directory.iterdir():
release.require(snapshot[path.name][1] == path.stat().st_size, "draft size mismatch")
after = api(f"releases/tags/{tag}")
release.require(
after["id"] == before["id"] and asset_snapshot(after) == snapshot,
"draft changed during verification",
)
return after
def draft(root, directory, version, tag):
workflow_guard()
release.preflight(root, version, tag, check_git=True, signing=True)
release.verify_release(root, directory, version, tag)
with tempfile.TemporaryDirectory(prefix="shacraft-release-notes-") as temporary:
notes = Path(temporary) / "notes.md"
release_notes = release.read_json(directory / "latest.json")["notes"]
instructions = (
f"https://github.com/{release.REPOSITORY}/blob/{tag}/docs/updater-release.md"
"#installed-package-migration-and-recovery"
)
notes.write_text(
release_notes + "\n\n" +
"[Установка, переход с 0.1.1 и восстановление](" + instructions + ").\n" +
"Используйте прежний тип пакета. Обновление .deb выполняется через менеджер пакетов.\n",
encoding="utf-8",
)
gh(
"release",
"create",
tag,
*[str(path) for path in sorted(directory.iterdir())],
"--repo",
release.REPOSITORY,
"--draft",
"--verify-tag",
"--title",
f"ShaCraft Launcher {version}",
"--notes-file",
str(notes),
)
verify_draft(root, version, tag)
print(
"Complete draft uploaded and re-verified. Publication requires the separate protected workflow."
)
def publish(root, version, tag, confirmation):
workflow_guard()
release.require(
confirmation == f"publish {tag}", "explicit publication confirmation does not match tag"
)
release.preflight(root, version, tag, check_git=True)
pinned = (root / "src-tauri/updater-public-key.txt").read_text().strip()
release.require(
release.public_key() == pinned, "publication key differs from committed updater key"
)
gate("launcher-release-publish") # Re-check protection immediately before publication.
current = api("releases/latest", missing=True)
if current is not None:
old_tag = current.get("tag_name", "")
release.version_tag(old_tag.removeprefix("v"), old_tag)
release.require(
tuple(map(int, version.split("."))) > tuple(map(int, old_tag[1:].split("."))),
"publication must advance the stable release version",
)
verify_draft(root, version, tag)
gh("release", "edit", tag, "--repo", release.REPOSITORY, "--draft=false", "--latest")
print("Verified release published by explicit protected operator workflow.")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1])
sub = parser.add_subparsers(dest="command", required=True)
item = sub.add_parser("gate")
item.add_argument("environment", choices=sorted(ENVIRONMENTS))
for name in ("draft", "publish"):
item = sub.add_parser(name)
item.add_argument("--version", required=True)
item.add_argument("--tag", required=True)
if name == "draft":
item.add_argument("--directory", required=True, type=Path)
else:
item.add_argument("--confirmation", required=True)
args = parser.parse_args()
if args.command == "gate":
gate(args.environment)
elif args.command == "draft":
draft(args.root.resolve(), args.directory, args.version, args.tag)
else:
publish(args.root.resolve(), args.version, args.tag, args.confirmation)
if __name__ == "__main__":
try:
main()
except (ValueError, OSError, subprocess.SubprocessError, KeyError, TypeError) as error:
print(f"Release stopped: {error}", file=sys.stderr)
sys.exit(1)
+14
View File
@@ -0,0 +1,14 @@
param([Parameter(Mandatory=$true)][string]$PackagePath)
$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
# MSIDBOPEN_READONLY = 0. Reading COM properties never installs the package.
$installer = New-Object -ComObject WindowsInstaller.Installer
$summary = $installer.GetType().InvokeMember('SummaryInformation', 'GetProperty', $null, $installer, @($PackagePath, 0))
$template = $summary.GetType().InvokeMember('Property', 'GetProperty', $null, $summary, @(7))
$database = $installer.GetType().InvokeMember('OpenDatabase', 'InvokeMethod', $null, $installer, @($PackagePath, 0))
$view = $database.GetType().InvokeMember('OpenView', 'InvokeMethod', $null, $database, @('SELECT `Value` FROM `Property` WHERE `Property` = ''ProductVersion'''))
$view.GetType().InvokeMember('Execute', 'InvokeMethod', $null, $view, $null) | Out-Null
$record = $view.GetType().InvokeMember('Fetch', 'InvokeMethod', $null, $view, $null)
if ($null -eq $record) { throw 'Missing MSI ProductVersion' }
$version = $record.GetType().InvokeMember('StringData', 'GetProperty', $null, $record, @(1))
@{ template = [string]$template; version = [string]$version } | ConvertTo-Json -Compress
+531
View File
@@ -0,0 +1,531 @@
"""Real Tauri signature checks using disposable keys, never release credentials."""
import io
import json
import os
import plistlib
import shutil
import struct
import subprocess
import tarfile
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
import release
from release_assets_test import DraftAssetGateTests # noqa: F401; unittest discovery
from release_gate_test import ReleaseGateTests # noqa: F401; unittest discovery
import release_formats
import release_github
ROOT = Path(os.environ.get("RELEASE_TEST_ROOT", Path(__file__).resolve().parents[1]))
def fixture_pe(machine=0x8664, magic=0x20B):
data = bytearray(256)
data[:2] = b"MZ"
struct.pack_into("<I", data, 60, 128)
data[128:132] = b"PE\0\0"
struct.pack_into("<H", data, 132, machine)
struct.pack_into("<H", data, 152, magic)
marker = b"__TAURI_BUNDLE_TYPE_VAR_UNK"
data[200 : 200 + len(marker)] = marker
return bytes(data)
def fixture_tar(files):
stream = io.BytesIO()
with tarfile.open(fileobj=stream, mode="w:gz") as archive:
for name, data in files.items():
info = tarfile.TarInfo(name)
info.size = len(data)
archive.addfile(info, io.BytesIO(data))
return stream.getvalue()
def fixture_deb(architecture="amd64", version="0.3.0"):
control = fixture_tar(
{
"./control": f"Package: shacraft-launcher\nVersion: {version}\nArchitecture: {architecture}\n".encode()
}
)
result = bytearray(b"!<arch>\n")
for name, data in [
("debian-binary", b"2.0\n"),
("control.tar.gz", control),
("data.tar.gz", fixture_tar({})),
]:
header = f"{name + '/':<16}{0:<12}{0:<6}{0:<6}{100644:<8}{len(data):<10}`\n".encode()
assert len(header) == 60
result.extend(header + data + (b"\n" if len(data) % 2 else b""))
return bytes(result)
def fixture_package(name, version="0.3.0"):
if name.endswith(".app.tar.gz"):
cpu = 0x0100000C if "aarch64" in name else 0x01000007
executable = struct.pack("<IIIIIIII", 0xFEEDFACF, cpu, 0, 2, 0, 0, 0, 0)
return fixture_tar(
{
"ShaCraft.app/Contents/Info.plist": plistlib.dumps(
{
"CFBundleExecutable": "shacraft-launcher",
"CFBundleShortVersionString": version,
}
),
"ShaCraft.app/Contents/MacOS/shacraft-launcher": executable,
}
)
if name.endswith(".AppImage"):
data = bytearray(64)
data[:6] = b"\x7fELF\x02\x01"
data[8:11] = b"AI\x02"
struct.pack_into("<H", data, 18, 62)
return bytes(data)
if name.endswith(".deb"):
return fixture_deb(version=version)
if name.endswith(".dmg"):
return b"koly" + bytes(508)
if name.endswith(".msi"):
data = bytearray(512)
data[:8] = bytes.fromhex("d0cf11e0a1b11ae1")
data[28:30] = b"\xfe\xff"
return bytes(data)
return fixture_pe(0x14C, 0x10B) # x86 NSIS wrapper is valid for an x64 payload.
class ReleaseTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.temporary = tempfile.TemporaryDirectory(prefix="shacraft-release-tests-")
cls.base = Path(cls.temporary.name)
# A new unencrypted disposable key per test process. No static private
# key or credentials are stored in the repository or test output.
cls.key = cls.base / "test-only.key"
environment = dict(os.environ)
for name in (
"TAURI_SIGNING_PRIVATE_KEY",
"TAURI_SIGNING_PRIVATE_KEY_PATH",
"TAURI_SIGNING_PRIVATE_KEY_PASSWORD",
):
environment.pop(name, None)
result = subprocess.run(
[
"node",
str(ROOT / "node_modules/@tauri-apps/cli/tauri.js"),
"signer",
"generate",
"--ci",
"--password",
"",
"--write-keys",
str(cls.key),
],
env=environment,
capture_output=True,
timeout=120,
check=False,
)
if result.returncode:
raise RuntimeError("disposable Tauri signer setup failed")
cls.key.chmod(0o600)
cls.key_values = {
"SHACRAFT_UPDATER_PUBLIC_KEY": Path(str(cls.key) + ".pub").read_text().strip(),
"TAURI_SIGNING_PRIVATE_KEY": cls.key.read_text().strip(),
"TAURI_SIGNING_PRIVATE_KEY_PASSWORD": "",
"SHACRAFT_UPDATER_TEST_BUILD": "0",
}
cls.originals = cls.base / "complete"
cls.originals.mkdir()
with patch.dict(os.environ, cls.key_values):
for name in release.expected_names("0.3.0"):
path = cls.originals / name
path.write_bytes(fixture_package(name))
release.signer(ROOT, path)
data = release.metadata(
ROOT, cls.originals, "0.3.0", "v0.3.0", "Test only", "2026-09-09T00:00:00Z"
)
(cls.originals / "latest.json").write_bytes(release.canonical(data))
release.signer(ROOT, cls.originals / "latest.json")
@classmethod
def tearDownClass(cls):
cls.temporary.cleanup()
def setUp(self):
self.case = tempfile.TemporaryDirectory(dir=self.base)
self.directory = Path(self.case.name) / "assets"
shutil.copytree(self.originals, self.directory)
self.environment = patch.dict(os.environ, self.key_values)
self.environment.start()
def tearDown(self):
self.environment.stop()
self.case.cleanup()
def verify(self):
release.verify_release(ROOT, self.directory, "0.3.0", "v0.3.0")
def resign_metadata(self, mutate):
path = self.directory / "latest.json"
data = json.loads(path.read_bytes())
mutate(data)
path.write_bytes(release.canonical(data))
release.signer(ROOT, path)
def test_complete_release_and_metadata_generation_are_deterministic(self):
self.verify()
first = release.metadata(
ROOT, self.directory, "0.3.0", "v0.3.0", "Test only", "2026-09-09T00:00:00Z"
)
self.assertEqual(release.canonical(first), (self.directory / "latest.json").read_bytes())
def test_package_bit_flip_fails_actual_plugin_verification(self):
path = self.directory / release.filename("0.3.0", "linux-x86_64", ".AppImage")
path.write_bytes(path.read_bytes() + b"tampered")
with self.assertRaisesRegex(ValueError, "invalid updater signature"):
self.verify()
def test_metadata_substitution_is_rejected_before_json_parsing(self):
(self.directory / "latest.json").write_bytes(b"not even JSON")
with self.assertRaisesRegex(ValueError, "invalid updater signature"):
self.verify()
def test_valid_signature_for_other_artifact_does_not_suffice(self):
linux = release.filename("0.3.0", "linux-x86_64", ".AppImage")
windows = release.filename("0.3.0", "windows-x86_64", "-setup.exe")
shutil.copyfile(self.directory / (windows + ".sig"), self.directory / (linux + ".sig"))
with self.assertRaisesRegex(ValueError, "invalid updater signature"):
self.verify()
def test_wrong_public_key_fails_real_verifier(self):
encoded = release.public_key()
import base64
lines = base64.b64decode(encoded).decode().splitlines()
raw = bytearray(base64.b64decode(lines[1]))
raw[-1] ^= 1
lines[1] = base64.b64encode(raw).decode()
wrong = base64.b64encode(("\n".join(lines) + "\n").encode()).decode()
with patch.dict(os.environ, {"SHACRAFT_UPDATER_PUBLIC_KEY": wrong}):
with self.assertRaisesRegex(ValueError, "invalid updater signature"):
self.verify()
def test_all_platforms_and_manual_packages_are_mandatory(self):
for name in sorted(release.expected_names("0.3.0")):
path = self.directory / (name + ".sig")
original = path.read_bytes()
path.unlink()
with self.subTest(name=name), self.assertRaisesRegex(ValueError, "asset set"):
self.verify()
path.write_bytes(original)
def test_extra_ci_marker_or_file_blocks_promotion(self):
(self.directory / "CI_NOT_FOR_RELEASE.txt").write_text("test key")
with self.assertRaisesRegex(ValueError, "asset set"):
self.verify()
def test_signed_metadata_must_match_tag_and_exact_source_assets(self):
alterations = [
lambda x: x.update(version="0.2.0"),
lambda x: x.update(tag="v0.4.0"),
lambda x: x["platforms"].pop("darwin-aarch64"),
lambda x: x["platforms"].update({"linux-aarch64": x["platforms"]["linux-x86_64"]}),
lambda x: x["manualPackages"].pop("windows-x86_64-msi"),
lambda x: x["platforms"]["linux-x86_64"].update(url="https://evil.invalid/package"),
lambda x: x["platforms"]["linux-x86_64"].update(
url=x["platforms"]["linux-x86_64"]["url"].replace("v0.3.0", "v0.2.0")
),
lambda x: x["platforms"]["linux-x86_64"].update(sha256="0" * 64),
lambda x: x["platforms"]["linux-x86_64"].update(size=True),
lambda x: x.update(pub_date="2026-09-09T00:00:00+03:00"),
lambda x: x.update(extra="not allowed"),
]
for index, alter in enumerate(alterations):
shutil.copyfile(self.originals / "latest.json", self.directory / "latest.json")
self.resign_metadata(alter)
with self.subTest(index=index), self.assertRaises(ValueError):
self.verify()
def test_duplicate_json_fields_are_rejected_even_if_signed(self):
path = self.directory / "latest.json"
path.write_bytes(
path.read_bytes().replace(
b'"schemaVersion": 1,', b'"schemaVersion": 1, "schemaVersion": 1,'
)
)
release.signer(ROOT, path)
with self.assertRaisesRegex(ValueError, "duplicate JSON"):
self.verify()
def test_collect_requires_generated_updater_sig_and_signs_manual_deb(self):
bundle = Path(self.case.name) / "bundle"
(bundle / "appimage").mkdir(parents=True)
(bundle / "deb").mkdir()
app = bundle / "appimage/Test.AppImage"
app.write_bytes(fixture_package("Test.AppImage"))
(bundle / "deb/Test.deb").write_bytes(fixture_package("Test.deb"))
output = Path(self.case.name) / "collected"
with self.assertRaisesRegex(ValueError, "missing generated updater signature"):
release.collect(ROOT, bundle, output, "linux-x86_64", "0.3.0")
shutil.rmtree(output)
release.signer(ROOT, app)
release.collect(ROOT, bundle, output, "linux-x86_64", "0.3.0")
self.assertEqual(len(list(output.iterdir())), 4)
for suffix in (".AppImage", ".deb"):
data = output / release.filename("0.3.0", "linux-x86_64", suffix)
release.verify_signature(ROOT, data, Path(str(data) + ".sig"))
def test_missing_key_fails_and_optional_password_never_prompts(self):
data = self.directory / "latest.json"
with patch.dict(os.environ, {"TAURI_SIGNING_PRIVATE_KEY": ""}):
with self.assertRaisesRegex(ValueError, "PRIVATE_KEY is missing"):
release.signer(ROOT, data)
with patch.dict(os.environ):
os.environ.pop("TAURI_SIGNING_PRIVATE_KEY_PASSWORD", None)
release.signer(ROOT, data)
release.verify_signature(ROOT, data, Path(str(data) + ".sig"))
def test_release_preflight_rejects_placeholder_mismatch_and_test_mode(self):
source = Path(self.case.name) / "source"
(source / "src-tauri").mkdir(parents=True)
(source / "package.json").write_text('{"version":"0.3.0"}')
(source / "package-lock.json").write_text(
'{"version":"0.3.0","packages":{"":{"version":"0.3.0"}}}'
)
(source / "src-tauri/tauri.conf.json").write_text('{"version":"0.3.0"}')
(source / "src-tauri/Cargo.toml").write_text('[package]\nversion="0.3.0"\n')
(source / "src-tauri/updater-public-key.txt").write_text("UNCONFIGURED")
with self.assertRaisesRegex(ValueError, "differs from committed"):
release.preflight(source, "0.3.0", "v0.3.0", signing=True)
with patch.dict(os.environ, {"SHACRAFT_UPDATER_TEST_BUILD": "1"}):
with self.assertRaisesRegex(ValueError, "test keys cannot release"):
release.preflight(source, "0.3.0", "v0.3.0", signing=True)
(source / "package.json").write_text('{"version":"0.3.1"}')
with self.assertRaisesRegex(ValueError, "source versions disagree"):
release.preflight(source, "0.3.0", "v0.3.0")
class PackageFormatTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory(prefix="shacraft-format-tests-")
self.root = Path(self.temporary.name)
def tearDown(self):
self.temporary.cleanup()
def package(self, name, data=None):
path = self.root / name
path.write_bytes(fixture_package(name) if data is None else data)
return path
def test_mac_archive_binds_main_executable_cpu_and_version(self):
path = self.package("darwin-aarch64.app.tar.gz")
release_formats.mac_app(path, "darwin-aarch64", "0.3.0")
for platform, version in [("darwin-x86_64", "0.3.0"), ("darwin-aarch64", "0.2.0")]:
with self.subTest(platform=platform, version=version), self.assertRaises(ValueError):
release_formats.mac_app(path, platform, version)
def test_mac_archive_rejects_traversal_and_missing_binary(self):
for files in (
{"../Bad.app/Contents/Info.plist": b"bad"},
{
"ShaCraft.app/Contents/Info.plist": plistlib.dumps(
{"CFBundleExecutable": "missing", "CFBundleShortVersionString": "0.3.0"}
)
},
):
path = self.package("darwin-aarch64.app.tar.gz", fixture_tar(files))
with self.assertRaises(ValueError):
release_formats.mac_app(path, "darwin-aarch64", "0.3.0")
def test_appimage_rejects_arm64_wrong_class_and_type1(self):
path = self.package("linux-x86_64.AppImage")
release_formats.appimage(path)
for offset, data in [(18, struct.pack("<H", 183)), (4, b"\x01"), (10, b"\x01")]:
bad = bytearray(fixture_package(path.name))
bad[offset : offset + len(data)] = data
path.write_bytes(bad)
with self.assertRaises(ValueError):
release_formats.appimage(path)
def test_deb_control_binds_architecture_and_version(self):
path = self.package("linux-x86_64.deb")
release_formats.deb(path, "0.3.0")
for architecture, version in [("arm64", "0.3.0"), ("amd64", "0.2.0")]:
path.write_bytes(fixture_deb(architecture, version))
with self.assertRaises(ValueError):
release_formats.deb(path, "0.3.0")
def test_nsis_stub_may_be_x86_but_extracted_main_must_equal_x64_build(self):
wrapper = self.package("setup.exe")
main = self.package("shacraft-launcher.exe", fixture_pe())
listing = subprocess.CompletedProcess(
[], 0, b"Path = setup.exe\nPath = $INSTDIR/shacraft-launcher.exe\n", b""
)
for extracted, success in [
(release_formats.expected_nsis_payload(fixture_pe()), True),
(fixture_pe(), False),
(release_formats.expected_nsis_payload(fixture_pe(0xAA64, 0x20B)), False),
(release_formats.expected_nsis_payload(fixture_pe()) + b"different", False),
]:
with (
patch.object(release_formats.shutil, "which", return_value=str(main)),
patch.object(
release_formats.subprocess,
"run",
side_effect=[listing, subprocess.CompletedProcess([], 0, extracted, b"")],
),
):
if success:
release_formats.windows(wrapper, "-setup.exe", "0.3.0", main)
else:
with self.assertRaises(ValueError):
release_formats.windows(wrapper, "-setup.exe", "0.3.0", main)
def test_nsis_identity_allows_only_the_actual_first_bundle_marker_patch(self):
original = fixture_pe() + b"__TAURI_BUNDLE_TYPE_VAR_UNK"
expected = release_formats.expected_nsis_payload(original)
self.assertEqual(
expected,
original.replace(b"__TAURI_BUNDLE_TYPE_VAR_UNK", b"__TAURI_BUNDLE_TYPE_VAR_NSS", 1),
)
self.assertEqual(expected.count(b"__TAURI_BUNDLE_TYPE_VAR_UNK"), 1)
with self.assertRaisesRegex(ValueError, "bundle-type marker"):
release_formats.expected_nsis_payload(
fixture_pe().replace(b"__TAURI_BUNDLE_TYPE_VAR_UNK", b"__TAURI_BUNDLE_TYPE_VAR_MSI")
)
# No certificate table/checksum/other byte range is ignored.
mutated = bytearray(expected)
mutated[190] ^= 1
self.assertNotEqual(bytes(mutated), release_formats.expected_nsis_payload(original))
self.assertNotEqual(
expected.replace(b"_VAR_NSS", b"_VAR_MSI"),
release_formats.expected_nsis_payload(original),
)
def test_msi_readonly_metadata_requires_x64_and_version(self):
msi = self.package("setup.msi")
main = self.package("shacraft-launcher.exe", fixture_pe())
for architecture, version, success in [
("x64;1033", "0.3.0", True),
("Intel;1033", "0.3.0", False),
("x64;1033", "0.2.0", False),
]:
result = subprocess.CompletedProcess(
[], 0, json.dumps({"template": architecture, "version": version}).encode(), b""
)
with patch.object(release_formats.subprocess, "run", return_value=result) as command:
if success:
release_formats.windows(msi, ".msi", "0.3.0", main)
else:
with self.assertRaises(ValueError):
release_formats.windows(msi, ".msi", "0.3.0", main)
self.assertIn("release_msi.ps1", command.call_args.args[0][4])
class WorkflowPolicyTests(unittest.TestCase):
def protected(self):
return {
"deployment_branch_policy": {
"protected_branches": True,
"custom_branch_policies": False,
},
"protection_rules": [
{
"type": "required_reviewers",
"prevent_self_review": True,
"reviewers": [{"type": "User", "reviewer": {"id": 123}}],
}
],
}
def test_protected_environment_requires_existing_independent_review(self):
release_github.validate_environment(self.protected())
for data in (
None,
{},
{"deployment_branch_policy": {}},
dict(self.protected(), protection_rules=[]),
):
with self.subTest(data=data), self.assertRaises(ValueError):
release_github.validate_environment(data)
for mutate in (
lambda d: d["deployment_branch_policy"].update(protected_branches=False),
lambda d: d["protection_rules"][0].update(prevent_self_review=False),
lambda d: d["protection_rules"][0].update(reviewers=[]),
):
data = self.protected()
mutate(data)
with self.assertRaises(ValueError):
release_github.validate_environment(data)
def test_gate_does_not_emit_missing_environment_name(self):
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "output"
env = {
"GITHUB_REPOSITORY": release.REPOSITORY,
"GITHUB_EVENT_NAME": "workflow_dispatch",
"GITHUB_REF": "refs/heads/main",
"GITHUB_OUTPUT": str(output),
}
with (
patch.dict(os.environ, env),
patch.object(release_github, "api", side_effect=[{"protected": True}, None]),
):
with self.assertRaisesRegex(ValueError, "absent"):
release_github.gate("launcher-release")
self.assertFalse(output.exists())
def test_publication_never_runs_without_exact_confirmation(self):
env = {
"GITHUB_REPOSITORY": release.REPOSITORY,
"GITHUB_EVENT_NAME": "workflow_dispatch",
"GITHUB_REF": "refs/heads/main",
}
with patch.dict(os.environ, env), patch.object(release_github, "gh") as client:
with self.assertRaisesRegex(ValueError, "confirmation"):
release_github.publish(ROOT, "0.3.0", "v0.3.0", "publish v0.2.0")
client.assert_not_called()
def test_asset_snapshot_rejects_public_or_incomplete_draft(self):
data = {
"draft": True,
"prerelease": False,
"assets": [
{
"id": 1,
"name": "latest.json",
"size": 10,
"state": "uploaded",
"digest": "sha256:abc",
}
],
}
self.assertEqual(release_github.asset_snapshot(data)["latest.json"][0], 1)
for change in (
{"draft": False},
{"prerelease": True},
{"assets": [dict(data["assets"][0], state="new")]},
{"assets": data["assets"] * 2},
):
with self.assertRaises(ValueError):
release_github.asset_snapshot(dict(data, **change))
def test_version_and_tag_cannot_inject_paths_or_exceed_msi_limits(self):
release.version_tag("0.3.0", "v0.3.0")
for version, tag in (
("0.3.0", "main"),
("0.3.0", "v0.3.0/evil"),
("01.3.0", "v01.3.0"),
("0.3.0-beta", "v0.3.0-beta"),
("256.0.0", "v256.0.0"),
("0.0.65536", "v0.0.65536"),
):
with self.subTest(version=version, tag=tag), self.assertRaises(ValueError):
release.version_tag(version, tag)
if __name__ == "__main__":
unittest.main()
+413 -18
View File
@@ -1770,6 +1770,36 @@ dependencies = [
"windows-sys 0.45.0",
]
[[package]]
name = "jni"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
dependencies = [
"cfg-if",
"combine",
"jni-macros",
"jni-sys 0.4.1",
"log",
"simd_cesu8",
"thiserror 2.0.20",
"walkdir",
"windows-link 0.2.1",
]
[[package]]
name = "jni-macros"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"simd_cesu8",
"syn 2.0.119",
]
[[package]]
name = "jni-sys"
version = "0.3.1"
@@ -1975,6 +2005,12 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2057,6 +2093,15 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "ntapi"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
dependencies = [
"winapi",
]
[[package]]
name = "num-conv"
version = "0.2.2"
@@ -2217,10 +2262,21 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.13.1",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
[[package]]
name = "objc2-io-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
dependencies = [
"libc",
"objc2-core-foundation",
]
[[package]]
name = "objc2-io-surface"
version = "0.3.2"
@@ -2232,6 +2288,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 +2363,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 +2905,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 +2986,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 +3008,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 +3067,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 +3133,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,20 +3380,26 @@ dependencies = [
[[package]]
name = "shacraft-launcher"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"base64 0.22.1",
"ed25519-dalek",
"flate2",
"md-5",
"minisign-verify",
"reqwest 0.12.28",
"reqwest 0.13.4",
"semver",
"serde",
"serde_json",
"sha1",
"sha2",
"sysinfo",
"tar",
"tauri",
"tauri-build",
"tauri-plugin-updater",
"time",
"url",
"zip",
]
@@ -3255,6 +3425,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"
@@ -3446,6 +3632,20 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "sysinfo"
version = "0.39.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2071df9448915b71c4fe6d25deaf1c22f12bd234f01540b77312bb8e41361e6"
dependencies = [
"libc",
"memchr",
"ntapi",
"objc2-core-foundation",
"objc2-io-kit",
"windows 0.62.2",
]
[[package]]
name = "system-deps"
version = "6.2.2"
@@ -3477,7 +3677,7 @@ dependencies = [
"gdkwayland-sys",
"gdkx11-sys",
"gtk",
"jni",
"jni 0.21.1",
"libc",
"log",
"ndk",
@@ -3493,7 +3693,7 @@ dependencies = [
"tao-macros",
"unicode-segmentation",
"url",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-version",
"x11-dl",
@@ -3544,7 +3744,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"jni",
"jni 0.21.1",
"libc",
"log",
"mime",
@@ -3575,7 +3775,7 @@ dependencies = [
"webkit2gtk",
"webview2-com",
"window-vibrancy",
"windows",
"windows 0.61.3",
]
[[package]]
@@ -3640,6 +3840,54 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b28d8cabdeb0564f03ae261963de4bc3d98321cd3d213e76a81b7d344e5df606"
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",
]
[[package]]
name = "tauri-runtime"
version = "2.11.3"
@@ -3650,7 +3898,7 @@ dependencies = [
"dpi",
"gtk",
"http",
"jni",
"jni 0.21.1",
"objc2",
"objc2-ui-kit",
"objc2-web-kit",
@@ -3662,7 +3910,7 @@ dependencies = [
"url",
"webkit2gtk",
"webview2-com",
"windows",
"windows 0.61.3",
]
[[package]]
@@ -3673,7 +3921,7 @@ checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f"
dependencies = [
"gtk",
"http",
"jni",
"jni 0.21.1",
"log",
"objc2",
"objc2-app-kit",
@@ -3687,7 +3935,7 @@ dependencies = [
"url",
"webkit2gtk",
"webview2-com",
"windows",
"windows 0.61.3",
"wry",
]
@@ -3740,6 +3988,19 @@ dependencies = [
"toml 1.1.5+spec-1.1.0",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "tendril"
version = "0.5.1"
@@ -4417,6 +4678,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"
@@ -4434,7 +4704,7 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
dependencies = [
"webview2-com-macros",
"webview2-com-sys",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-implement",
"windows-interface",
@@ -4458,7 +4728,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
dependencies = [
"thiserror 2.0.20",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
]
@@ -4514,11 +4784,23 @@ version = "0.61.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
dependencies = [
"windows-collections",
"windows-collections 0.2.0",
"windows-core 0.61.2",
"windows-future",
"windows-future 0.2.1",
"windows-link 0.1.3",
"windows-numerics",
"windows-numerics 0.2.0",
]
[[package]]
name = "windows"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
dependencies = [
"windows-collections 0.3.2",
"windows-core 0.62.2",
"windows-future 0.3.2",
"windows-numerics 0.3.1",
]
[[package]]
@@ -4530,6 +4812,15 @@ dependencies = [
"windows-core 0.61.2",
]
[[package]]
name = "windows-collections"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
dependencies = [
"windows-core 0.62.2",
]
[[package]]
name = "windows-core"
version = "0.61.2"
@@ -4564,7 +4855,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
dependencies = [
"windows-core 0.61.2",
"windows-link 0.1.3",
"windows-threading",
"windows-threading 0.1.0",
]
[[package]]
name = "windows-future"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
dependencies = [
"windows-core 0.62.2",
"windows-link 0.2.1",
"windows-threading 0.2.1",
]
[[package]]
@@ -4611,6 +4913,16 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-numerics"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
dependencies = [
"windows-core 0.62.2",
"windows-link 0.2.1",
]
[[package]]
name = "windows-result"
version = "0.3.4"
@@ -4674,6 +4986,15 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -4707,13 +5028,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"
@@ -4723,6 +5061,15 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-threading"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-version"
version = "0.1.7"
@@ -4744,6 +5091,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
@@ -4756,6 +5109,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
@@ -4768,12 +5127,24 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
@@ -4786,6 +5157,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
@@ -4798,6 +5175,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
@@ -4810,6 +5193,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
@@ -4822,6 +5211,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.5.40"
@@ -4886,7 +5281,7 @@ dependencies = [
"gtk",
"http",
"javascriptcore-rs",
"jni",
"jni 0.21.1",
"libc",
"ndk",
"objc2",
@@ -4906,7 +5301,7 @@ dependencies = [
"webkit2gtk",
"webkit2gtk-sys",
"webview2-com",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-version",
"x11-dl",
+8 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "shacraft-launcher"
version = "0.1.1"
version = "0.2.0"
description = "ShaCraft Minecraft launcher"
authors = ["ShaCraft"]
license = "MIT"
@@ -27,3 +27,10 @@ reqwest = { version = "0.12", default-features = false, features = ["blocking",
flate2 = "1"
tar = "0.4"
zip = { version = "2", default-features = false, features = ["deflate"] }
sysinfo = { version = "0.39.6", default-features = false, features = ["system"] }
tauri-plugin-updater = { version = "=2.11.0", default-features = false, features = ["rustls-tls"] }
minisign-verify = "=0.2.5"
semver = "1"
time = { version = "0.3", features = ["parsing", "formatting"] }
reqwest-updater = { package = "reqwest", version = "0.13", default-features = false, features = ["rustls-no-provider"] }
+3
View File
@@ -1,3 +1,6 @@
fn main() {
println!("cargo:rerun-if-env-changed=SHACRAFT_UPDATER_PUBLIC_KEY");
println!("cargo:rerun-if-env-changed=SHACRAFT_UPDATER_TEST_BUILD");
println!("cargo:rerun-if-changed=updater-public-key.txt");
tauri_build::build()
}
+6
View File
@@ -30,8 +30,10 @@ pub(crate) fn start_microsoft_login(
state: State<'_, LauncherOperations>,
) -> Result<(), String> {
let directory = data_dir(&app)?;
let lifecycle = crate::update_guard::begin_operation(&directory, &state)?;
let permit = state.account.acquire("Account operation")?;
tauri::async_runtime::spawn_blocking(move || {
let _lifecycle = lifecycle;
let _permit = permit;
let login = || -> Result<msa::MinecraftProfile, String> {
let client = msa::http_client().map_err(|error| error.to_string())?;
@@ -76,8 +78,10 @@ pub(crate) async fn get_account(
state: State<'_, LauncherOperations>,
) -> Result<Option<msa::MinecraftProfile>, String> {
let data_dir = data_dir(&app)?;
let lifecycle = crate::update_guard::begin_operation(&data_dir, &state)?;
let permit = state.account.acquire("Account operation")?;
tauri::async_runtime::spawn_blocking(move || {
let _lifecycle = lifecycle;
let _permit = permit;
let Some(refresh_token) = msa::load_refresh_token(&data_dir) else {
return Ok(None);
@@ -102,8 +106,10 @@ pub(crate) async fn logout(
state: State<'_, LauncherOperations>,
) -> Result<(), String> {
let data_dir = data_dir(&app)?;
let lifecycle = crate::update_guard::begin_operation(&data_dir, &state)?;
let permit = state.account.acquire("Account operation")?;
tauri::async_runtime::spawn_blocking(move || {
let _lifecycle = lifecycle;
let _permit = permit;
msa::clear_account(&data_dir)
})
+258 -172
View File
@@ -1,7 +1,7 @@
use super::{data_dir, shacraft::resolve_identity};
use super::{data_dir, profiles::ProfileMetadata, shacraft::resolve_identity};
use crate::{
java, launch, manifest, mojang, neoforge, operations::LauncherOperations, remote, runtime,
settings,
installation_lock::InstallationLock, java, launch, manifest, mojang, neoforge,
operations::LauncherOperations, profile, remote, runtime, session, settings, shacraft_account,
};
use reqwest::blocking::Client;
use serde::Serialize;
@@ -15,132 +15,158 @@ pub(crate) struct InstallProgress {
current_bytes: u64,
total_bytes: u64,
}
fn progress(app: &AppHandle, stage: &'static str) -> mojang::ProgressCallback {
let app = app.clone();
Arc::new(move |current, total| {
let _ = app.emit(
"game-install-progress",
InstallProgress {
stage,
current_bytes: current,
total_bytes: total,
},
);
})
}
/// Resolves the vanilla + (if any) loader version JSONs for `manifest` and
/// merges them, ensuring a Java runtime and (for NeoForge profiles) running
/// the installer along the way. Shared by `ensure_game_installed` and
/// `launch_game` so both always agree on exactly what "installed" means.
/// `on_progress` is forwarded to the NeoForge installer when one runs;
/// callers that don't display progress (e.g. `launch_game`, which only
/// hits this after `ensure_game_installed` already installed everything)
/// pass a no-op callback.
fn resolve_merged_version(
client: &Client,
manifest: &manifest::Manifest,
java_executable: &Path,
java: &Path,
game_dir: &Path,
cache_dir: &Path,
on_progress: &mojang::ProgressCallback,
) -> Result<mojang::MergedVersion, String> {
let mojang_manifest =
mojang::fetch_version_manifest(client).map_err(|error| error.to_string())?;
let vanilla_entry = mojang::find_version(&mojang_manifest, &manifest.minecraft.version)
.ok_or_else(|| {
format!(
"Mojang does not list Minecraft version {}",
manifest.minecraft.version
)
})?;
let vanilla =
mojang::fetch_version_json(client, vanilla_entry).map_err(|error| error.to_string())?;
let catalog = mojang::fetch_version_manifest(client).map_err(|e| e.to_string())?;
let entry = mojang::find_version(&catalog, &manifest.minecraft.version)
.ok_or_else(|| format!("Mojang does not list {}", manifest.minecraft.version))?;
let vanilla = mojang::fetch_version_json(client, entry).map_err(|e| e.to_string())?;
// The installer may reuse an existing vanilla JAR without verifying it.
// Establish provider integrity BEFORE any NeoForge processor uses that input.
mojang::ensure_client_jar(
client,
game_dir,
&vanilla.id,
&vanilla
.downloads
.as_ref()
.ok_or("Vanilla client download metadata is missing")?
.client,
)
.map_err(|e| e.to_string())?;
if manifest.minecraft.loader.kind == "neoforge" {
let installer_client = neoforge::http_client().map_err(|error| error.to_string())?;
let neoforge_version = neoforge::ensure_client_installed(
let installer_client = neoforge::http_client().map_err(|e| e.to_string())?;
let loader = neoforge::ensure_client_installed(
&installer_client,
java_executable,
java,
game_dir,
cache_dir,
&manifest.minecraft.loader.version,
&vanilla,
on_progress,
)
.map_err(|error| error.to_string())?;
mojang::merge_versions(&vanilla, Some(&neoforge_version)).map_err(|error| error.to_string())
.map_err(|e| e.to_string())?;
mojang::merge_versions(&vanilla, Some(&loader)).map_err(|e| e.to_string())
} else {
mojang::merge_versions(&vanilla, None).map_err(|error| error.to_string())
mojang::merge_versions(&vanilla, None).map_err(|e| e.to_string())
}
}
/// Downloads and installs everything needed to run `profile_id`: the
/// exact Minecraft/loader version the ShaCraft-signed manifest specifies,
/// a Java runtime if none is already usable, and game assets. Emits
/// `game-install-progress` throughout with real progress for every stage:
/// download bytes for Java, installer-confirmed library/processor counts
/// for NeoForge, and download bytes for libraries/assets.
/// Internal stages receive the same verified snapshot; none can refetch it.
fn prepare(
directory: &Path,
snapshot: &remote::VerifiedSnapshot,
progress: &impl Fn(&'static str) -> mojang::ProgressCallback,
) -> Result<
(
mojang::MergedVersion,
java::JavaInstallation,
profile::ProfileInspection,
),
String,
> {
let manifest = &snapshot.manifest;
let root = directory.join("profiles").join(&manifest.id);
progress("mods")(0, 0);
profile::sync_snapshot(&root, snapshot).map_err(|e| e.to_string())?;
let inspection = profile::inspect(&root, manifest).map_err(|e| e.to_string())?;
if !inspection.up_to_date {
return Err(
"Синхронизация не завершена; запуск остановлен. Проверьте конфликты файлов.".into(),
);
}
let game_dir = directory.join("game");
let client = mojang::http_client().map_err(|e| e.to_string())?;
let runtime_client = runtime::http_client().map_err(|e| e.to_string())?;
let java = java::ensure_java(
&runtime_client,
&game_dir.join("runtime"),
manifest.minecraft.java_major,
&progress("java"),
)
.map_err(|e| e.to_string())?;
let merged = resolve_merged_version(
&client,
manifest,
Path::new(&java.executable),
&game_dir,
&game_dir.join("cache"),
&progress("neoforge"),
)?;
let libraries_client = mojang::library_http_client().map_err(|e| e.to_string())?;
mojang::ensure_client_jar(
&client,
&game_dir,
&merged.client_jar_version_id,
&merged.client,
)
.map_err(|e| e.to_string())?;
mojang::ensure_libraries(
&libraries_client,
&game_dir,
&merged.libraries,
&progress("libraries"),
)
.map_err(|e| e.to_string())?;
let index = mojang::ensure_asset_index(&client, &game_dir, &merged.asset_index)
.map_err(|e| e.to_string())?;
mojang::ensure_assets(&client, &game_dir, &index, &progress("assets"))
.map_err(|e| e.to_string())?;
Ok((merged, java, inspection))
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PreparationResult {
inspection: profile::ProfileInspection,
metadata: ProfileMetadata,
onboarding: Option<shacraft_account::LinkStart>,
}
/// Full repair, using one snapshot and one writer lock for mods AND the game.
#[tauri::command]
pub(crate) async fn ensure_game_installed(
app: AppHandle,
state: State<'_, LauncherOperations>,
profile_id: String,
) -> Result<(), String> {
let game_dir = data_dir(&app)?.join("game");
let runtime_root = game_dir.join("runtime");
let cache_dir = game_dir.join("cache");
) -> Result<PreparationResult, String> {
let directory = data_dir(&app)?;
let lifecycle = crate::update_guard::begin_operation(&directory, &state)?;
let permit = state.installation.acquire("Installation")?;
tauri::async_runtime::spawn_blocking(move || -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
let _lifecycle = lifecycle;
let _permit = permit;
let client = mojang::http_client().map_err(|error| error.to_string())?;
let runtime_client = runtime::http_client().map_err(|error| error.to_string())?;
let manifest = remote::fetch_manifest(&profile_id).map_err(|error| error.to_string())?;
let stage_progress = |stage: &'static str| -> mojang::ProgressCallback {
let app = app.clone();
Arc::new(move |current, total| {
let _ = app.emit(
"game-install-progress",
InstallProgress {
stage,
current_bytes: current,
total_bytes: total,
},
);
})
};
let java_install = java::ensure_java(
&runtime_client,
&runtime_root,
manifest.minecraft.java_major,
&stage_progress("java"),
)
.map_err(|error| error.to_string())?;
let merged = resolve_merged_version(
&client,
&manifest,
Path::new(&java_install.executable),
&game_dir,
&cache_dir,
&stage_progress("neoforge"),
)?;
// NeoForge may leave vanilla runtime libraries (including LWJGL) absent.
// Verify the full merged set, using the loader Maven only for libraries.
let library_client = mojang::library_http_client().map_err(|error| error.to_string())?;
mojang::ensure_client_jar(
&client,
&game_dir,
&merged.client_jar_version_id,
&merged.client,
)
.map_err(|error| error.to_string())?;
mojang::ensure_libraries(
&library_client,
&game_dir,
&merged.libraries,
&stage_progress("libraries"),
)
.map_err(|error| error.to_string())?;
let asset_index = mojang::ensure_asset_index(&client, &game_dir, &merged.asset_index)
.map_err(|error| error.to_string())?;
mojang::ensure_assets(&client, &game_dir, &asset_index, &stage_progress("assets"))
.map_err(|error| error.to_string())?;
Ok(())
let _lock = InstallationLock::acquire(&directory)?;
let snapshot = remote::fetch_snapshot(&profile_id).map_err(|e| e.to_string())?;
let (_, _, inspection) = prepare(&directory, &snapshot, &|stage| progress(&app, stage))?;
Ok(PreparationResult {
inspection,
metadata: (&snapshot).into(),
onboarding: None,
})
})
.await
.map_err(|error| format!("Install task failed: {error}"))?
.map_err(|e| e.to_string())?
}
#[derive(Clone, Serialize)]
@@ -150,83 +176,143 @@ pub(crate) struct GameExited {
exit_code: Option<i32>,
}
/// Launches `profile_id` with the verified ShaCraft account's linked nickname.
/// Local legacy nickname/account-mode preferences cannot override the link.
/// Spawns the game detached; watches it on a
/// background thread only to emit `game-exited` when it eventually closes.
async fn play(
app: AppHandle,
state: &LauncherOperations,
profile_id: String,
onboarding_name: Option<String>,
) -> Result<PreparationResult, String> {
let directory = data_dir(&app)?;
let lifecycle = crate::update_guard::begin_operation(&directory, &state)?;
let permit = state.installation.acquire("Installation")?;
let account_operation = state.shacraft_account.clone();
tauri::async_runtime::spawn_blocking(move || {
let _lifecycle = lifecycle;
let _permit = permit;
let lock = InstallationLock::acquire(&directory)?;
let snapshot = remote::fetch_snapshot(&profile_id).map_err(|e| e.to_string())?;
if onboarding_name.is_some() && (profile_id != "aeronautics" || !snapshot.manifest.files.iter().any(|f|
f.path.starts_with("mods/shacraft-game-bridge-") && f.path.ends_with(".jar") && f.policy == manifest::FilePolicy::Managed)) {
return Err("Опубликованная сборка ещё не поддерживает первый вход. Нужен подписанный мод ShaCraft Game Bridge.".into());
}
let (merged, java, inspection) = prepare(&directory, &snapshot, &|stage| progress(&app, stage))?;
// Refresh identity AFTER downloads; no long-lived cached permission.
let grant = if let Some(name) = onboarding_name {
let _account = account_operation.acquire("ShaCraft account operation")?;
let grant = shacraft_account::start_onboarding(&directory, &name).map_err(|e| e.to_string())?;
shacraft_account::validate_onboarding(&directory, &grant).map_err(|e| e.to_string())?;
Some(grant)
} else { None };
let identity = if let Some(grant) = &grant {
session::PlayerIdentity::Offline { name: grant.challenge.mc_username.clone() }
} else { resolve_identity(&directory, &account_operation)? };
let preferences = settings::load(&directory).map_err(|e| e.to_string())?;
let logs = directory.join("logs");
std::fs::create_dir_all(&logs).map_err(|e| e.to_string())?;
let timestamp = SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos();
let game_dir = directory.join("game");
let profile_dir = directory.join("profiles").join(&snapshot.manifest.id);
let log_path = logs.join(format!("{profile_id}-{timestamp}.log"));
let request = launch::LaunchRequest { java_executable: Path::new(&java.executable), game_dir: &game_dir,
profile_dir: &profile_dir, merged: &merged, identity: &identity, memory_mb: preferences.memory_mb,
log_path: &log_path, onboarding_token: grant.as_ref().map(|g|g.grant_token.as_str()) };
progress(&app, "launch")(0,0);
lock.starting()?;
let mut child = match launch::launch(&request) {
Ok(child) => child,
Err(error) => { lock.finished()?; return Err(error.to_string()); }
};
// Failure to record a living child, including an immediate exit,
// terminates and waits for it before releasing the writer lock.
if let Err(error) = lock.running(child.id()) {
let _ = child.kill();
if child.wait().is_ok() { let _ = lock.finished(); }
return Err(error);
}
let watch_app = app.clone();
std::thread::spawn(move || {
let exit = child.wait();
if exit.is_ok() { let _ = lock.finished(); }
let _ = watch_app.emit("game-exited", GameExited {profile_id,exit_code: exit.ok().and_then(|s|s.code())});
drop(lock);
});
Ok(PreparationResult { inspection, metadata: (&snapshot).into(), onboarding: grant.map(|g|g.challenge) })
}).await.map_err(|e|e.to_string())?
}
/// Legacy command also performs the entire preparation; no public IPC can skip
/// reconciliation or substitute a fresh manifest between install and launch.
#[tauri::command]
pub(crate) async fn launch_game(
app: AppHandle,
state: State<'_, LauncherOperations>,
profile_id: String,
) -> Result<(), String> {
let game_dir = data_dir(&app)?.join("game");
let data_dir = data_dir(&app)?;
let permit = state.installation.acquire("Installation")?;
let account_operation = state.shacraft_account.clone();
) -> Result<PreparationResult, String> {
play(app, &state, profile_id, None).await
}
#[tauri::command]
pub(crate) async fn launch_onboarding(
app: AppHandle,
state: State<'_, LauncherOperations>,
profile_id: String,
nickname: String,
) -> Result<PreparationResult, String> {
if !shacraft_account::valid_nickname(&nickname) {
return Err("Неверный игровой ник".into());
}
play(app, &state, profile_id, Some(nickname)).await
}
tauri::async_runtime::spawn_blocking(move || -> Result<(), String> {
let _permit = permit;
let client = mojang::http_client().map_err(|error| error.to_string())?;
let runtime_client = runtime::http_client().map_err(|error| error.to_string())?;
let manifest = remote::fetch_manifest(&profile_id).map_err(|error| error.to_string())?;
let profile_dir = data_dir.join("profiles").join(&manifest.id);
let settings = settings::load(&data_dir).map_err(|error| error.to_string())?;
let identity = resolve_identity(&data_dir, &account_operation)?;
#[cfg(test)]
mod tests {
use super::*;
// Everything here should already be installed by `ensure_game_installed`,
// so these are expected to hit their fast paths; no progress to show.
let no_progress: mojang::ProgressCallback = Arc::new(|_, _| {});
let java_install = java::ensure_java(
&runtime_client,
&game_dir.join("runtime"),
manifest.minecraft.java_major,
&no_progress,
)
.map_err(|error| error.to_string())?;
let merged = resolve_merged_version(
&client,
&manifest,
Path::new(&java_install.executable),
&game_dir,
&game_dir.join("cache"),
&no_progress,
)?;
let timestamp = SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let log_dir = data_dir.join("logs");
std::fs::create_dir_all(&log_dir).map_err(|error| error.to_string())?;
let log_path = log_dir.join(format!("{profile_id}-{timestamp}.log"));
let request = launch::LaunchRequest {
java_executable: Path::new(&java_install.executable),
game_dir: &game_dir,
profile_dir: &profile_dir,
merged: &merged,
identity: &identity,
memory_mb: settings.memory_mb,
log_path: &log_path,
/// Real provider downloads and installer execution; never logs in or joins
/// a server. Artifacts stay in a fresh temporary directory for diagnosis.
#[test]
#[ignore = "downloads the real pack/game and executes the official installer; needs network and Java 21"]
fn live_cold_install_and_corruption_repair() {
let directory = std::env::temp_dir().canonicalize().unwrap().join(format!(
"shacraft-cold-install-{}",
SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
assert!(!directory.exists());
eprintln!("Isolated installation: {}", directory.display());
let _lock = InstallationLock::acquire(&directory).unwrap();
let snapshot = remote::fetch_snapshot("aeronautics").unwrap();
let callback = |stage| -> mojang::ProgressCallback {
eprintln!("Stage: {stage}");
Arc::new(|_, _| {})
};
let mut child = launch::launch(&request).map_err(|error| error.to_string())?;
let watch_app = app.clone();
let watch_profile_id = profile_id.clone();
std::thread::spawn(move || {
let exit_code = child.wait().ok().and_then(|status| status.code());
let _ = watch_app.emit(
"game-exited",
GameExited {
profile_id: watch_profile_id,
exit_code,
},
);
});
Ok(())
})
.await
.map_err(|error| format!("Launch task failed: {error}"))?
let (_, java, inspection) = prepare(&directory, &snapshot, &callback).unwrap();
assert!(inspection.up_to_date);
assert_eq!(java.major, snapshot.manifest.minecraft.java_major);
let game = directory.join("game");
let version = &snapshot.manifest.minecraft.loader.version;
let json = neoforge::installed_version_json_path(&game, version);
let jar = game.join(format!(
"libraries/net/neoforged/neoforge/{version}/neoforge-{version}-client.jar"
));
let original_json = std::fs::read(&json).unwrap();
std::fs::write(&json, b"nonempty corrupted version JSON").unwrap();
std::fs::write(&jar, b"nonempty corrupted patched JAR").unwrap();
let (_, _, repaired) = prepare(&directory, &snapshot, &callback).unwrap();
assert!(repaired.up_to_date);
assert_eq!(std::fs::read(&json).unwrap(), original_json);
assert!(std::fs::metadata(&jar).unwrap().len() > 1024);
// A third preparation verifies the receipt and all downloads again.
assert!(
prepare(&directory, &snapshot, &callback)
.unwrap()
.2
.up_to_date
);
eprintln!(
"Cold install, corrupt JSON/JAR repair and healthy recheck passed: {}",
directory.display()
);
}
}
+1
View File
@@ -5,6 +5,7 @@ pub(crate) mod host;
pub(crate) mod preferences;
pub(crate) mod profiles;
pub(crate) mod shacraft;
pub(crate) mod updater;
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
+11 -6
View File
@@ -1,6 +1,6 @@
use super::data_dir;
use crate::settings;
use tauri::AppHandle;
use crate::{operations::LauncherOperations, settings};
use tauri::{AppHandle, State};
#[tauri::command]
pub(crate) async fn load_settings(app: AppHandle) -> Result<settings::LauncherSettings, String> {
@@ -14,11 +14,16 @@ pub(crate) async fn load_settings(app: AppHandle) -> Result<settings::LauncherSe
#[tauri::command]
pub(crate) async fn save_settings(
app: AppHandle,
state: State<'_, LauncherOperations>,
settings: settings::LauncherSettings,
) -> Result<settings::LauncherSettings, String> {
let data_dir = data_dir(&app)?;
tauri::async_runtime::spawn_blocking(move || settings::save(&data_dir, settings))
.await
.map_err(|error| format!("Settings task failed: {error}"))?
.map_err(|error| error.to_string())
let lifecycle = crate::update_guard::begin_operation(&data_dir, &state)?;
tauri::async_runtime::spawn_blocking(move || {
let _lifecycle = lifecycle;
settings::save(&data_dir, settings)
})
.await
.map_err(|error| format!("Settings task failed: {error}"))?
.map_err(|error| error.to_string())
}
+102 -15
View File
@@ -1,47 +1,134 @@
use super::data_dir;
use crate::{operations::LauncherOperations, profile, remote};
use crate::{installation_lock::InstallationLock, operations::LauncherOperations, profile, remote};
use serde::Serialize;
use tauri::{AppHandle, State};
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ProfileMetadata {
pub snapshot: String,
pub minecraft_version: String,
pub loader_kind: String,
pub loader_version: String,
pub java_major: u8,
}
impl From<&remote::VerifiedSnapshot> for ProfileMetadata {
fn from(snapshot: &remote::VerifiedSnapshot) -> Self {
let game = &snapshot.manifest.minecraft;
Self {
snapshot: snapshot.digest.clone(),
minecraft_version: game.version.clone(),
loader_kind: game.loader.kind.clone(),
loader_version: game.loader.version.clone(),
java_major: game.java_major,
}
}
}
#[tauri::command]
pub(crate) async fn profile_metadata(profile_id: String) -> Result<ProfileMetadata, String> {
tauri::async_runtime::spawn_blocking(move || {
let snapshot = remote::fetch_snapshot(&profile_id).map_err(|e| e.to_string())?;
Ok(ProfileMetadata::from(&snapshot))
})
.await
.map_err(|e| e.to_string())?
}
#[tauri::command]
pub(crate) async fn get_server_status(profile_id: String) -> Result<remote::ServerStatus, String> {
tauri::async_runtime::spawn_blocking(move || {
remote::fetch_server_status(&profile_id).map_err(|error| error.to_string())
remote::fetch_server_status(&profile_id).map_err(|e| e.to_string())
})
.await
.map_err(|error| format!("Server-status task failed: {error}"))?
.map_err(|e| e.to_string())?
}
/// Loads and validates the published ShaCraft manifest before inspecting a profile.
#[tauri::command]
pub(crate) async fn inspect_remote_profile(
app: AppHandle,
state: State<'_, LauncherOperations>,
profile_id: String,
) -> Result<profile::ProfileInspection, String> {
let data_dir = data_dir(&app)?;
let directory = data_dir(&app)?;
let lifecycle = crate::update_guard::begin_operation(&directory, &state)?;
tauri::async_runtime::spawn_blocking(move || {
let manifest = remote::fetch_manifest(&profile_id).map_err(|error| error.to_string())?;
profile::inspect(&data_dir.join("profiles").join(&manifest.id), &manifest)
.map_err(|error| error.to_string())
let _lifecycle = lifecycle;
// Inspection must not report a partially applied journal as ready.
let _lock = InstallationLock::acquire(&directory)?;
let manifest = remote::fetch_manifest(&profile_id).map_err(|e| e.to_string())?;
profile::inspect(&directory.join("profiles").join(&manifest.id), &manifest)
.map_err(|e| e.to_string())
})
.await
.map_err(|error| format!("Profile inspection task failed: {error}"))?
.map_err(|e| e.to_string())?
}
/// Downloads missing or changed ShaCraft-managed files from the fixed v2 endpoint.
#[tauri::command]
pub(crate) async fn sync_remote_profile(
app: AppHandle,
state: State<'_, LauncherOperations>,
profile_id: String,
) -> Result<profile::SyncResult, String> {
let data_dir = data_dir(&app)?;
let directory = data_dir(&app)?;
let lifecycle = crate::update_guard::begin_operation(&directory, &state)?;
let permit = state.installation.acquire("Installation")?;
tauri::async_runtime::spawn_blocking(move || {
let _lifecycle = lifecycle;
let _permit = permit;
let manifest = remote::fetch_manifest(&profile_id).map_err(|error| error.to_string())?;
profile::sync(&data_dir.join("profiles").join(&manifest.id), &manifest)
.map_err(|error| error.to_string())
let _lock = InstallationLock::acquire(&directory)?;
let snapshot = remote::fetch_snapshot(&profile_id).map_err(|e| e.to_string())?;
profile::sync_snapshot(
&directory.join("profiles").join(&snapshot.manifest.id),
&snapshot,
)
.map_err(|e| e.to_string())
})
.await
.map_err(|error| format!("Profile synchronization task failed: {error}"))?
.map_err(|e| e.to_string())?
}
#[tauri::command]
pub(crate) async fn legacy_mods(
app: AppHandle,
state: State<'_, LauncherOperations>,
profile_id: String,
) -> Result<Vec<profile::LegacyMod>, String> {
let directory = data_dir(&app)?;
let lifecycle = crate::update_guard::begin_operation(&directory, &state)?;
tauri::async_runtime::spawn_blocking(move || {
let _lifecycle = lifecycle;
let _lock = InstallationLock::acquire(&directory)?;
let manifest = remote::fetch_manifest(&profile_id).map_err(|e| e.to_string())?;
profile::list_legacy_mods(&directory.join("profiles").join(&manifest.id), &manifest)
.map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())?
}
#[tauri::command]
pub(crate) async fn backup_legacy_mods(
app: AppHandle,
state: State<'_, LauncherOperations>,
profile_id: String,
selections: Vec<profile::LegacySelection>,
) -> Result<profile::LegacyBackup, String> {
let directory = data_dir(&app)?;
let lifecycle = crate::update_guard::begin_operation(&directory, &state)?;
let permit = state.installation.acquire("Legacy migration")?;
tauri::async_runtime::spawn_blocking(move || {
let _lifecycle = lifecycle;
let _permit = permit;
let _lock = InstallationLock::acquire(&directory)?;
let manifest = remote::fetch_manifest(&profile_id).map_err(|e| e.to_string())?;
profile::backup_legacy_mods(
&directory.join("profiles").join(&manifest.id),
&manifest,
&selections,
)
.map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())?
}
+2
View File
@@ -13,10 +13,12 @@ async fn account_task<T: Send + 'static>(
work: impl FnOnce(&Path) -> Result<T, shacraft_account::AccountError> + Send + 'static,
) -> Result<T, String> {
let directory = data_dir(app)?;
let lifecycle = crate::update_guard::begin_operation(&directory, operations)?;
let permit = operations
.shacraft_account
.acquire("ShaCraft account operation")?;
tauri::async_runtime::spawn_blocking(move || {
let _lifecycle = lifecycle;
let _permit = permit;
work(&directory).map_err(|error| error.to_string())
})
+158
View File
@@ -0,0 +1,158 @@
//! No updater command accepts a URL, key, target, version, executable or arguments.
use super::data_dir;
use crate::{
operations::LauncherOperations,
update_guard,
updater::{self, UpdateStatus, UpdaterState},
};
use tauri::{AppHandle, Manager, State};
fn pending(app: &AppHandle, state: &UpdaterState) -> Result<Option<UpdateStatus>, String> {
let operations = app.state::<LauncherOperations>();
if let Err(reason) = operations.ensure_writable() {
return Ok(Some(state.recovery(app, reason)));
}
let directory = data_dir(app)?;
match update_guard::pending_reason(&directory, env!("CARGO_PKG_VERSION")) {
Ok(Some(reason)) | Err(reason) => {
// Recovery discovered after startup is just as permanent for this
// process. External marker deletion cannot resume native writes.
operations.latch_recovery(reason.clone());
Ok(Some(state.recovery(app, reason)))
}
Ok(None) => Ok(None),
}
}
fn contain_worker_panic<T>(
work: impl FnOnce() -> Result<T, String>,
panic_message: &str,
) -> Result<T, String> {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(work))
.unwrap_or_else(|_| Err(panic_message.to_string()))
}
fn report_failure(app: &AppHandle, state: &UpdaterState, error: String) -> UpdateStatus {
match pending(app, state) {
Ok(Some(recovery)) => recovery,
Ok(None) => state.fail(app, error),
Err(reason) => {
app.state::<LauncherOperations>()
.latch_recovery(reason.clone());
state.recovery(app, reason)
}
}
}
#[tauri::command]
pub(crate) fn updater_status(
app: AppHandle,
state: State<'_, UpdaterState>,
) -> Result<UpdateStatus, String> {
if state.critical() {
return Ok(state.status());
}
Ok(pending(&app, &state)?.unwrap_or_else(|| state.status()))
}
#[tauri::command]
pub(crate) async fn updater_check(
app: AppHandle,
state: State<'_, UpdaterState>,
) -> Result<UpdateStatus, String> {
let permit = state.acquire()?;
if let Some(status) = pending(&app, &state)? {
return Ok(status);
}
let state = state.inner().clone();
let worker_app = app.clone();
let worker_state = state.clone();
let worker = tauri::async_runtime::spawn_blocking(move || {
let _permit = permit;
// Publish failure from the worker even if its IPC caller disappeared.
contain_worker_panic(
|| updater::check(&worker_app, &worker_state),
"Проверка обновления прервалась. Повторите проверку.",
)
.unwrap_or_else(|error| report_failure(&worker_app, &worker_state, error))
});
Ok(worker.await.unwrap_or_else(|_| {
report_failure(
&app,
&state,
"Проверка обновления прервалась. Повторите проверку.".into(),
)
}))
}
#[tauri::command]
pub(crate) async fn updater_download_install(
app: AppHandle,
state: State<'_, UpdaterState>,
) -> Result<UpdateStatus, String> {
let permit = state.acquire()?;
if let Some(status) = pending(&app, &state)? {
return Ok(status);
}
let directory = data_dir(&app)?;
let state = state.inner().clone();
let worker_app = app.clone();
let worker_state = state.clone();
let worker = tauri::async_runtime::spawn_blocking(move || {
let _permit = permit;
contain_worker_panic(
|| {
let operations = worker_app.state::<LauncherOperations>();
updater::download_install(&worker_app, &worker_state, &directory, &operations)
},
"Установка обновления прервалась; проверьте состояние перед повторной попыткой.",
)
.unwrap_or_else(|error| report_failure(&worker_app, &worker_state, error))
});
Ok(worker.await.unwrap_or_else(|_| {
report_failure(
&app,
&state,
"Установка обновления прервалась; проверьте состояние перед повторной попыткой.".into(),
)
}))
}
#[tauri::command]
pub(crate) fn updater_restart(
app: AppHandle,
state: State<'_, UpdaterState>,
) -> Result<(), String> {
let _permit = state.acquire()?;
if !state.ready() {
return Err("Нет завершённого обновления для перезапуска".into());
}
app.restart()
}
#[tauri::command]
pub(crate) fn updater_open_release_page() -> Result<(), String> {
updater::open_release_page()
}
#[cfg(test)]
mod tests {
use super::contain_worker_panic;
use crate::operations::Operation;
#[test]
fn panicking_worker_releases_owned_permit_and_returns_a_retryable_boundary_error() {
let operation = Operation::default();
let permit = operation.acquire("test updater").unwrap();
let result: Result<(), String> = contain_worker_panic(
move || {
let _permit = permit;
panic!("synthetic worker failure");
},
"worker interrupted",
);
assert_eq!(result, Err("worker interrupted".into()));
assert!(operation.acquire("retry").is_ok());
assert_eq!(contain_worker_panic(|| Ok(42), "unused"), Ok(42));
}
}
+326
View File
@@ -0,0 +1,326 @@
//! One writer for the entire shared game tree, across launcher instances.
//! The OS lock is retained by the child watcher. A durable process lease also
//! protects a detached Minecraft after the launcher exits (PID + start time,
//! never PID alone). An interrupted spawn with no recorded child fails closed.
use serde::{Deserialize, Serialize};
use std::{
fs::{self, File, OpenOptions},
io,
path::{Path, PathBuf},
};
use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System};
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
struct ProcessIdentity {
pid: u32,
started: u64,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(tag = "state")]
enum Lease {
Starting { launcher: ProcessIdentity },
Running { game: ProcessIdentity },
}
fn process_identity(pid: u32) -> Result<Option<ProcessIdentity>, String> {
let me = Pid::from_u32(std::process::id());
let target = Pid::from_u32(pid);
let mut system = System::new();
// sysinfo resets each refreshed process's updated flag while removing dead
// entries. Repeating a PID makes the second pass remove that live entry.
let pids = if me == target {
vec![me]
} else {
vec![me, target]
};
system.refresh_processes_specifics(
ProcessesToUpdate::Some(&pids),
true,
ProcessRefreshKind::nothing().without_tasks(),
);
// An unsupported/failed process inspection must not permit file mutation.
if system.process(me).is_none() {
return Err("Не удалось проверить запущенные процессы; запись файлов заблокирована".into());
}
system
.process(target)
.map(|process| {
let started = process.start_time();
if started == 0 {
return Err("Не удалось определить время запуска игры".into());
}
Ok(ProcessIdentity { pid, started })
})
.transpose()
}
fn ordinary_path(path: &Path) -> Result<(), String> {
match fs::symlink_metadata(path) {
Ok(meta) if meta.file_type().is_symlink() => {
Err("Служебный путь блокировки является ссылкой".into())
}
Ok(_) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.to_string()),
}
}
pub(crate) struct InstallationLock {
_file: File,
lease: PathBuf,
}
impl Drop for InstallationLock {
fn drop(&mut self) {
// A concurrent Unix spawn can retain an inherited copy until exec.
// Release this owner's lock explicitly instead of waiting for every
// duplicate descriptor to close. Never remove the durable game lease.
// If unlock fails, closing the file still leaves the OS fail-closed.
let _ = self._file.unlock();
}
}
impl InstallationLock {
pub fn acquire(data_dir: &Path) -> Result<Self, String> {
ordinary_path(data_dir)?;
fs::create_dir_all(data_dir).map_err(|e| e.to_string())?;
let directory = data_dir.join("installation-state");
ordinary_path(&directory)?;
fs::create_dir_all(&directory).map_err(|e| e.to_string())?;
let path = directory.join("writer.lock");
ordinary_path(&path)?;
let mut options = OpenOptions::new();
options.read(true).write(true).create(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let file = options.open(&path).map_err(|e| e.to_string())?;
file.try_lock().map_err(|_| "Сборка используется другим экземпляром лаунчера или игрой. Закройте игру и дождитесь завершения операции.".to_string())?;
let guard = Self {
_file: file,
lease: directory.join("game-lease.json"),
};
ordinary_path(&guard.lease)?;
match fs::read(&guard.lease) {
Ok(bytes) => {
let lease: Lease = serde_json::from_slice(&bytes).map_err(|_| "Повреждена запись запущенной игры; запись файлов остановлена. Закройте Minecraft и восстановите служебную запись по инструкции.".to_string())?;
match lease {
Lease::Starting { .. } => return Err("Предыдущий запуск прервался до регистрации процесса. Запись файлов заблокирована: сначала завершите Minecraft и выполните ручное восстановление game-lease.json по инструкции.".into()),
Lease::Running { game } => {
if process_identity(game.pid)?.as_ref() == Some(&game) {
return Err("Minecraft ещё работает. Перед обновлением или восстановлением закройте игру.".into());
}
fs::remove_file(&guard.lease).map_err(|e| e.to_string())?;
}
}
}
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
Err(e) => return Err(e.to_string()),
}
Ok(guard)
}
fn store(&self, value: &Lease) -> Result<(), String> {
ordinary_path(&self.lease)?;
crate::storage::write_atomic(
&self.lease,
&serde_json::to_vec(value).map_err(|e| e.to_string())?,
)
.map_err(|e| e.to_string())
}
/// Write ahead of spawn, while holding the OS lock, closing the crash window
/// in which a child could exist with no durable evidence whatsoever.
pub fn starting(&self) -> Result<(), String> {
let launcher =
process_identity(std::process::id())?.ok_or("Launcher process disappeared")?;
self.store(&Lease::Starting { launcher })
}
pub fn running(&self, pid: u32) -> Result<(), String> {
let game = process_identity(pid)?.ok_or("Игра завершилась во время запуска")?;
self.store(&Lease::Running { game })
}
/// Only the owner, after failed spawn or wait() proving child termination,
/// clears the lease. Drop intentionally does not clear it.
pub fn finished(&self) -> Result<(), String> {
match fs::remove_file(&self.lease) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.to_string()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT: AtomicU64 = AtomicU64::new(0);
fn dir() -> PathBuf {
let p = std::env::temp_dir().join(format!(
"shacraft-lock-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn separate_file_descriptions_exclude_writers() {
let p = dir();
let a = InstallationLock::acquire(&p).unwrap();
assert!(InstallationLock::acquire(&p).is_err());
drop(a);
drop(
InstallationLock::acquire(&p)
.unwrap_or_else(|error| panic!("expected released lock: {error}")),
);
fs::remove_dir_all(p).unwrap();
}
#[test]
fn live_game_lease_survives_dropping_launcher_lock() {
let p = dir();
let a = InstallationLock::acquire(&p).unwrap();
a.starting().unwrap();
a.running(std::process::id()).unwrap();
drop(a);
let error = InstallationLock::acquire(&p).unwrap_err_string();
assert!(error.contains("Minecraft"), "unexpected refusal: {error}");
fs::remove_dir_all(p).unwrap();
}
#[test]
fn reused_pid_with_different_start_does_not_block_forever() {
let p = dir();
let a = InstallationLock::acquire(&p).unwrap();
a.store(&Lease::Running {
game: ProcessIdentity {
pid: std::process::id(),
started: 1,
},
})
.unwrap();
drop(a);
drop(
InstallationLock::acquire(&p)
.unwrap_or_else(|error| panic!("expected released lock: {error}")),
);
fs::remove_dir_all(p).unwrap();
}
#[test]
fn inherited_file_description_does_not_extend_owner_guard_lifetime() {
let p = dir();
let guard = InstallationLock::acquire(&p).unwrap();
guard
.store(&Lease::Running {
game: ProcessIdentity {
pid: std::process::id(),
started: 1,
},
})
.unwrap();
// A concurrent Unix spawn inherits this same open file description
// until exec closes its CLOEXEC copy. try_clone deterministically keeps
// that description alive without relying on fork timing or sleeps.
let inherited = guard._file.try_clone().unwrap();
assert!(InstallationLock::acquire(&p).is_err());
drop(guard);
let reacquired = InstallationLock::acquire(&p)
.unwrap_or_else(|error| panic!("owner dropped but lock remained: {error}"));
assert!(!p.join("installation-state/game-lease.json").exists());
drop(inherited);
drop(reacquired);
fs::remove_dir_all(p).unwrap();
}
#[test]
fn releasing_owner_lock_keeps_live_game_lease_with_inherited_description() {
let p = dir();
let guard = InstallationLock::acquire(&p).unwrap();
guard.running(std::process::id()).unwrap();
let inherited = guard._file.try_clone().unwrap();
drop(guard);
let error = InstallationLock::acquire(&p).unwrap_err_string();
assert!(error.contains("Minecraft"), "unexpected refusal: {error}");
assert!(p.join("installation-state/game-lease.json").exists());
drop(inherited);
fs::remove_dir_all(p).unwrap();
}
#[test]
fn interrupted_spawn_fails_closed() {
let p = dir();
let a = InstallationLock::acquire(&p).unwrap();
a.starting().unwrap();
drop(a);
assert!(InstallationLock::acquire(&p).is_err());
fs::remove_dir_all(p).unwrap();
}
#[test]
fn current_process_identity_is_detected_without_duplicate_pid_removal() {
let pid = std::process::id();
let identity = process_identity(pid).unwrap().unwrap();
assert_eq!(identity.pid, pid);
assert!(identity.started > 0);
}
#[test]
fn real_child_lease_blocks_until_child_exits_after_launcher_guard_drops() {
const CHILD_MODE: &str = "SHACRAFT_LEASE_TEST_CHILD";
if std::env::var_os(CHILD_MODE).is_some() {
use std::io::Read;
let mut bytes = Vec::new();
std::io::stdin().read_to_end(&mut bytes).unwrap();
return;
}
// Launch this one test in child mode; stdin keeps it alive without a
// platform shell, installed external program or arbitrary sleep.
struct TestChild(std::process::Child);
impl Drop for TestChild {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
let mut child = TestChild(std::process::Command::new(std::env::current_exe().unwrap())
.arg("--exact")
.arg("installation_lock::tests::real_child_lease_blocks_until_child_exits_after_launcher_guard_drops")
.env(CHILD_MODE, "1")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn().unwrap());
let directory = dir();
let guard = InstallationLock::acquire(&directory).unwrap();
guard.starting().unwrap();
guard.running(child.0.id()).unwrap();
drop(guard);
assert!(InstallationLock::acquire(&directory)
.unwrap_err_string()
.contains("Minecraft"));
child.0.kill().unwrap();
child.0.wait().unwrap();
let guard = InstallationLock::acquire(&directory).unwrap();
assert!(!directory
.join("installation-state/game-lease.json")
.exists());
drop(guard);
fs::remove_dir_all(directory).unwrap();
}
trait ErrorText {
fn unwrap_err_string(self) -> String;
}
impl ErrorText for Result<InstallationLock, String> {
fn unwrap_err_string(self) -> String {
match self {
Err(e) => e,
Ok(_) => panic!("expected lock refusal"),
}
}
}
}
+602
View File
@@ -0,0 +1,602 @@
//! Launcher-owned profile state lives next to, never inside, the payload tree.
//! Callers hold the installation lock. Local records are not remote manifests:
//! they describe completed launcher writes, and never adopt an existing file.
use crate::{download, manifest::is_portable_component, storage};
use serde::{Deserialize, Serialize};
use std::{
collections::BTreeMap,
fs,
io::{self, Read},
path::{Path, PathBuf},
sync::atomic::{AtomicU64, Ordering},
time::{SystemTime, UNIX_EPOCH},
};
const MAX_STATE_BYTES: u64 = 8 * 1024 * 1024;
static NEXT_TRANSACTION: AtomicU64 = AtomicU64::new(0);
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub(crate) struct Fingerprint {
pub size: u64,
pub sha256: String,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub(crate) struct OwnedFile {
pub fingerprint: Fingerprint,
pub snapshot: String,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub(crate) struct Inventory {
pub version: u32,
pub files: BTreeMap<String, OwnedFile>,
}
impl Default for Inventory {
fn default() -> Self {
Self {
version: 1,
files: BTreeMap::new(),
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Change {
pub path: String,
pub before: Option<Fingerprint>,
pub after: Option<Fingerprint>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Journal {
version: u32,
pub transaction: String,
pub changes: Vec<Change>,
pub next: Inventory,
}
pub(crate) struct Store {
pub root: PathBuf,
profile: PathBuf,
}
pub(crate) fn invalid(message: impl Into<String>) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message.into())
}
pub(crate) fn safe_relative(relative: &str) -> bool {
!relative.is_empty() && relative.split('/').all(is_portable_component)
}
// Personal game data is never eligible for automated profile management.
pub(crate) fn protected(relative: &str) -> bool {
matches!(
relative
.split('/')
.next()
.unwrap_or("")
.to_ascii_lowercase()
.as_str(),
"saves" | "worlds" | "screenshots" | "logs" | "crash-reports"
)
}
pub(crate) fn checked_path(root: &Path, relative: &str) -> io::Result<PathBuf> {
if !safe_relative(relative) {
return Err(invalid("Unsafe stored profile path"));
}
reject_links(root)?;
let mut path = root.to_path_buf();
for component in relative.split('/') {
path.push(component);
match fs::symlink_metadata(&path) {
Ok(meta) if meta.file_type().is_symlink() => {
return Err(invalid("Profile path contains a symbolic link"))
}
Ok(_) => {}
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
Err(e) => return Err(e),
}
}
Ok(path)
}
fn reject_links(path: &Path) -> io::Result<()> {
// The caller supplies the trusted profile/state root. Check that root and
// its immediate parent; checked_path walks every descendant separately.
// System ancestors may legitimately be links (e.g. /var on macOS).
for ancestor in path.ancestors().take(2) {
match fs::symlink_metadata(ancestor) {
Ok(meta) if meta.file_type().is_symlink() => {
return Err(invalid(
"Launcher state/profile path contains a symbolic link",
))
}
Ok(_) => {}
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
Err(e) => return Err(e),
}
}
Ok(())
}
pub(crate) fn fingerprint(path: &Path) -> io::Result<Option<Fingerprint>> {
let metadata = match fs::symlink_metadata(path) {
Ok(meta) => meta,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e),
};
if !metadata.is_file() || metadata.file_type().is_symlink() {
return Err(invalid("Expected a regular profile file"));
}
Ok(Some(Fingerprint {
size: metadata.len(),
sha256: download::file_hashes(path)?.1,
}))
}
fn valid_fingerprint(value: &Fingerprint) -> bool {
value.sha256.len() == 64 && value.sha256.bytes().all(|b| b.is_ascii_hexdigit())
}
fn validate_inventory(inventory: &Inventory) -> io::Result<()> {
if inventory.version != 1
|| inventory.files.iter().any(|(path, record)| {
!safe_relative(path)
|| protected(path)
|| !valid_fingerprint(&record.fingerprint)
|| record.snapshot.len() != 64
|| !record.snapshot.bytes().all(|b| b.is_ascii_hexdigit())
})
{
return Err(invalid(
"Invalid launcher ownership inventory; existing files were preserved",
));
}
Ok(())
}
fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> io::Result<Option<T>> {
reject_links(path)?;
let file = match fs::File::open(path) {
Ok(file) => file,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e),
};
let mut bytes = Vec::new();
file.take(MAX_STATE_BYTES + 1).read_to_end(&mut bytes)?;
if bytes.len() as u64 > MAX_STATE_BYTES {
return Err(invalid("Launcher state is too large"));
}
serde_json::from_slice(&bytes)
.map(Some)
.map_err(|_| invalid("Invalid launcher state; existing files were preserved"))
}
impl Store {
pub fn open(profile: &Path) -> io::Result<Self> {
reject_links(profile)?;
let name = profile
.file_name()
.and_then(|s| s.to_str())
.filter(|s| is_portable_component(s))
.ok_or_else(|| invalid("Invalid profile directory"))?;
let parent = profile
.parent()
.ok_or_else(|| invalid("Profile needs a parent directory"))?;
let root = parent.join(format!(".{name}.shacraft-state"));
reject_links(&root)?;
Ok(Self {
root,
profile: profile.to_path_buf(),
})
}
pub fn load(&self) -> io::Result<Inventory> {
let inventory = read_json(&self.root.join("inventory.json"))?.unwrap_or_default();
validate_inventory(&inventory)?;
Ok(inventory)
}
pub fn pending(&self) -> io::Result<bool> {
Ok(self.read_journal()?.is_some())
}
fn read_journal(&self) -> io::Result<Option<Journal>> {
let journal: Option<Journal> = read_json(&self.root.join("pending.json"))?;
if let Some(journal) = &journal {
validate_inventory(&journal.next)?;
let mut paths = std::collections::HashSet::new();
if journal.version != 1
|| !is_portable_component(&journal.transaction)
|| !journal.transaction.starts_with("tx-")
|| journal.changes.iter().any(|change| {
!safe_relative(&change.path)
|| protected(&change.path)
|| !paths.insert(change.path.to_lowercase())
|| change
.before
.as_ref()
.is_some_and(|f| !valid_fingerprint(f))
|| change.after.as_ref().is_some_and(|f| !valid_fingerprint(f))
})
{
return Err(invalid(
"Invalid pending update; existing files were preserved",
));
}
}
Ok(journal)
}
pub fn transaction(&self) -> io::Result<String> {
reject_links(&self.root)?;
fs::create_dir_all(&self.root)?;
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let name = format!(
"tx-{timestamp}-{}-{}",
std::process::id(),
NEXT_TRANSACTION.fetch_add(1, Ordering::Relaxed)
);
fs::create_dir(self.root.join(&name))?;
fs::create_dir(self.root.join(&name).join("staged"))?;
fs::create_dir(self.root.join(&name).join("backup"))?;
Ok(name)
}
pub fn stage(&self, transaction: &str, index: usize) -> io::Result<PathBuf> {
checked_path(&self.root, &format!("{transaction}/staged/{index}"))
}
pub fn prepare(
&self,
transaction: String,
changes: Vec<Change>,
next: Inventory,
) -> io::Result<()> {
if self.pending()? {
return Err(invalid("A previous profile update needs recovery"));
}
validate_inventory(&next)?;
let journal = Journal {
version: 1,
transaction,
changes,
next,
};
let bytes = serde_json::to_vec(&journal).map_err(|e| invalid(e.to_string()))?;
if bytes.len() as u64 > MAX_STATE_BYTES {
return Err(invalid("Profile update journal is too large"));
}
let transaction_root = checked_path(&self.root, &journal.transaction)?;
storage::write_atomic(&transaction_root.join("receipt.json"), &bytes)?;
sync_directory(&transaction_root.join("staged"))?;
sync_directory(&transaction_root)?;
storage::write_atomic(&self.root.join("pending.json"), &bytes)?;
sync_directory(&self.root)
}
/// Roll forward only when every affected path still matches its before or
/// after image. Staged bytes are rehashed; unexpected local changes stop
/// recovery without overwriting them. Backups remain available to the user.
pub fn recover(&self) -> io::Result<()> {
let Some(journal) = self.read_journal()? else {
return Ok(());
};
// Check every transition first, before moving any remaining file.
for (index, change) in journal.changes.iter().enumerate() {
self.check_change(&journal.transaction, index, change)?;
}
for (index, change) in journal.changes.iter().enumerate() {
self.apply_change(&journal.transaction, index, change)?;
}
let bytes = serde_json::to_vec(&journal.next).map_err(|e| invalid(e.to_string()))?;
storage::write_atomic(&self.root.join("inventory.json"), &bytes)?;
sync_directory(&self.root)?;
fs::remove_file(self.root.join("pending.json"))?;
sync_directory(&self.root)
}
fn check_change(&self, transaction: &str, index: usize, change: &Change) -> io::Result<()> {
let target = checked_path(&self.profile, &change.path)?;
let actual = fingerprint(&target)?;
let backup = checked_path(&self.root, &format!("{transaction}/backup/{index}"))?;
let saved = fingerprint(&backup)?;
if actual == change.after && (change.before.is_none() || saved == change.before) {
// A consumed stage proves that the launcher completed the rename.
// If it is still present, identical bytes may have been created by
// the user during download; do not silently adopt that file.
if change.after.is_some() && fingerprint(&self.stage(transaction, index)?)?.is_some() {
return Err(invalid(format!(
"Update conflict: {} appeared during staging; file preserved",
change.path
)));
}
return Ok(());
}
if actual != change.before && !(actual.is_none() && saved == change.before) {
return Err(invalid(format!(
"Update conflict: {} changed; file preserved",
change.path
)));
}
if actual.is_some() && saved.is_some() {
return Err(invalid(format!(
"Update conflict: {} and its backup both exist",
change.path
)));
}
if let Some(after) = &change.after {
if fingerprint(&self.stage(transaction, index)?)?.as_ref() != Some(after) {
return Err(invalid(format!(
"Staged file is missing or corrupt: {}; retry needs recovery",
change.path
)));
}
}
Ok(())
}
fn apply_change(&self, transaction: &str, index: usize, change: &Change) -> io::Result<()> {
self.check_change(transaction, index, change)?;
let target = checked_path(&self.profile, &change.path)?;
let actual = fingerprint(&target)?;
let backup = checked_path(&self.root, &format!("{transaction}/backup/{index}"))?;
if actual == change.after {
return Ok(());
}
if actual.is_some() {
fs::rename(&target, &backup)?;
sync_directory(target.parent().unwrap())?;
sync_directory(backup.parent().unwrap())?;
}
if change.after.is_some() {
fs::create_dir_all(target.parent().unwrap())?;
fs::rename(self.stage(transaction, index)?, &target)?;
sync_directory(target.parent().unwrap())?;
}
Ok(())
}
}
fn sync_directory(path: &Path) -> io::Result<()> {
#[cfg(unix)]
{
fs::File::open(path)?.sync_all()?;
}
#[cfg(not(unix))]
{
let _ = path;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use sha2::{Digest, Sha256};
struct Fixture {
base: PathBuf,
profile: PathBuf,
store: Store,
}
impl Fixture {
fn new() -> Self {
let base = std::env::temp_dir().join(format!(
"shacraft-journal-{}-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos(),
NEXT_TRANSACTION.fetch_add(1, Ordering::Relaxed)
));
let profile = base.join("profiles/aeronautics");
fs::create_dir_all(profile.join("mods")).unwrap();
let store = Store::open(&profile).unwrap();
Self {
base,
profile,
store,
}
}
fn replacement(&self) -> (String, Change) {
fs::write(self.profile.join("mods/current.jar"), b"old").unwrap();
let transaction = self.store.transaction().unwrap();
fs::write(self.store.stage(&transaction, 0).unwrap(), b"new").unwrap();
let change = Change {
path: "mods/current.jar".into(),
before: Some(fp(b"old")),
after: Some(fp(b"new")),
};
let mut next = Inventory::default();
next.files.insert(
change.path.clone(),
OwnedFile {
fingerprint: fp(b"new"),
snapshot: "a".repeat(64),
},
);
self.store
.prepare(transaction.clone(), vec![change.clone()], next)
.unwrap();
(transaction, change)
}
}
impl Drop for Fixture {
fn drop(&mut self) {
fs::remove_dir_all(&self.base).unwrap();
}
}
fn fp(bytes: &[u8]) -> Fingerprint {
Fingerprint {
size: bytes.len() as u64,
sha256: format!("{:x}", Sha256::digest(bytes)),
}
}
#[test]
fn crash_after_backing_up_old_file_finishes_replacement_and_ownership() {
let f = Fixture::new();
let (transaction, _) = f.replacement();
let backup = f.store.root.join(&transaction).join("backup/0");
fs::rename(f.profile.join("mods/current.jar"), &backup).unwrap();
assert!(f.store.pending().unwrap());
f.store.recover().unwrap();
assert_eq!(
fs::read(f.profile.join("mods/current.jar")).unwrap(),
b"new"
);
assert_eq!(fs::read(backup).unwrap(), b"old");
assert_eq!(
f.store.load().unwrap().files["mods/current.jar"].fingerprint,
fp(b"new")
);
assert!(!f.store.pending().unwrap());
f.store.recover().unwrap(); // idempotent repeated recovery
}
#[test]
fn crash_after_payload_commit_before_inventory_is_recoverable() {
let f = Fixture::new();
let (transaction, change) = f.replacement();
f.store.apply_change(&transaction, 0, &change).unwrap();
assert!(f.store.load().unwrap().files.is_empty());
assert!(f.store.pending().unwrap());
f.store.recover().unwrap();
assert_eq!(f.store.load().unwrap().files.len(), 1);
assert_eq!(
fs::read(f.profile.join("mods/current.jar")).unwrap(),
b"new"
);
}
#[test]
fn corrupt_staging_or_locally_changed_target_preserves_payload_and_journal() {
let f = Fixture::new();
let (transaction, _) = f.replacement();
fs::write(f.store.stage(&transaction, 0).unwrap(), b"corrupt").unwrap();
assert!(f.store.recover().is_err());
assert_eq!(
fs::read(f.profile.join("mods/current.jar")).unwrap(),
b"old"
);
assert!(f.store.pending().unwrap());
fs::write(f.store.stage(&transaction, 0).unwrap(), b"new").unwrap();
fs::write(f.profile.join("mods/current.jar"), b"user").unwrap();
assert!(f.store.recover().is_err());
assert_eq!(
fs::read(f.profile.join("mods/current.jar")).unwrap(),
b"user"
);
assert!(f.store.pending().unwrap());
}
#[test]
fn validates_all_transitions_before_resuming_any_remaining_move() {
let f = Fixture::new();
fs::write(f.profile.join("mods/first.jar"), b"first").unwrap();
fs::write(f.profile.join("mods/second.jar"), b"second").unwrap();
let transaction = f.store.transaction().unwrap();
let changes = vec![
Change {
path: "mods/first.jar".into(),
before: Some(fp(b"first")),
after: None,
},
Change {
path: "mods/second.jar".into(),
before: Some(fp(b"second")),
after: None,
},
];
f.store
.prepare(transaction, changes, Inventory::default())
.unwrap();
fs::write(f.profile.join("mods/second.jar"), b"edits").unwrap();
assert!(f.store.recover().is_err());
assert_eq!(
fs::read(f.profile.join("mods/first.jar")).unwrap(),
b"first"
);
assert_eq!(
fs::read(f.profile.join("mods/second.jar")).unwrap(),
b"edits"
);
}
#[test]
fn pending_transaction_prevents_ready_even_if_current_manifest_files_match() {
let f = Fixture::new();
let (transaction, change) = f.replacement();
f.store.apply_change(&transaction, 0, &change).unwrap();
let manifest = crate::manifest::validate_json(&format!(r#"{{"schemaVersion":1,"id":"aeronautics","displayName":"Test","minecraft":{{"version":"1.21.1","loader":{{"kind":"neoforge","version":"21.1.248"}},"javaMajor":21}},"files":[{{"path":"mods/current.jar","url":"https://cdn.shacraft.ru/current.jar","size":3,"sha256":"{}","policy":"managed"}}]}}"#, fp(b"new").sha256)).unwrap();
let inspection = crate::profile::inspect(&f.profile, &manifest).unwrap();
assert!(inspection.pending_update);
assert!(!inspection.up_to_date);
f.store.recover().unwrap();
assert!(
crate::profile::inspect(&f.profile, &manifest)
.unwrap()
.up_to_date
);
}
#[test]
fn matching_file_created_during_staging_is_not_adopted() {
let f = Fixture::new();
let transaction = f.store.transaction().unwrap();
fs::write(f.store.stage(&transaction, 0).unwrap(), b"new").unwrap();
let mut next = Inventory::default();
next.files.insert(
"mods/new.jar".into(),
OwnedFile {
fingerprint: fp(b"new"),
snapshot: "a".repeat(64),
},
);
f.store
.prepare(
transaction,
vec![Change {
path: "mods/new.jar".into(),
before: None,
after: Some(fp(b"new")),
}],
next,
)
.unwrap();
fs::write(f.profile.join("mods/new.jar"), b"new").unwrap();
assert!(f.store.recover().is_err());
assert!(f.store.load().unwrap().files.is_empty());
assert_eq!(fs::read(f.profile.join("mods/new.jar")).unwrap(), b"new");
assert!(f.store.pending().unwrap());
}
#[test]
fn corrupt_or_unsafe_inventory_fails_closed_without_adoption() {
let f = Fixture::new();
f.store.transaction().unwrap();
fs::write(f.store.root.join("inventory.json"), b"broken JSON").unwrap();
assert!(f.store.load().is_err());
let mut inventory = Inventory::default();
inventory.files.insert(
"../outside.jar".into(),
OwnedFile {
fingerprint: fp(b"outside"),
snapshot: "a".repeat(64),
},
);
fs::write(
f.store.root.join("inventory.json"),
serde_json::to_vec(&inventory).unwrap(),
)
.unwrap();
assert!(f.store.load().is_err());
}
#[cfg(unix)]
#[test]
fn linked_state_and_payload_are_refused() {
use std::os::unix::fs::symlink;
let f = Fixture::new();
let outside = f.base.join("outside");
fs::create_dir(&outside).unwrap();
symlink(&outside, &f.store.root).unwrap();
assert!(Store::open(&f.profile).is_err());
fs::remove_file(&f.store.root).unwrap();
symlink(&outside, f.profile.join("mods/linked.jar")).unwrap();
assert!(checked_path(&f.profile, "mods/linked.jar").is_err());
}
}
+57 -6
View File
@@ -60,18 +60,28 @@ pub fn ensure_java(
required_major: u8,
on_progress: &ProgressCallback,
) -> Result<JavaInstallation, EnsureJavaError> {
if let Some(installation) = detect() {
if installation.major == required_major {
on_progress(1, 1);
return Ok(installation);
}
if let Some(installation) = find_matching(candidates(), required_major, check_candidate) {
on_progress(1, 1);
return Ok(installation);
}
let executable = runtime::ensure_runtime(client, runtime_root, required_major, on_progress)
.map_err(EnsureJavaError::Provisioning)?;
check_candidate(executable.clone())
.filter(|installation| installation.major == required_major)
.ok_or(EnsureJavaError::ProvisionedButUnrecognised(executable))
}
fn find_matching(
candidates: impl IntoIterator<Item = PathBuf>,
required_major: u8,
mut inspect: impl FnMut(PathBuf) -> Option<JavaInstallation>,
) -> Option<JavaInstallation> {
candidates
.into_iter()
.filter_map(&mut inspect)
.find(|installation| installation.major == required_major)
}
fn candidates() -> Vec<PathBuf> {
let executable = if cfg!(target_os = "windows") {
"java.exe"
@@ -127,7 +137,48 @@ fn parse_major(version: &str) -> Option<u8> {
#[cfg(test)]
mod tests {
use super::{parse_major, parse_version};
use super::*;
#[test]
fn matching_path_runtime_is_not_hidden_by_wrong_java_home() {
let candidates = ["JAVA_HOME", "PATH"].map(PathBuf::from);
let found = find_matching(candidates, 21, |path| {
let major = if path == Path::new("JAVA_HOME") {
17
} else {
21
};
Some(JavaInstallation {
executable: path.display().to_string(),
major,
version: major.to_string(),
})
})
.unwrap();
assert_eq!(found.executable, "PATH");
}
#[test]
fn matching_home_remains_preferred_and_unusable_candidates_are_skipped() {
let found = find_matching(["broken", "home", "path"].map(PathBuf::from), 21, |path| {
assert_ne!(path, Path::new("path"), "must stop at matching JAVA_HOME");
(path != Path::new("broken")).then(|| JavaInstallation {
executable: path.display().to_string(),
major: 21,
version: "21".into(),
})
})
.unwrap();
assert_eq!(found.executable, "home");
assert!(find_matching([PathBuf::from("newer")], 21, |path| Some(
JavaInstallation {
executable: path.display().to_string(),
major: 25,
version: "25".into()
}
))
.is_none());
}
#[test]
fn parses_modern_java_version() {
+8
View File
@@ -47,6 +47,8 @@ pub struct LaunchRequest<'a> {
pub identity: &'a PlayerIdentity,
pub memory_mb: u16,
pub log_path: &'a Path,
/// Short-lived onboarding grant; never persisted or placed in command-line arguments.
pub onboarding_token: Option<&'a str>,
}
fn classpath_separator() -> &'static str {
@@ -244,6 +246,12 @@ pub fn launch(request: &LaunchRequest) -> Result<Child, LaunchError> {
command.arg(substitute(&argument, &vars));
}
command.current_dir(request.profile_dir);
// Do not inherit a stale grant from the launcher process environment.
command.env_remove("SHACRAFT_ONBOARDING_TOKEN");
if let Some(token) = request.onboarding_token {
command.env("SHACRAFT_ONBOARDING_TOKEN", token);
}
command.stdin(Stdio::null());
let log_file = fs::File::create(request.log_path)?;
command.stdout(Stdio::from(log_file.try_clone()?));
+45 -1
View File
@@ -1,4 +1,9 @@
mod download;
mod update_guard;
mod updater;
use tauri::Manager;
mod installation_lock;
mod inventory;
mod java;
mod launch;
mod manifest;
@@ -20,7 +25,42 @@ mod operations;
pub fn run() {
tauri::Builder::default()
.manage(operations::LauncherOperations::default())
.manage(updater::UpdaterState::default())
.plugin(
tauri_plugin_updater::Builder::new()
.pubkey(updater::configured_key().unwrap_or(""))
.build(),
)
.setup(|app| {
let directory = app.path().app_data_dir()?;
let instance =
update_guard::InstanceGuard::acquire(&directory, env!("CARGO_PKG_VERSION"))
.map_err(std::io::Error::other)?;
if let Some(reason) = instance.recovery_reason() {
app.state::<operations::LauncherOperations>()
.latch_recovery(reason);
}
app.manage(instance);
Ok(())
})
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
if window
.app_handle()
.state::<operations::LauncherOperations>()
.lifecycle
.is_updating()
{
api.prevent_close();
}
}
})
.invoke_handler(tauri::generate_handler![
commands::updater::updater_status,
commands::updater::updater_check,
commands::updater::updater_download_install,
commands::updater::updater_restart,
commands::updater::updater_open_release_page,
commands::host::native_host,
commands::host::detect_java,
commands::host::microsoft_login_available,
@@ -28,6 +68,9 @@ pub fn run() {
commands::profiles::inspect_remote_profile,
commands::profiles::sync_remote_profile,
commands::profiles::get_server_status,
commands::profiles::profile_metadata,
commands::profiles::legacy_mods,
commands::profiles::backup_legacy_mods,
commands::preferences::load_settings,
commands::preferences::save_settings,
commands::shacraft::shacraft_authenticate,
@@ -39,7 +82,8 @@ pub fn run() {
commands::account::get_account,
commands::account::logout,
commands::game::ensure_game_installed,
commands::game::launch_game
commands::game::launch_game,
commands::game::launch_onboarding
])
.run(tauri::generate_context!())
.expect("error while running ShaCraft Launcher");
+58 -93
View File
@@ -25,6 +25,9 @@
//! arguments already present on the merged profile) and is intentionally
//! never added to our own classpath.
#[path = "neoforge_repair.rs"]
mod repair;
use crate::download::{self, Checksum, DownloadError, ProgressCallback};
use crate::mojang::VersionJson;
use reqwest::blocking::Client;
@@ -56,6 +59,7 @@ pub enum NeoForgeError {
Download(DownloadError),
Io(io::Error),
InvalidJson(serde_json::Error),
InvalidInstallation(String),
InstallerFailed {
exit_code: Option<i32>,
output_tail: String,
@@ -76,6 +80,9 @@ impl fmt::Display for NeoForgeError {
Self::Download(error) => write!(formatter, "{error}"),
Self::Io(error) => write!(formatter, "I/O error: {error}"),
Self::InvalidJson(error) => write!(formatter, "invalid NeoForge version JSON: {error}"),
Self::InvalidInstallation(message) => {
write!(formatter, "invalid NeoForge installation: {message}")
}
Self::InstallerFailed {
exit_code,
output_tail,
@@ -166,25 +173,8 @@ pub fn installed_version_json_path(game_dir: &Path, loader_version: &str) -> Pat
.join(format!("neoforge-{loader_version}.json"))
}
fn patched_client_path(game_dir: &Path, loader_version: &str) -> PathBuf {
game_dir
.join("libraries/net/neoforged/neoforge")
.join(loader_version)
.join(format!("neoforge-{loader_version}-client.jar"))
}
fn is_nonempty_file(path: &Path) -> bool {
path.metadata()
.is_ok_and(|metadata| metadata.is_file() && metadata.len() > 0)
}
fn installation_complete(game_dir: &Path, loader_version: &str) -> bool {
is_nonempty_file(&installed_version_json_path(game_dir, loader_version))
&& is_nonempty_file(&patched_client_path(game_dir, loader_version))
}
/// The installer jar bundles its own `install_profile.json`, which lists
/// exactly which libraries it will download and which processors it will
/// which libraries it may download and which processors it may
/// run to patch the client — the same manifest the installer itself reads.
/// Reading it upfront gives a real, version-agnostic total for progress
/// reporting instead of a guessed constant.
@@ -316,66 +306,51 @@ fn run_installer_with_progress(
Ok((status.code(), tail))
}
/// Ensures NeoForge `loader_version` is installed into the shared
/// `game_dir` (vanilla libraries/version must already be there so the
/// installer can reuse them). No-op if already installed. Runs the
/// installer headlessly with `java_executable`; its own network calls go
/// straight to `maven.neoforged.net`/Mojang, outside our control, which is
/// an accepted trust delegation to NeoForge's official tooling once the
/// installer binary itself is SHA-256 verified. `on_progress` reports real
/// progress (installer-confirmed library downloads plus patch-processor
/// steps, read from the installer's own `install_profile.json`) while it
/// runs; it fires once with `(1, 1)` when already installed.
/// Verifies a generated installation against its provenance receipt. Legacy
/// installations and corrupt outputs are rebuilt by the verified official
/// installer in an empty staging directory. The caller must ensure vanilla's
/// client JAR first; the staged copy is checked against `vanilla` again before
/// any processor runs. No existing generated artifacts are adopted as trusted.
pub fn ensure_client_installed(
client: &Client,
java_executable: &Path,
game_dir: &Path,
cache_dir: &Path,
loader_version: &str,
vanilla: &VersionJson,
on_progress: &ProgressCallback,
) -> Result<VersionJson, NeoForgeError> {
let version_json_path = installed_version_json_path(game_dir, loader_version);
if !installation_complete(game_dir, loader_version) {
ensure_launcher_profiles_stub(game_dir)?;
let installer_path = ensure_installer(client, cache_dir, loader_version)?;
// A leftover version JSON makes some installer versions treat the
// profile as already installed even when the patched client was
// deleted or quarantined. Remove only that generated marker so the
// official installer is forced to rebuild the incomplete profile.
match fs::remove_file(&version_json_path) {
Ok(()) => {}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(NeoForgeError::Io(error)),
}
let (total_libraries, total_processors) =
read_install_profile_counts(&installer_path).unwrap_or((0, 0));
let total = (total_libraries + total_processors).max(1);
on_progress(0, total);
let (exit_code, tail) = run_installer_with_progress(
java_executable,
&installer_path,
game_dir,
cache_dir,
total_libraries,
total,
on_progress,
)?;
if !installation_complete(game_dir, loader_version) {
return Err(NeoForgeError::InstallerFailed {
exit_code,
output_tail: tail,
});
}
on_progress(total, total);
} else {
let installer_path = ensure_installer(client, cache_dir, loader_version)?;
let mut rebuilt = false;
let version = repair::ensure(
&installer_path,
game_dir,
cache_dir,
loader_version,
vanilla,
|stage| {
rebuilt = true;
let (total_libraries, total_processors) =
read_install_profile_counts(&installer_path).unwrap_or((0, 0));
let total = (total_libraries + total_processors).max(1);
on_progress(0, total);
run_installer_with_progress(
java_executable,
&installer_path,
stage,
cache_dir,
total_libraries,
total,
on_progress,
)?;
on_progress(total, total);
Ok(())
},
)?;
if !rebuilt {
on_progress(1, 1);
}
let bytes = fs::read(&version_json_path)?;
serde_json::from_slice(&bytes).map_err(NeoForgeError::InvalidJson)
Ok(version)
}
#[cfg(test)]
@@ -412,25 +387,6 @@ mod tests {
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn incomplete_install_is_not_accepted() {
let dir = std::env::temp_dir().join(format!(
"shacraft-neoforge-completeness-test-{}",
std::process::id()
));
let version = "21.1.248";
let json = installed_version_json_path(&dir, version);
fs::create_dir_all(json.parent().unwrap()).unwrap();
fs::write(&json, b"{}").unwrap();
assert!(!installation_complete(&dir, version));
let client = patched_client_path(&dir, version);
fs::create_dir_all(client.parent().unwrap()).unwrap();
fs::write(&client, b"patched").unwrap();
assert!(installation_complete(&dir, version));
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn observe_installer_line_counts_downloads_and_processor_headers() {
let downloads_done = AtomicU64::new(0);
@@ -492,8 +448,7 @@ mod tests {
/// Full live pipeline: provisions a real Java 21 (runtime.rs) if none
/// is already usable, then runs the real NeoForge 21.1.248 installer
/// into an empty game dir (it fetches and patches vanilla 1.21.1
/// itself — confirmed manually, no pre-seeding needed) and checks the
/// into a staging game dir with a verified vanilla 1.21.1 input and checks the
/// installed profile merges into a launch-shaped spec together with a
/// separately-fetched vanilla version JSON (mojang.rs), exactly as
/// `lib.rs`'s `ensure_game_installed` command will do it. Not run by
@@ -504,8 +459,10 @@ mod tests {
use crate::{java, mojang};
let client = Client::builder().build().unwrap();
let root =
std::env::temp_dir().join(format!("shacraft-neoforge-pipeline-{}", std::process::id()));
let root = std::env::temp_dir()
.canonicalize()
.unwrap()
.join(format!("shacraft-neoforge-pipeline-{}", std::process::id()));
let game_dir = root.join("game");
let cache_dir = root.join("cache");
fs::create_dir_all(&cache_dir).unwrap();
@@ -518,8 +475,14 @@ mod tests {
let java_install =
java::ensure_java(&client, &root.join("runtime"), 21, &no_progress).unwrap();
// The installer fetches and patches vanilla itself; we don't
// pre-download it. It only needs a Java runtime and an empty dir.
// Verify vanilla before the installer is allowed to use it.
mojang::ensure_client_jar(
&client,
&game_dir,
&vanilla.id,
&vanilla.downloads.as_ref().unwrap().client,
)
.unwrap();
let progress_calls: Arc<Mutex<Vec<(u64, u64)>>> = Arc::new(Mutex::new(Vec::new()));
let progress: ProgressCallback = {
let progress_calls = Arc::clone(&progress_calls);
@@ -531,6 +494,7 @@ mod tests {
&game_dir,
&cache_dir,
"21.1.248",
&vanilla,
&progress,
)
.unwrap();
@@ -577,6 +541,7 @@ mod tests {
&game_dir,
&cache_dir,
"21.1.248",
&vanilla,
&no_progress,
)
.unwrap();
+765
View File
@@ -0,0 +1,765 @@
//! Local provenance for outputs created by the verified official installer.
//! No receipt is ever bootstrapped by hashing an unknown legacy installation.
//! The receipt is a final commit marker, not a vendor signature for output jars.
use super::{ensure_launcher_profiles_stub, installed_version_json_path, NeoForgeError};
use crate::{download, manifest::is_portable_component, mojang::VersionJson, storage};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::{
collections::{BTreeMap, BTreeSet},
fs, io,
io::Read,
path::{Path, PathBuf},
sync::atomic::{AtomicU64, Ordering},
};
const MAX_METADATA: u64 = 4 * 1024 * 1024;
static NEXT_STAGE: AtomicU64 = AtomicU64::new(0);
fn invalid(message: impl Into<String>) -> NeoForgeError {
NeoForgeError::InvalidInstallation(message.into())
}
/// Refuse links in every existing ancestor, including launcher root ancestors.
/// Same-user concurrent path substitution remains outside the OS trust model.
fn safe_path(root: &Path, relative: &str) -> Result<PathBuf, NeoForgeError> {
if relative.is_empty() || !relative.split('/').all(is_portable_component) {
return Err(invalid("unsafe NeoForge artifact path"));
}
let target = root.join(relative);
for path in target.ancestors() {
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() => {
return Err(invalid(format!(
"symlink in NeoForge path: {}",
path.display()
)));
}
Ok(_) => {}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
}
Ok(target)
}
fn bounded_read(path: &Path) -> Result<Vec<u8>, NeoForgeError> {
let mut bytes = Vec::new();
fs::File::open(path)?
.take(MAX_METADATA + 1)
.read_to_end(&mut bytes)?;
if bytes.len() as u64 > MAX_METADATA {
return Err(invalid("NeoForge metadata is too large"));
}
Ok(bytes)
}
fn embedded(archive: &mut zip::ZipArchive<fs::File>, name: &str) -> Result<Vec<u8>, NeoForgeError> {
let entry = archive
.by_name(name)
.map_err(|error| invalid(error.to_string()))?;
let mut bytes = Vec::new();
entry.take(MAX_METADATA + 1).read_to_end(&mut bytes)?;
if bytes.len() as u64 > MAX_METADATA {
return Err(invalid("embedded NeoForge metadata is too large"));
}
Ok(bytes)
}
fn hash_bytes(bytes: &[u8]) -> String {
format!("{:x}", Sha256::digest(bytes))
}
fn valid_version(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 128
&& is_portable_component(value)
&& value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b".-_".contains(&b))
}
/// Only provider-owned Maven outputs are published. Archive paths, absolute
/// arguments, ROOT substitutions and client-controlled filenames are rejected.
fn coordinate_path(coordinate: &str) -> Result<String, NeoForgeError> {
let coordinate = coordinate
.strip_prefix('[')
.and_then(|s| s.strip_suffix(']'))
.ok_or_else(|| invalid("unsupported NeoForge output coordinate"))?;
let (coordinate, extension) = coordinate.split_once('@').unwrap_or((coordinate, "jar"));
let parts: Vec<_> = coordinate.split(':').collect();
if !(3..=4).contains(&parts.len())
|| !parts.iter().all(|s| valid_version(s))
|| !matches!(parts[0], "net.minecraft" | "net.neoforged")
|| !matches!(extension, "jar" | "txt")
{
return Err(invalid("unsupported NeoForge output coordinate"));
}
let classifier = parts.get(3).map(|v| format!("-{v}")).unwrap_or_default();
let relative = format!(
"libraries/{}/{}/{}/{}-{}{classifier}.{extension}",
parts[0].replace('.', "/"),
parts[1],
parts[2],
parts[1],
parts[2]
);
if !relative.split('/').all(is_portable_component) {
return Err(invalid("unsafe generated NeoForge coordinate"));
}
Ok(relative)
}
fn data_value<'a>(data: &'a Value, argument: &'a str) -> Result<&'a str, NeoForgeError> {
if let Some(key) = argument.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
data.get(key)
.and_then(|v| v.get("client"))
.and_then(Value::as_str)
.ok_or_else(|| invalid(format!("missing client recipe value: {key}")))
} else {
Ok(argument)
}
}
struct Recipe {
version_bytes: Vec<u8>,
version_relative: String,
outputs: BTreeMap<String, Option<String>>,
identity: Identity,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
struct Identity {
schema: u32,
loader: String,
minecraft: String,
installer_sha256: String,
recipe_sha256: String,
vanilla_sha1: String,
vanilla_size: u64,
}
#[derive(Debug, Serialize, Deserialize)]
struct Receipt {
identity: Identity,
files: BTreeMap<String, FileDigest>,
}
#[derive(Debug, Serialize, Deserialize)]
struct FileDigest {
size: u64,
sha256: String,
}
impl Recipe {
fn read(installer: &Path, loader: &str, vanilla: &VersionJson) -> Result<Self, NeoForgeError> {
if !valid_version(loader) || !valid_version(&vanilla.id) {
return Err(invalid("invalid Minecraft or NeoForge version"));
}
let mut archive = zip::ZipArchive::new(fs::File::open(installer)?)
.map_err(|error| invalid(error.to_string()))?;
let profile_bytes = embedded(&mut archive, "install_profile.json")?;
let version_bytes = embedded(&mut archive, "version.json")?;
let profile: Value =
serde_json::from_slice(&profile_bytes).map_err(NeoForgeError::InvalidJson)?;
let version: Value =
serde_json::from_slice(&version_bytes).map_err(NeoForgeError::InvalidJson)?;
let id = format!("neoforge-{loader}");
if profile["spec"] != 1
|| profile["version"] != id
|| profile["minecraft"] != vanilla.id
|| profile["json"] != "/version.json"
|| version["id"] != id
|| version["inheritsFrom"] != vanilla.id
{
return Err(invalid(
"installer recipe does not match selected Minecraft/NeoForge",
));
}
serde_json::from_slice::<VersionJson>(&version_bytes)
.map_err(NeoForgeError::InvalidJson)?;
let data = &profile["data"];
let processors = profile["processors"]
.as_array()
.ok_or_else(|| invalid("missing processors"))?;
let mut outputs = BTreeMap::new();
for processor in processors {
if let Some(sides) = processor.get("sides") {
let sides = sides
.as_array()
.ok_or_else(|| invalid("invalid processor sides"))?;
if !sides.iter().any(|side| side == "client") {
continue;
}
}
let args = processor["args"]
.as_array()
.ok_or_else(|| invalid("missing processor args"))?;
for pair in args.windows(2) {
if matches!(pair[0].as_str(), Some("--output" | "--slim" | "--extra")) {
let argument = pair[1]
.as_str()
.ok_or_else(|| invalid("invalid output argument"))?;
let path = coordinate_path(data_value(data, argument)?)?;
outputs.entry(path).or_insert(None);
}
}
if let Some(expected) = processor.get("outputs") {
for (argument, digest) in expected
.as_object()
.ok_or_else(|| invalid("invalid processor outputs"))?
{
let path = coordinate_path(data_value(data, argument)?)?;
let digest = data_value(
data,
digest
.as_str()
.ok_or_else(|| invalid("invalid output hash"))?,
)?;
let digest = digest
.strip_prefix('\'')
.and_then(|s| s.strip_suffix('\''))
.unwrap_or(digest);
if digest.len() != 40 || !digest.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(invalid("unsupported processor output checksum"));
}
if let Some(Some(existing)) = outputs.get(&path) {
if existing != &digest.to_ascii_lowercase() {
return Err(invalid("conflicting output hashes"));
}
}
outputs.insert(path, Some(digest.to_ascii_lowercase()));
}
}
}
let mut portable_paths = BTreeSet::new();
if outputs
.keys()
.any(|path| !portable_paths.insert(path.to_ascii_lowercase()))
{
return Err(invalid(
"generated output paths collide on a case-insensitive filesystem",
));
}
let patched = coordinate_path(data_value(data, "{PATCHED}")?)?;
let expected_patched =
format!("libraries/net/neoforged/neoforge/{loader}/neoforge-{loader}-client.jar");
let extra = coordinate_path(data_value(data, "{MC_EXTRA}")?)?;
if patched != expected_patched
|| !outputs.contains_key(&patched)
|| !outputs.contains_key(&extra)
{
return Err(invalid(
"unsupported recipe: missing patched client or extra output",
));
}
let client = &vanilla
.downloads
.as_ref()
.ok_or_else(|| invalid("missing verified vanilla download"))?
.client;
if client.size == 0
|| client.sha1.len() != 40
|| !client.sha1.bytes().all(|b| b.is_ascii_hexdigit())
{
return Err(invalid("invalid verified vanilla identity"));
}
Ok(Self {
version_relative: format!("versions/{id}/{id}.json"),
version_bytes,
outputs,
identity: Identity {
schema: 1,
loader: loader.into(),
minecraft: vanilla.id.clone(),
installer_sha256: download::file_hashes(installer)?.1,
recipe_sha256: hash_bytes(&profile_bytes),
vanilla_sha1: client.sha1.to_ascii_lowercase(),
vanilla_size: client.size,
},
})
}
fn paths(&self) -> impl Iterator<Item = &String> {
self.outputs
.keys()
.chain(std::iter::once(&self.version_relative))
}
fn current(&self, root: &Path, receipt_path: &Path) -> Result<bool, NeoForgeError> {
let receipt = match bounded_read(receipt_path) {
Ok(bytes) => match serde_json::from_slice::<Receipt>(&bytes) {
Ok(receipt) => receipt,
Err(_) => return Ok(false),
},
Err(NeoForgeError::Io(e)) if e.kind() == io::ErrorKind::NotFound => return Ok(false),
Err(NeoForgeError::InvalidInstallation(_)) => return Ok(false),
Err(error) => return Err(error),
};
if receipt.identity != self.identity || receipt.files.len() != self.outputs.len() + 1 {
return Ok(false);
}
// Enumerate trusted recipe paths, never paths claimed by the local receipt.
for relative in self.paths() {
let Some(digest) = receipt.files.get(relative) else {
return Ok(false);
};
let path = safe_path(root, relative)?;
if !download::is_current(
&path,
Some(digest.size),
&download::Checksum::Sha256(digest.sha256.clone()),
)? {
return Ok(false);
}
}
Ok(bounded_read(&safe_path(root, &self.version_relative)?)? == self.version_bytes)
}
}
struct Stage(PathBuf);
impl Stage {
fn new(cache: &Path) -> Result<Self, NeoForgeError> {
for _ in 0..128 {
let name = format!(
"neoforge-stage-{}-{}",
std::process::id(),
NEXT_STAGE.fetch_add(1, Ordering::Relaxed)
);
let path = safe_path(cache, &name)?;
fs::create_dir_all(cache)?;
match fs::create_dir(&path) {
Ok(()) => return Ok(Self(path)),
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error.into()),
}
}
Err(invalid("cannot create NeoForge staging directory"))
}
}
impl Drop for Stage {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn atomic_copy(source: &Path, target: &Path) -> Result<(), NeoForgeError> {
let mut source = fs::File::open(source)?;
let mut output = storage::AtomicFile::new(target)?;
io::copy(&mut source, output.writer())?;
output.commit()?;
Ok(())
}
fn validate_output(path: &Path, expected_sha1: Option<&str>) -> Result<FileDigest, NeoForgeError> {
let metadata = fs::metadata(path)?;
if !metadata.is_file() || metadata.len() == 0 {
return Err(invalid("empty generated artifact"));
}
if path.extension().is_some_and(|ext| ext == "jar") {
let mut archive = zip::ZipArchive::new(fs::File::open(path)?)
.map_err(|error| invalid(error.to_string()))?;
if archive.is_empty() {
return Err(invalid("empty generated jar"));
}
// Reading every entry validates ZIP checksums, not just its directory.
for index in 0..archive.len() {
let mut entry = archive
.by_index(index)
.map_err(|error| invalid(error.to_string()))?;
io::copy(&mut entry, &mut io::sink())?;
}
}
let (sha1, sha256) = download::file_hashes(path)?;
if expected_sha1.is_some_and(|expected| !expected.eq_ignore_ascii_case(&sha1)) {
return Err(invalid(
"generated artifact differs from recipe output checksum",
));
}
Ok(FileDigest {
size: metadata.len(),
sha256,
})
}
/// `installer` is supplied only by ensure_installer, after fixed-host SHA-256
/// verification. Tests inject a synthetic archive and a bounded fake runner.
/// A failed promotion has no receipt; next invocation rebuilds from scratch.
pub(super) fn ensure(
installer: &Path,
game: &Path,
cache: &Path,
loader: &str,
vanilla: &VersionJson,
run: impl FnOnce(&Path) -> Result<(), NeoForgeError>,
) -> Result<VersionJson, NeoForgeError> {
let recipe = Recipe::read(installer, loader, vanilla)?;
let receipt_path = safe_path(cache, &format!("neoforge-receipts/{loader}.json"))?;
if recipe.current(game, &receipt_path)? {
return serde_json::from_slice(&recipe.version_bytes).map_err(NeoForgeError::InvalidJson);
}
// Invalidate before changing any output. Even interruption during multi-file
// promotion cannot leave a complete receipt over a partial installation.
match fs::remove_file(&receipt_path) {
Ok(()) => {}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
// Validate all destination paths before invoking the installer.
for relative in recipe.paths() {
safe_path(game, relative)?;
}
let stage = Stage::new(cache)?;
ensure_launcher_profiles_stub(&stage.0)?;
let vanilla_relative = format!("versions/{0}/{0}.jar", vanilla.id);
let source = safe_path(game, &vanilla_relative)?;
let input = safe_path(&stage.0, &vanilla_relative)?;
atomic_copy(&source, &input)?;
if !download::is_current(
&input,
Some(recipe.identity.vanilla_size),
&download::Checksum::Sha1(recipe.identity.vanilla_sha1.clone()),
)? {
return Err(invalid(
"vanilla input changed or was not verified before NeoForge installation",
));
}
run(&stage.0)?;
let version = safe_path(&stage.0, &recipe.version_relative)?;
if bounded_read(&version)? != recipe.version_bytes {
return Err(invalid("installer produced unexpected version JSON"));
}
let mut files = BTreeMap::new();
for (relative, sha1) in &recipe.outputs {
files.insert(
relative.clone(),
validate_output(&safe_path(&stage.0, relative)?, sha1.as_deref())?,
);
}
files.insert(
recipe.version_relative.clone(),
FileDigest {
size: recipe.version_bytes.len() as u64,
sha256: hash_bytes(&recipe.version_bytes),
},
);
for relative in recipe.paths() {
atomic_copy(&safe_path(&stage.0, relative)?, &safe_path(game, relative)?)?;
}
let receipt = Receipt {
identity: recipe.identity,
files,
};
storage::write_atomic(
&receipt_path,
&serde_json::to_vec(&receipt).map_err(NeoForgeError::InvalidJson)?,
)?;
// Use the same expected bytes for merge as for receipt validation.
debug_assert_eq!(
installed_version_json_path(game, loader),
game.join(&recipe.version_relative)
);
serde_json::from_slice(&recipe.version_bytes).map_err(NeoForgeError::InvalidJson)
}
#[cfg(test)]
mod tests {
use super::*;
use std::{
cell::Cell,
io::{Cursor, Write},
};
use zip::write::SimpleFileOptions;
const LOADER: &str = "21.1.248";
fn jar_bytes(contents: &[u8]) -> Vec<u8> {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
zip.start_file("fixture.class", SimpleFileOptions::default())
.unwrap();
zip.write_all(contents).unwrap();
zip.finish().unwrap().into_inner()
}
struct Fixture {
_root: Stage,
installer: PathBuf,
game: PathBuf,
cache: PathBuf,
vanilla: VersionJson,
profile: Value,
version: Vec<u8>,
}
impl Fixture {
fn new() -> Self {
// macOS temporary directories may use the system /var -> /private/var
// alias. Canonicalize only this trusted fixture anchor; production
// safe_path must continue rejecting root/descendant substitutions.
let temporary_root = std::env::temp_dir().canonicalize().unwrap();
let root = Stage::new(&temporary_root).unwrap();
let game = root.0.join("game");
let cache = root.0.join("cache");
let input = jar_bytes(b"verified vanilla");
let input_path = game.join("versions/1.21.1/1.21.1.jar");
fs::create_dir_all(input_path.parent().unwrap()).unwrap();
fs::write(&input_path, &input).unwrap();
let vanilla = serde_json::from_value(serde_json::json!({
"id":"1.21.1", "mainClass":"Main", "downloads":{"client":{
"sha1":download::file_hashes(&input_path).unwrap().0,
"size":input.len(), "url":"https://piston-data.mojang.com/client.jar"
}}
}))
.unwrap();
let profile = serde_json::json!({
"spec":1, "version":"neoforge-21.1.248", "minecraft":"1.21.1", "json":"/version.json",
"data":{
"PATCHED":{"client":"[net.neoforged:neoforge:21.1.248:client]"},
"MC_EXTRA":{"client":"[net.minecraft:client:1.21.1-20240808.144430:extra]"},
"MAPPINGS":{"client":"[net.neoforged:neoform:1.21.1-20240808.144430:mappings@txt]"}
},
"processors":[
{"sides":["server"],"args":["--output","{ROOT}/run.sh"]},
{"args":["--output","{MAPPINGS}"]},
{"sides":["client"],"args":["--extra","{MC_EXTRA}"]},
{"args":["--output","{PATCHED}"]}
], "libraries":[]
});
let version = serde_json::to_vec(&serde_json::json!({
"id":"neoforge-21.1.248", "inheritsFrom":"1.21.1", "mainClass":"Main", "libraries":[]
})).unwrap();
let fixture = Self {
installer: root.0.join("installer.jar"),
_root: root,
game,
cache,
vanilla,
profile,
version,
};
fixture.write_installer();
fixture
}
fn write_installer(&self) {
let mut archive = zip::ZipWriter::new(fs::File::create(&self.installer).unwrap());
for (name, bytes) in [
(
"install_profile.json",
serde_json::to_vec(&self.profile).unwrap(),
),
("version.json", self.version.clone()),
] {
archive
.start_file(name, SimpleFileOptions::default())
.unwrap();
archive.write_all(&bytes).unwrap();
}
archive.finish().unwrap();
}
fn receipt(&self) -> PathBuf {
self.cache.join("neoforge-receipts/21.1.248.json")
}
fn patched(&self) -> PathBuf {
self.game
.join("libraries/net/neoforged/neoforge/21.1.248/neoforge-21.1.248-client.jar")
}
fn run(
&self,
runner: impl FnOnce(&Path) -> Result<(), NeoForgeError>,
) -> Result<VersionJson, NeoForgeError> {
ensure(
&self.installer,
&self.game,
&self.cache,
LOADER,
&self.vanilla,
runner,
)
}
fn produce(&self, stage: &Path) -> Result<(), NeoForgeError> {
let recipe = Recipe::read(&self.installer, LOADER, &self.vanilla)?;
for relative in recipe.outputs.keys() {
let path = stage.join(relative);
assert!(
!path.exists(),
"must never reuse old generated files in staging"
);
fs::create_dir_all(path.parent().unwrap())?;
fs::write(
&path,
if relative.ends_with(".jar") {
jar_bytes(b"clean generated output")
} else {
b"mappings".to_vec()
},
)?;
}
let version = stage.join(recipe.version_relative);
fs::create_dir_all(version.parent().unwrap())?;
fs::write(version, &self.version)?;
Ok(())
}
}
#[test]
fn legacy_is_rebuilt_and_healthy_receipt_skips_runner() {
let f = Fixture::new();
fs::create_dir_all(f.patched().parent().unwrap()).unwrap();
fs::write(f.patched(), b"legacy corrupt nonempty jar").unwrap();
let profile_file = f._root.0.join("profiles/aeronautics/mods/user.jar");
fs::create_dir_all(profile_file.parent().unwrap()).unwrap();
fs::write(&profile_file, b"user mod").unwrap();
f.run(|stage| f.produce(stage)).unwrap();
assert!(f.receipt().exists());
assert_ne!(
fs::read(f.patched()).unwrap(),
b"legacy corrupt nonempty jar"
);
f.run(|_| panic!("healthy receipt must not run installer"))
.unwrap();
assert_eq!(fs::read(profile_file).unwrap(), b"user mod");
}
#[test]
fn nonempty_json_and_jar_corruption_trigger_clean_rebuild() {
let f = Fixture::new();
f.run(|stage| f.produce(stage)).unwrap();
for bytes in [
b"broken json".as_slice(),
br#"{"id":"neoforge-21.1.248","mainClass":"Wrong"}"#,
] {
fs::write(installed_version_json_path(&f.game, LOADER), bytes).unwrap();
let called = Cell::new(false);
f.run(|stage| {
called.set(true);
f.produce(stage)
})
.unwrap();
assert!(called.get());
}
for bytes in [
b"not a zip".to_vec(),
jar_bytes(b"changed but valid zip"),
Vec::new(),
] {
fs::write(f.patched(), bytes).unwrap();
let called = Cell::new(false);
f.run(|stage| {
called.set(true);
f.produce(stage)
})
.unwrap();
assert!(called.get());
}
fs::remove_file(f.patched()).unwrap();
f.run(|stage| f.produce(stage)).unwrap();
}
#[test]
fn corrupt_vanilla_input_is_rejected_before_processors() {
let f = Fixture::new();
fs::write(f.game.join("versions/1.21.1/1.21.1.jar"), b"corrupt input").unwrap();
assert!(f
.run(|_| panic!("must verify vanilla before running processors"))
.is_err());
assert!(!f.receipt().exists());
}
#[test]
fn failed_runner_and_invalid_outputs_do_not_create_receipt() {
let f = Fixture::new();
assert!(f
.run(|_| Err(invalid("simulated installer failure")))
.is_err());
assert!(!f.receipt().exists());
assert!(f
.run(|stage| {
f.produce(stage)?;
fs::write(
stage.join(
"libraries/net/neoforged/neoforge/21.1.248/neoforge-21.1.248-client.jar",
),
b"nonempty damaged jar",
)?;
Ok(())
})
.is_err());
assert!(!f.receipt().exists());
f.run(|stage| f.produce(stage)).unwrap();
}
#[test]
fn missing_commit_marker_after_partial_promotion_forces_rebuild() {
let f = Fixture::new();
f.run(|stage| f.produce(stage)).unwrap();
// Equivalent persisted state to interruption after one promoted file.
fs::remove_file(f.receipt()).unwrap();
fs::write(f.patched(), jar_bytes(b"partially promoted generation")).unwrap();
let called = Cell::new(false);
f.run(|stage| {
called.set(true);
f.produce(stage)
})
.unwrap();
assert!(called.get());
f.run(|_| panic!("recovered generation must be complete"))
.unwrap();
}
#[test]
fn receipt_cannot_invent_output_paths_and_changed_recipe_rebuilds() {
let mut f = Fixture::new();
f.run(|stage| f.produce(stage)).unwrap();
let mut receipt: Value = serde_json::from_slice(&fs::read(f.receipt()).unwrap()).unwrap();
receipt["files"]["../../user.jar"] = serde_json::json!({"size":1,"sha256":"00"});
fs::write(f.receipt(), serde_json::to_vec(&receipt).unwrap()).unwrap();
f.run(|stage| f.produce(stage)).unwrap();
f.profile["recipeChange"] = Value::Bool(true);
f.write_installer();
let called = Cell::new(false);
f.run(|stage| {
called.set(true);
f.produce(stage)
})
.unwrap();
assert!(called.get());
}
#[test]
fn recipe_version_output_paths_and_authoritative_hashes_are_enforced() {
let mut f = Fixture::new();
f.profile["minecraft"] = Value::String("1.20.1".into());
f.write_installer();
assert!(f.run(|_| panic!("wrong version recipe")).is_err());
f.profile["minecraft"] = Value::String("1.21.1".into());
f.profile["data"]["PATCHED"]["client"] =
Value::String("[net.neoforged:neoforge:../escape:client]".into());
f.write_installer();
assert!(f.run(|_| panic!("escaping output")).is_err());
f.profile["data"]["PATCHED"]["client"] =
Value::String("[net.neoforged:neoforge:21.1.248:client]".into());
f.profile["processors"][3]["outputs"] =
serde_json::json!({"{PATCHED}":"0000000000000000000000000000000000000000"});
f.write_installer();
assert!(f.run(|stage| f.produce(stage)).is_err());
assert!(!f.receipt().exists());
}
#[cfg(unix)]
#[test]
fn output_symlinks_are_rejected_without_touching_target() {
let f = Fixture::new();
let outside = f._root.0.join("outside");
fs::create_dir(&outside).unwrap();
fs::write(outside.join("user"), b"untouched").unwrap();
std::os::unix::fs::symlink(&outside, f.game.join("libraries")).unwrap();
assert!(f
.run(|_| panic!("symlink rejected before installer"))
.is_err());
assert_eq!(fs::read(outside.join("user")).unwrap(), b"untouched");
assert!(!f.receipt().exists());
}
}
+63 -2
View File
@@ -3,12 +3,14 @@
//! Acquire before scheduling the worker and move the permit into it. Dropping
//! the caller's future cannot unlock an operation that is still running.
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
atomic::{AtomicBool, AtomicUsize, Ordering},
Arc, Mutex,
};
#[derive(Default)]
pub(crate) struct LauncherOperations {
pub lifecycle: Lifecycle,
recovery: Mutex<Option<String>>,
pub installation: Operation,
pub account: Operation,
pub shacraft_account: Operation,
@@ -50,3 +52,62 @@ mod tests {
assert!(operation.acquire("Installation").is_ok());
}
}
// Owned, Send permits are held by the worker, not the awaiting IPC future.
// Readers represent all native operations which can persist or launch; the
// sole writer represents launcher replacement, including its download stage.
#[derive(Default)]
pub(crate) struct Lifecycle(Arc<AtomicUsize>);
const EXCLUSIVE: usize = usize::MAX;
impl Lifecycle {
pub fn shared(&self) -> Result<SharedPermit, String> {
self.0
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |n| {
if n < EXCLUSIVE - 1 {
Some(n + 1)
} else {
None
}
})
.map_err(|_| "Лаунчер обновляется. Дождитесь завершения и перезапуска.".to_string())?;
Ok(SharedPermit(self.0.clone()))
}
pub fn exclusive(&self) -> Result<ExclusivePermit, String> {
self.0
.compare_exchange(0, EXCLUSIVE, Ordering::AcqRel, Ordering::Acquire)
.map_err(|_| {
"Завершите операцию с игрой, настройками или аккаунтом и повторите обновление."
.to_string()
})?;
Ok(ExclusivePermit(self.0.clone()))
}
pub fn is_updating(&self) -> bool {
self.0.load(Ordering::Acquire) == EXCLUSIVE
}
}
pub(crate) struct SharedPermit(Arc<AtomicUsize>);
impl Drop for SharedPermit {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::Release);
}
}
pub(crate) struct ExclusivePermit(Arc<AtomicUsize>);
impl Drop for ExclusivePermit {
fn drop(&mut self) {
self.0.store(0, Ordering::Release);
}
}
impl LauncherOperations {
/// Startup recovery is latched for this process. Removing a marker in a
/// running application is never authority to resume writes or launch.
pub fn latch_recovery(&self, reason: String) {
*self.recovery.lock().unwrap() = Some(reason);
}
pub fn ensure_writable(&self) -> Result<(), String> {
match self.recovery.lock().unwrap().as_ref() {
Some(reason) => Err(reason.clone()),
None => Ok(()),
}
}
}
+733 -78
View File
@@ -1,8 +1,12 @@
use crate::download::{self, Checksum, DownloadError};
use crate::inventory::{self, Change, Fingerprint, Inventory, OwnedFile, Store};
use crate::manifest::{is_allowed_download_url, FilePolicy, ManagedFile, Manifest};
use reqwest::{blocking::Client, redirect::Policy};
use serde::Serialize;
use serde::{Deserialize, Serialize};
#[cfg(test)]
use sha2::{Digest, Sha256};
use std::{
collections::{BTreeSet, HashSet},
fmt, fs, io,
path::{Path, PathBuf},
time::Duration,
@@ -15,6 +19,10 @@ pub struct ProfileInspection {
pub managed_files: usize,
pub missing_files: usize,
pub mismatched_files: usize,
pub stale_files: usize,
pub conflicts: Vec<String>,
pub pending_update: bool,
pub legacy_files: usize,
pub up_to_date: bool,
}
@@ -25,6 +33,7 @@ pub struct SyncResult {
pub downloaded_files: usize,
pub reused_files: usize,
pub downloaded_bytes: u64,
pub removed_files: usize,
}
#[derive(Debug)]
@@ -33,54 +42,121 @@ pub enum ProfileError {
Network(reqwest::Error),
Download { path: String, source: DownloadError },
UnsafePath(PathBuf),
Conflict(Vec<String>),
}
impl fmt::Display for ProfileError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(error) => write!(formatter, "Cannot access profile: {error}"),
Self::Network(error) => write!(formatter, "Cannot download profile file: {error}"),
Self::Download { path, source } => {
write!(formatter, "Download failed for {path}: {source}")
}
Self::UnsafePath(path) => write!(
formatter,
"Profile path contains a symbolic link: {}",
path.display()
),
Self::Download { path, source } => write!(formatter, "Download failed for {path}: {source}"),
Self::UnsafePath(path) => write!(formatter, "Unsafe or protected profile path: {}", path.display()),
Self::Conflict(paths) => write!(formatter, "Файлы изменены или не принадлежат лаунчеру; сохранены без изменений: {}. Проверьте список пользовательских модов.", paths.join(", ")),
}
}
}
fn expected_fingerprint(file: &ManagedFile) -> Fingerprint {
Fingerprint {
size: file.size,
sha256: file.sha256.to_ascii_lowercase(),
}
}
#[cfg(test)]
fn manifest_snapshot(manifest: &Manifest) -> String {
// Internal identity of the already verified manifest's installation inputs.
// This fingerprint never substitutes for remote signature verification.
let mut hash = Sha256::new();
for value in [
&manifest.id,
&manifest.minecraft.version,
&manifest.minecraft.loader.kind,
&manifest.minecraft.loader.version,
] {
hash.update(value.as_bytes());
hash.update([0]);
}
hash.update([manifest.minecraft.java_major]);
for file in &manifest.files {
for value in [&file.path, &file.url, &file.sha256] {
hash.update(value.as_bytes());
hash.update([0]);
}
hash.update(file.size.to_le_bytes());
hash.update([if file.policy == FilePolicy::Managed {
1
} else {
2
}]);
}
format!("{:x}", hash.finalize())
}
fn current(root: &Path, relative: &str) -> Result<Option<Fingerprint>, ProfileError> {
inventory::fingerprint(&managed_target(root, relative)?).map_err(ProfileError::Io)
}
pub fn inspect(root: &Path, manifest: &Manifest) -> Result<ProfileInspection, ProfileError> {
let store = Store::open(root).map_err(ProfileError::Io)?;
let owned = store.load().map_err(ProfileError::Io)?;
let pending_update = store.pending().map_err(ProfileError::Io)?;
let mut missing_files = 0;
let mut mismatched_files = 0;
let mut conflicts = Vec::new();
let paths: HashSet<_> = manifest
.files
.iter()
.map(|file| file.path.as_str())
.collect();
for expected in &manifest.files {
let path = managed_target(root, &expected.path)?;
if !path.try_exists().map_err(ProfileError::Io)? {
let actual = current(root, &expected.path)?;
if actual.is_none() {
missing_files += 1;
continue;
}
if matches!(expected.policy, FilePolicy::Seed) && path.is_file() {
if expected.policy == FilePolicy::Seed {
continue;
}
let checksum = Checksum::Sha256(expected.sha256.clone());
if !download::is_current(&path, Some(expected.size), &checksum).map_err(ProfileError::Io)? {
if actual.as_ref() != Some(&expected_fingerprint(expected)) {
mismatched_files += 1;
if owned.files.get(&expected.path).map(|f| &f.fingerprint) != actual.as_ref() {
conflicts.push(expected.path.clone());
}
}
}
let mut stale_files = 0;
for (path, previous) in &owned.files {
if paths.contains(path.as_str()) {
continue;
}
if let Some(actual) = current(root, path)? {
stale_files += 1;
if actual != previous.fingerprint {
conflicts.push(path.clone());
}
}
}
let legacy_files = legacy_mods(root, manifest, &owned)?.len();
Ok(ProfileInspection {
root: root.display().to_string(),
managed_files: manifest.files.len(),
missing_files,
mismatched_files,
up_to_date: missing_files == 0 && mismatched_files == 0,
stale_files,
pending_update,
legacy_files,
up_to_date: missing_files == 0
&& mismatched_files == 0
&& stale_files == 0
&& conflicts.is_empty()
&& !pending_update,
conflicts,
})
}
pub fn sync(root: &Path, manifest: &Manifest) -> Result<SyncResult, ProfileError> {
pub fn sync_snapshot(
root: &Path,
snapshot: &crate::remote::VerifiedSnapshot,
) -> Result<SyncResult, ProfileError> {
let client = Client::builder()
.connect_timeout(Duration::from_secs(15))
.timeout(Duration::from_secs(10 * 60))
@@ -95,64 +171,309 @@ pub fn sync(root: &Path, manifest: &Manifest) -> Result<SyncResult, ProfileError
}))
.build()
.map_err(ProfileError::Network)?;
let mut downloaded_files = 0;
let mut reused_files = 0;
let mut downloaded_bytes = 0;
for expected in &manifest.files {
let target = managed_target(root, &expected.path)?;
if matches!(expected.policy, FilePolicy::Seed) && target.is_file() {
reused_files += 1;
continue;
}
let checksum = Checksum::Sha256(expected.sha256.clone());
if download::is_current(&target, Some(expected.size), &checksum)
.map_err(ProfileError::Io)?
{
reused_files += 1;
continue;
}
let bytes = download_managed_file(&client, expected, &target)?;
downloaded_files += 1;
downloaded_bytes += bytes;
}
Ok(SyncResult {
root: root.display().to_string(),
downloaded_files,
reused_files,
downloaded_bytes,
sync_with_snapshot(root, &snapshot.manifest, &snapshot.digest, |file, stage| {
download_managed_file(&client, file, stage)
})
}
/// Reject pre-existing links in the managed subtree before inspecting or
/// replacing files. A signed relative path must not follow a local link into
/// an unrelated directory. This is not a sandbox against a hostile local user
/// changing directories concurrently under the launcher's OS identity.
fn managed_target(root: &Path, relative: &str) -> Result<PathBuf, ProfileError> {
let mut path = root.to_path_buf();
if let Some(parent) = root.parent() {
reject_symlink(parent)?;
}
reject_symlink(&path)?;
for component in relative.split('/') {
path.push(component);
reject_symlink(&path)?;
}
Ok(path)
#[cfg(test)]
fn sync_with(
root: &Path,
manifest: &Manifest,
download: impl FnMut(&ManagedFile, &Path) -> Result<u64, ProfileError>,
) -> Result<SyncResult, ProfileError> {
sync_with_snapshot(root, manifest, &manifest_snapshot(manifest), download)
}
fn reject_symlink(path: &Path) -> Result<(), ProfileError> {
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() => {
Err(ProfileError::UnsafePath(path.to_path_buf()))
fn sync_with_snapshot(
root: &Path,
manifest: &Manifest,
snapshot: &str,
mut download: impl FnMut(&ManagedFile, &Path) -> Result<u64, ProfileError>,
) -> Result<SyncResult, ProfileError> {
let store = Store::open(root).map_err(ProfileError::Io)?;
store.recover().map_err(ProfileError::Io)?;
let previous = store.load().map_err(ProfileError::Io)?;
let mut next = previous.clone();
let mut conflicts = Vec::new();
let mut changes = Vec::new();
let mut downloads: Vec<(usize, &ManagedFile)> = Vec::new();
let mut reused_files = 0;
let mut removed_files = 0;
let paths: HashSet<_> = manifest
.files
.iter()
.map(|file| file.path.as_str())
.collect();
for expected in &manifest.files {
let actual = current(root, &expected.path)?;
let fingerprint = expected_fingerprint(expected);
if expected.policy == FilePolicy::Seed && actual.is_some() {
next.files.remove(&expected.path); // relinquish managed -> seed, preserving edits
reused_files += 1;
continue;
}
if actual.as_ref() == Some(&fingerprint) {
// Matching legacy bytes prove content, never launcher ownership.
if previous.files.get(&expected.path).map(|f| &f.fingerprint) != actual.as_ref() {
next.files.remove(&expected.path);
}
reused_files += 1;
continue;
}
if actual.is_some()
&& previous.files.get(&expected.path).map(|f| &f.fingerprint) != actual.as_ref()
{
conflicts.push(expected.path.clone());
continue;
}
downloads.push((changes.len(), expected));
changes.push(Change {
path: expected.path.clone(),
before: actual,
after: Some(fingerprint.clone()),
});
if expected.policy == FilePolicy::Managed {
next.files.insert(
expected.path.clone(),
OwnedFile {
fingerprint,
snapshot: snapshot.to_owned(),
},
);
} else {
next.files.remove(&expected.path);
}
Ok(_) => Ok(()),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(ProfileError::Io(error)),
}
for (path, previous_file) in &previous.files {
if paths.contains(path.as_str()) {
continue;
}
match current(root, path)? {
None => {
next.files.remove(path);
}
Some(actual) if actual == previous_file.fingerprint => {
changes.push(Change {
path: path.clone(),
before: Some(actual),
after: None,
});
next.files.remove(path);
removed_files += 1;
}
Some(_) => conflicts.push(path.clone()),
}
}
if !conflicts.is_empty() {
return Err(ProfileError::Conflict(conflicts));
}
if changes.is_empty() && next == previous {
return Ok(SyncResult {
root: root.display().to_string(),
downloaded_files: 0,
reused_files,
downloaded_bytes: 0,
removed_files: 0,
});
}
let transaction = store.transaction().map_err(ProfileError::Io)?;
let mut downloaded_bytes = 0;
for (index, file) in &downloads {
let stage = store
.stage(&transaction, *index)
.map_err(ProfileError::Io)?;
downloaded_bytes += download(file, &stage)?;
if inventory::fingerprint(&stage)
.map_err(ProfileError::Io)?
.as_ref()
!= Some(&expected_fingerprint(file))
{
return Err(ProfileError::Io(inventory::invalid(
"Staged profile file failed verification",
)));
}
}
// Persist the whole future ownership set before the first payload change.
store
.prepare(transaction, changes, next)
.map_err(ProfileError::Io)?;
store.recover().map_err(ProfileError::Io)?;
Ok(SyncResult {
root: root.display().to_string(),
downloaded_files: downloads.len(),
reused_files,
downloaded_bytes,
removed_files,
})
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LegacyMod {
pub path: String,
pub size: u64,
pub sha256: String,
pub reason: &'static str,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct LegacySelection {
pub path: String,
pub sha256: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LegacyBackup {
pub backup_root: String,
pub files: Vec<String>,
}
fn legacy_mods(
root: &Path,
manifest: &Manifest,
owned: &Inventory,
) -> Result<Vec<LegacyMod>, ProfileError> {
let mods = managed_target(root, "mods")?;
let entries = match fs::read_dir(mods) {
Ok(entries) => entries,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(ProfileError::Io(e)),
};
let mut result = Vec::new();
for entry in entries {
let entry = entry.map_err(ProfileError::Io)?;
let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
continue;
};
if !name.to_ascii_lowercase().ends_with(".jar")
|| !crate::manifest::is_portable_component(&name)
{
continue;
}
let path = format!("mods/{name}");
let expected = manifest
.files
.iter()
.find(|f| f.path.eq_ignore_ascii_case(&path));
if expected.is_some_and(|f| f.policy == FilePolicy::Seed) {
continue;
}
let actual = current(root, &path)?
.ok_or_else(|| ProfileError::Io(inventory::invalid("Mod changed during inspection")))?;
if owned
.files
.get(&path)
.is_some_and(|f| f.fingerprint == actual)
{
continue;
}
if expected.is_some_and(|f| expected_fingerprint(f) == actual) {
continue;
}
let reason = if owned.files.contains_key(&path) {
"changed_managed"
} else if expected.is_some() {
"conflicts_with_pack"
} else {
"not_in_pack"
};
result.push(LegacyMod {
path,
size: actual.size,
sha256: actual.sha256,
reason,
});
}
result.sort_by(|a, b| a.path.cmp(&b.path));
Ok(result)
}
/// Informational list, not ownership evidence. Unknown extra mods remain in
/// place; callers must show the file/hash and obtain an explicit selection.
pub fn list_legacy_mods(root: &Path, manifest: &Manifest) -> Result<Vec<LegacyMod>, ProfileError> {
let store = Store::open(root).map_err(ProfileError::Io)?;
if store.pending().map_err(ProfileError::Io)? {
return Err(ProfileError::Io(inventory::invalid(
"Finish recovery before reviewing legacy mods",
)));
}
legacy_mods(root, manifest, &store.load().map_err(ProfileError::Io)?)
}
/// Moves only currently listed, explicitly chosen JARs with the reviewed hash.
/// No arbitrary local path, ownership adoption, seed or personal data cleanup.
pub fn backup_legacy_mods(
root: &Path,
manifest: &Manifest,
selections: &[LegacySelection],
) -> Result<LegacyBackup, ProfileError> {
if selections.is_empty() {
return Err(ProfileError::Io(inventory::invalid(
"Select at least one reviewed mod",
)));
}
let store = Store::open(root).map_err(ProfileError::Io)?;
store.recover().map_err(ProfileError::Io)?;
let owned = store.load().map_err(ProfileError::Io)?;
let candidates = legacy_mods(root, manifest, &owned)?;
let mut unique = BTreeSet::new();
let mut changes = Vec::new();
for selection in selections {
if !unique.insert(selection.path.clone()) {
return Err(ProfileError::Io(inventory::invalid(
"Duplicate selected mod",
)));
}
let candidate = candidates
.iter()
.find(|c| c.path == selection.path && c.sha256 == selection.sha256)
.ok_or_else(|| {
ProfileError::Io(inventory::invalid(
"Selected mod changed or is no longer eligible; review the list again",
))
})?;
changes.push(Change {
path: candidate.path.clone(),
before: Some(Fingerprint {
size: candidate.size,
sha256: candidate.sha256.clone(),
}),
after: None,
});
}
let transaction = store.transaction().map_err(ProfileError::Io)?;
let backup_root = store
.root
.join(&transaction)
.join("backup")
.display()
.to_string();
// Retain a path-to-index map alongside backups after the journal completes.
let map = serde_json::to_vec(&selections.iter().map(|s| &s.path).collect::<Vec<_>>())
.map_err(|e| ProfileError::Io(inventory::invalid(e.to_string())))?;
crate::storage::write_atomic(&store.root.join(&transaction).join("files.json"), &map)
.map_err(ProfileError::Io)?;
let mut next = owned;
for selection in selections {
next.files.remove(&selection.path);
}
store
.prepare(transaction, changes, next)
.map_err(ProfileError::Io)?;
store.recover().map_err(ProfileError::Io)?;
Ok(LegacyBackup {
backup_root,
files: selections.iter().map(|s| s.path.clone()).collect(),
})
}
fn managed_target(root: &Path, relative: &str) -> Result<PathBuf, ProfileError> {
if inventory::protected(relative) {
return Err(ProfileError::UnsafePath(root.join(relative)));
}
inventory::checked_path(root, relative)
.map_err(|_| ProfileError::UnsafePath(root.join(relative)))
}
fn download_managed_file(
@@ -160,18 +481,17 @@ fn download_managed_file(
expected: &ManagedFile,
target: &Path,
) -> Result<u64, ProfileError> {
let checksum = Checksum::Sha256(expected.sha256.clone());
download::download_verified(
client,
&expected.url,
target,
Some(expected.size),
&checksum,
&Checksum::Sha256(expected.sha256.clone()),
|_, _| {},
)
.map_err(|error| ProfileError::Download {
.map_err(|source| ProfileError::Download {
path: expected.path.clone(),
source: error,
source,
})
}
@@ -179,12 +499,19 @@ fn download_managed_file(
mod tests {
use super::inspect;
use crate::manifest::{FilePolicy, Loader, ManagedFile, Manifest, Minecraft};
#[cfg(test)]
use sha2::{Digest, Sha256};
use std::{
fs, process,
time::{SystemTime, UNIX_EPOCH},
};
fn trusted_temporary_root() -> std::path::PathBuf {
// Resolve only the OS-provided fixture anchor. The profile root, its
// parent and payload descendants retain their production link checks.
std::env::temp_dir().canonicalize().unwrap()
}
fn manifest(hash: String, size: u64) -> Manifest {
Manifest {
schema_version: 1,
@@ -210,7 +537,7 @@ mod tests {
#[test]
fn reports_missing_and_matching_files() {
let root = std::env::temp_dir().join(format!(
let root = trusted_temporary_root().join(format!(
"shacraft-launcher-test-{}-{}",
process::id(),
SystemTime::now()
@@ -238,7 +565,7 @@ mod tests {
#[test]
fn edited_seed_files_remain_up_to_date() {
let root = std::env::temp_dir().join(format!(
let root = trusted_temporary_root().join(format!(
"shacraft-seed-test-{}-{}",
process::id(),
SystemTime::now()
@@ -256,7 +583,7 @@ mod tests {
#[test]
fn preserves_changed_seed_files_as_current() {
let root = std::env::temp_dir().join(format!(
let root = trusted_temporary_root().join(format!(
"shacraft-launcher-seed-test-{}-{}",
process::id(),
SystemTime::now()
@@ -279,7 +606,7 @@ mod tests {
#[test]
fn refuses_linked_profile_directories() {
use std::os::unix::fs::symlink;
let root = std::env::temp_dir().join(format!(
let root = trusted_temporary_root().join(format!(
"shacraft-link-test-{}-{}",
process::id(),
SystemTime::now()
@@ -297,3 +624,331 @@ mod tests {
fs::remove_dir_all(root).unwrap();
}
}
#[cfg(test)]
mod update_tests {
use super::*;
use crate::manifest::{Loader, Minecraft};
use std::{
collections::BTreeMap,
sync::atomic::{AtomicU64, Ordering},
};
static NEXT: AtomicU64 = AtomicU64::new(0);
struct Fixture {
base: PathBuf,
root: PathBuf,
}
impl Fixture {
fn new() -> Self {
let base = std::env::temp_dir().join(format!(
"shacraft-update-{}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
let root = base.join("profiles/aeronautics");
fs::create_dir_all(&root).unwrap();
Self { base, root }
}
fn write(&self, path: &str, bytes: &[u8]) {
let target = self.root.join(path);
fs::create_dir_all(target.parent().unwrap()).unwrap();
fs::write(target, bytes).unwrap();
}
fn bytes(&self, path: &str) -> Vec<u8> {
fs::read(self.root.join(path)).unwrap()
}
fn sync(
&self,
manifest: &Manifest,
files: &[(&str, &[u8])],
) -> Result<SyncResult, ProfileError> {
let content: BTreeMap<_, _> = files.iter().copied().collect();
sync_with(&self.root, manifest, |file, target| {
let bytes = content
.get(file.path.as_str())
.expect("unexpected download");
fs::write(target, bytes).map_err(ProfileError::Io)?;
Ok(bytes.len() as u64)
})
}
}
impl Drop for Fixture {
fn drop(&mut self) {
fs::remove_dir_all(&self.base).unwrap();
}
}
fn pack(files: &[(&str, &[u8], FilePolicy)]) -> Manifest {
Manifest {
schema_version: 1,
id: "aeronautics".into(),
display_name: "Aeronautics".into(),
minecraft: Minecraft {
version: "1.21.1".into(),
loader: Loader {
kind: "neoforge".into(),
version: "21.1.248".into(),
},
java_major: 21,
},
files: files
.iter()
.map(|(path, bytes, policy)| ManagedFile {
path: (*path).into(),
url: format!("https://cdn.shacraft.ru/{path}"),
sha256: format!("{:x}", Sha256::digest(bytes)),
size: bytes.len() as u64,
policy: *policy,
})
.collect(),
}
}
#[test]
fn renamed_owned_mod_is_backed_up_and_user_mod_and_seed_survive() {
let f = Fixture::new();
let a = pack(&[
("mods/one-1.jar", b"old", FilePolicy::Managed),
("config/seed.txt", b"default", FilePolicy::Seed),
]);
f.sync(
&a,
&[("mods/one-1.jar", b"old"), ("config/seed.txt", b"default")],
)
.unwrap();
f.write("mods/personal.jar", b"mine");
f.write("config/seed.txt", b"edits");
let b = pack(&[("mods/one-2.jar", b"new", FilePolicy::Managed)]);
let inspection = inspect(&f.root, &b).unwrap();
assert_eq!(inspection.stale_files, 1);
assert!(!inspection.up_to_date);
let synced = f.sync(&b, &[("mods/one-2.jar", b"new")]).unwrap();
assert_eq!(synced.removed_files, 1);
assert!(!f.root.join("mods/one-1.jar").exists());
assert_eq!(f.bytes("mods/one-2.jar"), b"new");
assert_eq!(f.bytes("mods/personal.jar"), b"mine");
assert_eq!(f.bytes("config/seed.txt"), b"edits");
let store = Store::open(&f.root).unwrap();
let owned = store.load().unwrap();
assert_eq!(
owned.files.keys().collect::<Vec<_>>(),
vec!["mods/one-2.jar"]
);
let state_backups: Vec<_> = fs::read_dir(&store.root)
.unwrap()
.filter_map(Result::ok)
.map(|e| e.path().join("backup/1"))
.filter(|p| p.exists())
.collect();
assert_eq!(state_backups.len(), 1);
assert_eq!(fs::read(&state_backups[0]).unwrap(), b"old");
assert!(inspect(&f.root, &b).unwrap().up_to_date);
}
#[test]
fn missing_inventory_never_adopts_matching_or_extra_legacy_files() {
let f = Fixture::new();
f.write("mods/current.jar", b"pack");
f.write("mods/old.jar", b"legacy");
let a = pack(&[("mods/current.jar", b"pack", FilePolicy::Managed)]);
let result = f.sync(&a, &[]).unwrap();
assert_eq!(result.reused_files, 1);
assert!(Store::open(&f.root)
.unwrap()
.load()
.unwrap()
.files
.is_empty());
f.sync(&pack(&[]), &[]).unwrap();
assert_eq!(f.bytes("mods/current.jar"), b"pack");
assert_eq!(f.bytes("mods/old.jar"), b"legacy");
let list = list_legacy_mods(&f.root, &a).unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0].path, "mods/old.jar");
}
#[test]
fn changed_owned_file_blocks_replacement_and_retirement_without_mutation() {
let f = Fixture::new();
let a = pack(&[("mods/current.jar", b"pack", FilePolicy::Managed)]);
f.sync(&a, &[("mods/current.jar", b"pack")]).unwrap();
f.write("mods/current.jar", b"player edits");
let b = pack(&[("mods/current.jar", b"next", FilePolicy::Managed)]);
assert!(matches!(f.sync(&b, &[]), Err(ProfileError::Conflict(_))));
assert!(matches!(
f.sync(&pack(&[]), &[]),
Err(ProfileError::Conflict(_))
));
assert_eq!(f.bytes("mods/current.jar"), b"player edits");
let inspection = inspect(&f.root, &b).unwrap();
assert!(!inspection.up_to_date);
assert_eq!(inspection.conflicts, vec!["mods/current.jar"]);
assert_eq!(
list_legacy_mods(&f.root, &b).unwrap()[0].reason,
"changed_managed"
);
}
#[test]
fn failed_staging_changes_no_payload_or_ownership() {
let f = Fixture::new();
let a = pack(&[("mods/current.jar", b"old", FilePolicy::Managed)]);
f.sync(&a, &[("mods/current.jar", b"old")]).unwrap();
let b = pack(&[
("mods/current.jar", b"new", FilePolicy::Managed),
("mods/second.jar", b"two", FilePolicy::Managed),
]);
let mut downloads = 0;
let result = sync_with(&f.root, &b, |_, path| {
downloads += 1;
if downloads == 2 {
return Err(ProfileError::Io(io::Error::other("network failed")));
}
fs::write(path, b"new").unwrap();
Ok(3)
});
assert!(result.is_err());
assert_eq!(f.bytes("mods/current.jar"), b"old");
assert!(!f.root.join("mods/second.jar").exists());
assert!(!Store::open(&f.root).unwrap().pending().unwrap());
assert!(inspect(&f.root, &a).unwrap().up_to_date);
f.sync(
&b,
&[("mods/current.jar", b"new"), ("mods/second.jar", b"two")],
)
.unwrap();
assert!(inspect(&f.root, &b).unwrap().up_to_date);
}
#[test]
fn seed_policy_transitions_preserve_user_edits_and_do_not_adopt_seed() {
let f = Fixture::new();
let a = pack(&[("config/file.txt", b"old", FilePolicy::Managed)]);
f.sync(&a, &[("config/file.txt", b"old")]).unwrap();
f.write("config/file.txt", b"custom");
let seeded = pack(&[("config/file.txt", b"default", FilePolicy::Seed)]);
f.sync(&seeded, &[]).unwrap();
assert!(Store::open(&f.root)
.unwrap()
.load()
.unwrap()
.files
.is_empty());
assert_eq!(f.bytes("config/file.txt"), b"custom");
assert!(matches!(f.sync(&a, &[]), Err(ProfileError::Conflict(_))));
f.sync(&pack(&[]), &[]).unwrap();
assert_eq!(f.bytes("config/file.txt"), b"custom");
}
#[test]
fn explicit_legacy_backup_requires_current_hash_and_preserves_every_other_file() {
let f = Fixture::new();
f.write("mods/old.jar", b"old");
f.write("mods/keep.jar", b"keep");
f.write("mods/seed.jar", b"seed edits");
f.write("saves/world/level.dat", b"world");
let manifest = pack(&[("mods/seed.jar", b"seed", FilePolicy::Seed)]);
let candidates = list_legacy_mods(&f.root, &manifest).unwrap();
assert_eq!(candidates.len(), 2);
let old = candidates
.iter()
.find(|c| c.path == "mods/old.jar")
.unwrap();
let invalid = [LegacySelection {
path: old.path.clone(),
sha256: "0".repeat(64),
}];
assert!(backup_legacy_mods(&f.root, &manifest, &invalid).is_err());
let traversal = [LegacySelection {
path: "../outside.jar".into(),
sha256: old.sha256.clone(),
}];
assert!(backup_legacy_mods(&f.root, &manifest, &traversal).is_err());
let chosen = [LegacySelection {
path: old.path.clone(),
sha256: old.sha256.clone(),
}];
let backup = backup_legacy_mods(&f.root, &manifest, &chosen).unwrap();
assert_eq!(
fs::read(Path::new(&backup.backup_root).join("0")).unwrap(),
b"old"
);
assert!(!f.root.join("mods/old.jar").exists());
assert_eq!(f.bytes("mods/keep.jar"), b"keep");
assert_eq!(f.bytes("mods/seed.jar"), b"seed edits");
assert_eq!(f.bytes("saves/world/level.dat"), b"world");
assert!(Store::open(&f.root)
.unwrap()
.load()
.unwrap()
.files
.is_empty());
}
#[test]
fn new_publication_recovers_and_retires_files_from_interrupted_previous_update() {
let f = Fixture::new();
let store = Store::open(&f.root).unwrap();
let transaction = store.transaction().unwrap();
let before = pack(&[("mods/intermediate.jar", b"middle", FilePolicy::Managed)]);
let fingerprint = expected_fingerprint(&before.files[0]);
let stage = store.stage(&transaction, 0).unwrap();
fs::write(&stage, b"middle").unwrap();
let mut next = Inventory::default();
next.files.insert(
"mods/intermediate.jar".into(),
OwnedFile {
fingerprint: fingerprint.clone(),
snapshot: manifest_snapshot(&before),
},
);
store
.prepare(
transaction,
vec![Change {
path: "mods/intermediate.jar".into(),
before: None,
after: Some(fingerprint),
}],
next,
)
.unwrap();
fs::create_dir_all(f.root.join("mods")).unwrap();
fs::rename(stage, f.root.join("mods/intermediate.jar")).unwrap();
let after = pack(&[("mods/final.jar", b"final", FilePolicy::Managed)]);
let result = f.sync(&after, &[("mods/final.jar", b"final")]).unwrap();
assert_eq!(result.removed_files, 1);
assert!(!f.root.join("mods/intermediate.jar").exists());
assert_eq!(f.bytes("mods/final.jar"), b"final");
assert!(inspect(&f.root, &after).unwrap().up_to_date);
assert!(!store.pending().unwrap());
}
#[test]
fn unknown_collision_requires_review_before_install_and_protected_paths_are_refused() {
let f = Fixture::new();
f.write("mods/current.jar", b"unknown");
let manifest = pack(&[("mods/current.jar", b"pack", FilePolicy::Managed)]);
assert!(matches!(
f.sync(&manifest, &[]),
Err(ProfileError::Conflict(_))
));
let list = list_legacy_mods(&f.root, &manifest).unwrap();
backup_legacy_mods(
&f.root,
&manifest,
&[LegacySelection {
path: list[0].path.clone(),
sha256: list[0].sha256.clone(),
}],
)
.unwrap();
f.sync(&manifest, &[("mods/current.jar", b"pack")]).unwrap();
assert!(Store::open(&f.root)
.unwrap()
.load()
.unwrap()
.files
.contains_key("mods/current.jar"));
let unsafe_manifest = pack(&[("screenshots/player.png", b"bad", FilePolicy::Managed)]);
assert!(f.sync(&unsafe_manifest, &[]).is_err());
assert!(!f.root.join("screenshots/player.png").exists());
}
}
+22 -2
View File
@@ -4,6 +4,7 @@ use ed25519_dalek::{Signature, VerifyingKey};
use reqwest::header::ACCEPT_ENCODING;
use reqwest::{blocking::Client, redirect::Policy};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{
fmt,
io::{self, Read},
@@ -68,7 +69,16 @@ impl fmt::Display for RemoteError {
}
}
pub struct VerifiedSnapshot {
pub manifest: Manifest,
pub digest: String,
}
pub fn fetch_manifest(profile_id: &str) -> Result<Manifest, RemoteError> {
Ok(fetch_snapshot(profile_id)?.manifest)
}
pub fn fetch_snapshot(profile_id: &str) -> Result<VerifiedSnapshot, RemoteError> {
let url = match profile_id {
"aeronautics" => AERONAUTICS_MANIFEST,
_ => return Err(RemoteError::UnknownProfile),
@@ -90,7 +100,7 @@ pub fn fetch_manifest(profile_id: &str) -> Result<Manifest, RemoteError> {
.expect("embedded public key must be 32 bytes"),
)
.expect("embedded public key must be valid");
verify_envelope(&source, profile_id, &public_key)
verify_snapshot(&source, profile_id, &public_key)
}
fn fetch_manifest_bytes(client: &Client, url: &str) -> Result<Vec<u8>, RemoteError> {
@@ -154,11 +164,20 @@ fn read_envelope(source: impl Read) -> Result<Vec<u8>, RemoteError> {
Ok(bytes)
}
#[cfg(test)]
fn verify_envelope(
source: &[u8],
profile_id: &str,
public_key: &VerifyingKey,
) -> Result<Manifest, RemoteError> {
Ok(verify_snapshot(source, profile_id, public_key)?.manifest)
}
fn verify_snapshot(
source: &[u8],
profile_id: &str,
public_key: &VerifyingKey,
) -> Result<VerifiedSnapshot, RemoteError> {
if source.len() > MAX_ENVELOPE_BYTES {
return Err(RemoteError::TooLarge);
}
@@ -178,12 +197,13 @@ fn verify_envelope(
public_key
.verify_strict(&payload, &signature)
.map_err(|_| RemoteError::InvalidSignature)?;
let digest = format!("{:x}", Sha256::digest(&payload));
let payload = String::from_utf8(payload).map_err(|_| RemoteError::InvalidSignature)?;
let manifest = manifest::validate_json(&payload).map_err(RemoteError::InvalidManifest)?;
if manifest.id != profile_id {
return Err(RemoteError::ProfileMismatch);
}
Ok(manifest)
Ok(VerifiedSnapshot { manifest, digest })
}
#[cfg(test)]
+131 -1
View File
@@ -45,9 +45,21 @@ pub struct LoginResult {
#[derive(Clone, Deserialize, Serialize)]
pub struct LinkStart {
pub proof_version: u32,
pub server_id: String,
pub challenge_id: i64,
pub expires_in_seconds: u64,
pub registered_on_server: bool,
pub proof_code: String,
pub mc_username: String,
pub player_uuid: String,
}
#[derive(Deserialize)]
pub struct OnboardingGrant {
#[serde(flatten)]
pub challenge: LinkStart,
pub grant_token: String,
}
#[derive(Clone, Deserialize, Serialize)]
@@ -203,7 +215,92 @@ pub fn start_link(
if !response.status().is_success() {
return Err(api_error(response));
}
response.json::<LinkStart>().map_err(AccountError::Network)
let value = response
.json::<LinkStart>()
.map_err(AccountError::Network)?;
validate_challenge(&value, server_id, nickname)?;
Ok(value)
}
pub fn valid_nickname(name: &str) -> bool {
(3..=16).contains(&name.len()) && name.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'_')
}
fn validate_challenge(
value: &LinkStart,
server_id: &str,
requested: &str,
) -> Result<(), AccountError> {
if value.proof_version != 1
|| value.server_id != server_id
|| !valid_nickname(requested)
|| value.mc_username != requested
|| value.player_uuid != crate::session::offline_uuid(requested)
|| value.challenge_id <= 0
|| value.expires_in_seconds == 0
|| value.expires_in_seconds > 600
|| value.proof_code.len() != 32
|| !value.proof_code.bytes().all(|c| c.is_ascii_hexdigit())
{
return Err(AccountError::Api(
"Сервер вернул неподходящее подтверждение ника".into(),
));
}
Ok(())
}
pub fn start_onboarding(data_dir: &Path, nickname: &str) -> Result<OnboardingGrant, AccountError> {
if !valid_nickname(nickname) {
return Err(AccountError::Api("Неверный игровой ник".into()));
}
let response = client()?
.post(format!("{API_ORIGIN}/api/launcher/onboarding/start"))
.bearer_auth(load_session(data_dir)?)
.json(&serde_json::json!({"server_id":"aoc","mc_username":nickname}))
.send()
.map_err(AccountError::Network)?;
if !response.status().is_success() {
return Err(api_error(response));
}
let grant: OnboardingGrant = response.json().map_err(AccountError::Network)?;
validate_challenge(&grant.challenge, "aoc", nickname)?;
if grant.grant_token.len() < 32
|| grant.grant_token.len() > 256
|| !grant
.grant_token
.bytes()
.all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_')
{
return Err(AccountError::Api(
"Некорректное разрешение первого входа".into(),
));
}
Ok(grant)
}
pub fn validate_onboarding(data_dir: &Path, grant: &OnboardingGrant) -> Result<(), AccountError> {
let response = client()?.post(format!("{API_ORIGIN}/api/launcher/onboarding/validate"))
.bearer_auth(load_session(data_dir)?)
.json(&serde_json::json!({"challenge_id":grant.challenge.challenge_id,"grant_token":grant.grant_token}))
.send().map_err(AccountError::Network)?;
if !response.status().is_success() {
return Err(api_error(response));
}
let data: serde_json::Value = response.json().map_err(AccountError::Network)?;
if data["server_id"] != "aoc"
|| data["mc_username"] != grant.challenge.mc_username
|| data["player_uuid"] != grant.challenge.player_uuid
|| data["challenge_id"] != grant.challenge.challenge_id
|| data["proof_version"] != 1
|| !data["expires_in_seconds"]
.as_u64()
.is_some_and(|n| n > 0 && n <= 600)
{
return Err(AccountError::Api(
"Первый вход не подтверждён сервером".into(),
));
}
Ok(())
}
pub fn link_status(data_dir: &Path, challenge_id: i64) -> Result<LinkStatus, AccountError> {
@@ -238,6 +335,39 @@ mod tests {
time::{SystemTime, UNIX_EPOCH},
};
#[test]
fn challenge_requires_matching_server_exact_nickname_uuid_and_bounded_nonce() {
let valid = super::LinkStart {
proof_version: 1,
server_id: "aoc".into(),
challenge_id: 1,
expires_in_seconds: 600,
registered_on_server: false,
proof_code: "a".repeat(32),
mc_username: "ShaCraft_Test".into(),
player_uuid: crate::session::offline_uuid("ShaCraft_Test"),
};
assert!(super::validate_challenge(&valid, "aoc", "ShaCraft_Test").is_ok());
assert!(super::validate_challenge(&valid, "create", "ShaCraft_Test").is_err());
assert!(super::validate_challenge(&valid, "aoc", "shacraft_test").is_err());
let mut wrong = valid.clone();
wrong.player_uuid = crate::session::offline_uuid("shacraft_test");
assert!(super::validate_challenge(&wrong, "aoc", "ShaCraft_Test").is_err());
for ttl in [0, 601] {
wrong = valid.clone();
wrong.expires_in_seconds = ttl;
assert!(super::validate_challenge(&wrong, "aoc", "ShaCraft_Test").is_err());
}
wrong = valid.clone();
wrong.proof_version = 0;
assert!(super::validate_challenge(&wrong, "aoc", "ShaCraft_Test").is_err());
for code in ["a".repeat(31), "g".repeat(32), "a".repeat(33)] {
wrong = valid.clone();
wrong.proof_code = code;
assert!(super::validate_challenge(&wrong, "aoc", "ShaCraft_Test").is_err());
}
}
fn temporary_directory() -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"shacraft-account-test-{}-{}",
+355
View File
@@ -0,0 +1,355 @@
//! Launcher replacement is a distinct trust and lifecycle boundary from game
//! installation. A process-local gate drains all native writes; the game lock
//! also checks the durable detached-game lease. A handoff record survives the
//! Windows updater's immediate process exit and is never cleared by a timeout.
use crate::{
installation_lock::InstallationLock,
operations::{ExclusivePermit, LauncherOperations, Permit},
};
use serde::{Deserialize, Serialize};
use std::{
fs::{self, File, OpenOptions},
io,
path::{Path, PathBuf},
};
// A renamed durable marker must survive a power loss before installer handoff.
// Unix directory fsync persists the name itself, in addition to write_atomic's
// fsync of the file contents. Windows uses its native file replacement semantics.
fn sync_directory(path: &Path) -> Result<(), String> {
#[cfg(unix)]
{
File::open(path)
.and_then(|file| file.sync_all())
.map_err(|e| e.to_string())?;
}
#[cfg(not(unix))]
let _ = path;
Ok(())
}
const RECOVERY: &str = "Предыдущая установка обновления не завершена или её запись повреждена. Автоматическое продолжение заблокировано. Закройте игру, установщик и лаунчер, затем восстановите приложение официальным пакетом того же типа. Настройки и игровые файлы удалять не нужно.";
fn ordinary(path: &Path) -> Result<(), String> {
match fs::symlink_metadata(path) {
Ok(meta) if meta.file_type().is_symlink() => {
Err("Служебный путь обновления является ссылкой".into())
}
Ok(_) => Ok(()),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error.to_string()),
}
}
fn directory(data: &Path) -> Result<PathBuf, String> {
ordinary(data)?;
fs::create_dir_all(data).map_err(|e| e.to_string())?;
let path = data.join("launcher-state");
ordinary(&path)?;
fs::create_dir_all(&path).map_err(|e| e.to_string())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).map_err(|e| e.to_string())?;
}
sync_directory(data)?;
if let Some(parent) = data.parent() {
sync_directory(parent)?;
}
Ok(path)
}
/// Retained in Tauri state for the entire process lifetime. In particular an
/// idle second launcher cannot keep an old executable loaded during replacement.
pub(crate) struct InstanceGuard {
_file: File,
recovery_reason: Option<String>,
}
impl Drop for InstanceGuard {
fn drop(&mut self) {
// Release the owner lock even if a concurrent spawn retains a temporary
// inherited file description before exec. Never clear the handoff marker.
let _ = self._file.unlock();
}
}
impl InstanceGuard {
pub fn recovery_reason(&self) -> Option<String> {
self.recovery_reason.clone()
}
pub fn acquire(data: &Path, current_version: &str) -> Result<Self, String> {
let dir = directory(data)?;
let path = dir.join("instance.lock");
ordinary(&path)?;
let mut options = OpenOptions::new();
options.read(true).write(true).create(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let file = options.open(&path).map_err(|e| e.to_string())?;
let marker = dir.join("pending-update.json");
let mut acquired = file.try_lock().is_ok();
if !acquired && read_pending(&marker)?.is_some_and(|pending| pending.to == current_version)
{
// Tauri starts the replacement child before its old process exits.
// Only the exact recorded target may wait for that legitimate handoff.
// Never remove a lock or treat elapsed time as successful acquisition.
let until = std::time::Instant::now() + std::time::Duration::from_secs(5);
while !acquired && std::time::Instant::now() < until {
std::thread::sleep(std::time::Duration::from_millis(25));
acquired = file.try_lock().is_ok();
}
}
if !acquired {
return Err(
"ShaCraft Launcher уже запущен. Откройте его окно или завершите другой экземпляр."
.into(),
);
}
let recovery_reason = match read_pending(&marker) {
// A corrupt/unreadable marker becomes explicit recovery state,
// NEVER Ready/None. Setup latches this reason before any IPC runs.
Err(reason) => Some(reason),
Ok(Some(pending)) if pending.to == current_version => fs::remove_file(marker)
.map_err(|error| error.to_string())
.and_then(|()| sync_directory(&dir))
.err()
.map(|error| format!("{RECOVERY} {error}")),
Ok(Some(pending)) => Some(format!("{RECOVERY} Ожидаемая версия: {}.", pending.to)),
Ok(None) => None,
};
Ok(Self {
_file: file,
recovery_reason,
})
}
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct PendingUpdate {
from: String,
to: String,
}
fn read_pending(path: &Path) -> Result<Option<PendingUpdate>, String> {
ordinary(path)?;
match fs::read(path) {
Ok(bytes) if bytes.len() <= 1024 => serde_json::from_slice(&bytes)
.map(Some)
.map_err(|_| RECOVERY.to_string()),
Ok(_) => Err(RECOVERY.into()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e.to_string()),
}
}
pub(crate) fn ensure_no_pending(data: &Path) -> Result<(), String> {
if let Some(pending) = read_pending(&directory(data)?.join("pending-update.json"))? {
return Err(format!("{RECOVERY} Ожидаемая версия: {}.", pending.to));
}
Ok(())
}
pub(crate) fn pending_reason(
data: &Path,
_current_version: &str,
) -> Result<Option<String>, String> {
// Startup is the only place allowed to acknowledge a completed handoff.
// Read-only status must never remove a marker while an installer is active.
match ensure_no_pending(data) {
Ok(()) => Ok(None),
Err(reason) => Ok(Some(reason)),
}
}
pub(crate) fn begin_operation(
data: &Path,
operations: &LauncherOperations,
) -> Result<crate::operations::SharedPermit, String> {
operations.ensure_writable()?;
let permit = operations.lifecycle.shared()?;
ensure_no_pending(data)?;
Ok(permit)
}
pub(crate) struct UpdateGuard<'a> {
operations: &'a LauncherOperations,
directory: PathBuf,
_exclusive: ExclusivePermit,
_operation: Permit,
_installation: InstallationLock,
}
impl<'a> UpdateGuard<'a> {
pub fn acquire(data: &Path, operations: &'a LauncherOperations) -> Result<Self, String> {
operations.ensure_writable()?;
let exclusive = operations.lifecycle.exclusive()?;
let operation = operations.installation.acquire("Обновление лаунчера")?;
ensure_no_pending(data)?;
let installation = InstallationLock::acquire(data)?;
Ok(Self {
operations,
directory: directory(data)?,
_exclusive: exclusive,
_operation: operation,
_installation: installation,
})
}
/// Call only after all package checks and immediately before the platform
/// installer. Drop intentionally preserves this record on uncertain errors.
pub fn begin_install(&self, current_version: &str, target_version: &str) -> Result<(), String> {
let from = semver::Version::parse(current_version).map_err(|e| e.to_string())?;
let to = semver::Version::parse(target_version).map_err(|e| e.to_string())?;
if to <= from || !to.pre.is_empty() || !to.build.is_empty() {
return Err("Установка этой версии обновления запрещена".into());
}
let path = self.directory.join("pending-update.json");
ordinary(&path)?;
crate::storage::write_atomic(
&path,
&serde_json::to_vec(&PendingUpdate {
from: current_version.into(),
to: target_version.into(),
})
.map_err(|e| e.to_string())?,
)
.map_err(|e| e.to_string())?;
self.operations
.latch_recovery(format!("{RECOVERY} Ожидаемая версия: {target_version}."));
sync_directory(&self.directory)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT: AtomicU64 = AtomicU64::new(0);
fn dir() -> PathBuf {
std::env::temp_dir().join(format!(
"shacraft-update-lock-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
))
}
#[test]
fn instance_owner_drop_releases_lock_despite_inherited_description() {
let data = dir();
let owner = InstanceGuard::acquire(&data, "0.2.0").unwrap();
let inherited = owner._file.try_clone().unwrap();
assert!(InstanceGuard::acquire(&data, "0.2.0").is_err());
drop(owner);
let next = InstanceGuard::acquire(&data, "0.2.0")
.unwrap_or_else(|error| panic!("owner instance lock remained: {error}"));
drop(inherited);
drop(next);
fs::remove_dir_all(data).unwrap();
}
#[test]
fn excludes_account_settings_and_game_writes_in_both_directions() {
let dir = dir();
let state = LauncherOperations::default();
let write = begin_operation(&dir, &state).unwrap();
assert!(UpdateGuard::acquire(&dir, &state).is_err());
drop(write);
let update = UpdateGuard::acquire(&dir, &state).unwrap();
assert!(begin_operation(&dir, &state).is_err());
assert!(UpdateGuard::acquire(&dir, &state).is_err());
drop(update);
assert!(begin_operation(&dir, &state).is_ok());
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn checks_game_lease_after_launcher_has_exited() {
let dir = dir();
let state = LauncherOperations::default();
let game = InstallationLock::acquire(&dir).unwrap();
game.running(std::process::id()).unwrap();
drop(game);
assert!(UpdateGuard::acquire(&dir, &state).is_err());
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn failed_download_can_retry_but_installer_handoff_survives_exit() {
let dir = dir();
let state = LauncherOperations::default();
drop(UpdateGuard::acquire(&dir, &state).unwrap());
let update = UpdateGuard::acquire(&dir, &state).unwrap();
assert!(update.begin_install("0.2.0", "0.1.1").is_err());
assert!(update.begin_install("0.2.0", "0.2.0").is_err());
update.begin_install("0.2.0", "0.2.1").unwrap();
drop(update);
assert!(begin_operation(&dir, &state).is_err());
assert!(UpdateGuard::acquire(&dir, &state).is_err());
let old = InstanceGuard::acquire(&dir, "0.2.0").unwrap();
assert!(begin_operation(&dir, &state).is_err());
drop(old);
let different = InstanceGuard::acquire(&dir, "0.3.0").unwrap();
assert!(begin_operation(&dir, &state).is_err());
drop(different);
let updated = InstanceGuard::acquire(&dir, "0.2.1").unwrap();
assert!(begin_operation(&dir, &state).is_err()); // old process stays latched
assert!(begin_operation(&dir, &LauncherOperations::default()).is_ok());
drop(updated);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn target_process_waits_for_real_lock_release_during_restart() {
let dir = dir();
let instance = InstanceGuard::acquire(&dir, "0.2.0").unwrap();
let state = LauncherOperations::default();
let update = UpdateGuard::acquire(&dir, &state).unwrap();
update.begin_install("0.2.0", "0.2.1").unwrap();
drop(update);
// Model Tauri's spawn-before-exit with an OS lock held by the old owner.
let next_dir = dir.clone();
let next = std::thread::spawn(move || InstanceGuard::acquire(&next_dir, "0.2.1"));
std::thread::sleep(std::time::Duration::from_millis(75));
assert!(!next.is_finished());
drop(instance);
let target = next.join().unwrap().unwrap();
assert!(target.recovery_reason().is_none());
assert!(begin_operation(&dir, &state).is_err());
assert!(begin_operation(&dir, &LauncherOperations::default()).is_ok());
drop(target);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn rejects_second_idle_instance_and_malformed_marker() {
let dir = dir();
let instance = InstanceGuard::acquire(&dir, "0.2.0").unwrap();
assert!(InstanceGuard::acquire(&dir, "0.2.0").is_err());
drop(instance);
fs::write(
directory(&dir).unwrap().join("pending-update.json"),
b"invalid",
)
.unwrap();
let recovery = InstanceGuard::acquire(&dir, "0.2.1").unwrap();
let state = LauncherOperations::default();
state.latch_recovery(
recovery
.recovery_reason()
.expect("corruption must be explicit recovery"),
);
// Read-only diagnostics are available, while every shared write and
// updater install remains denied, including after external deletion.
assert!(pending_reason(&dir, "0.2.1").unwrap().is_some());
assert!(begin_operation(&dir, &state).is_err());
assert!(UpdateGuard::acquire(&dir, &state).is_err());
fs::remove_file(directory(&dir).unwrap().join("pending-update.json")).unwrap();
assert!(begin_operation(&dir, &state).is_err());
assert!(UpdateGuard::acquire(&dir, &state).is_err());
drop(recovery);
fs::remove_dir_all(dir).unwrap();
}
}
+664
View File
@@ -0,0 +1,664 @@
//! Native-only launcher updater. The webview controls timing, never trust inputs.
mod format;
mod protocol;
use crate::{
operations::{LauncherOperations, Operation, Permit},
update_guard::UpdateGuard,
};
use protocol::{Artifact, VerifiedRelease};
use reqwest::blocking::Client;
use serde::Serialize;
use std::{
path::Path,
process::{Command, Stdio},
sync::{Arc, Mutex},
time::{Duration, Instant},
};
#[cfg(target_os = "linux")]
use tauri::Manager;
use tauri::{AppHandle, Emitter};
use tauri_plugin_updater::UpdaterExt;
#[derive(Clone, Serialize, Debug, PartialEq)]
#[serde(rename_all = "snake_case")]
pub(crate) enum Phase {
Idle,
Checking,
Available,
Downloading,
Verifying,
Installing,
Ready,
NoUpdate,
Unconfigured,
Manual,
Error,
}
#[derive(Clone, Serialize, Debug)]
#[serde(rename_all = "camelCase")]
pub(crate) struct UpdateStatus {
revision: u64,
installed_version: String,
package_format: &'static str,
phase: Phase,
available_version: Option<String>,
release_notes: Option<String>,
downloaded_bytes: u64,
total_bytes: Option<u64>,
can_retry: bool,
message: Option<String>,
test_build: bool,
}
struct StateData {
status: UpdateStatus,
candidate: Option<VerifiedRelease>,
}
#[derive(Clone)]
pub(crate) struct UpdaterState {
inner: Arc<Mutex<StateData>>,
operation: Operation,
}
impl Default for UpdaterState {
fn default() -> Self {
let configured = configured_key().is_some();
Self {
inner: Arc::new(Mutex::new(StateData {
status: UpdateStatus {
revision: 0,
installed_version: env!("CARGO_PKG_VERSION").into(),
package_format: format::installed_label(),
phase: if configured { Phase::Idle } else { Phase::Unconfigured },
available_version: None, release_notes: None, downloaded_bytes: 0, total_bytes: None,
can_retry: false,
message: (!configured).then(|| "Подписанные обновления ещё не настроены для этой сборки. Официальные выпуски доступны на GitHub.".into()),
test_build: protocol::test_build(),
},
candidate: None,
})),
operation: Operation::default(),
}
}
}
pub(crate) fn configured_key() -> Option<&'static str> {
protocol::configured_key()
}
impl UpdaterState {
pub fn status(&self) -> UpdateStatus {
self.inner.lock().unwrap().status.clone()
}
pub fn acquire(&self) -> Result<Permit, String> {
self.operation.acquire("Обновление лаунчера")
}
fn change(&self, app: &AppHandle, update: impl FnOnce(&mut StateData)) -> UpdateStatus {
let status = {
let mut data = self.inner.lock().unwrap();
update(&mut data);
data.status.revision += 1;
data.status.clone()
};
let _ = app.emit("launcher-update-status", &status);
status
}
fn phase(&self, app: &AppHandle, phase: Phase, message: Option<String>) -> UpdateStatus {
self.change(app, |data| {
data.status.can_retry = phase == Phase::Error;
data.status.phase = phase;
data.status.message = message;
})
}
pub fn fail(&self, app: &AppHandle, error: String) -> UpdateStatus {
self.change(app, |data| {
data.status.can_retry = data.status.phase != Phase::Installing;
data.status.phase = Phase::Error;
data.status.message = Some(error);
if !data.status.can_retry {
data.candidate = None;
}
})
}
pub fn recovery(&self, app: &AppHandle, reason: String) -> UpdateStatus {
self.change(app, |data| {
data.candidate = None;
data.status.phase = Phase::Error;
data.status.can_retry = false;
data.status.message = Some(reason);
})
}
pub fn may_check(&self) -> bool {
!matches!(
self.status().phase,
Phase::Downloading | Phase::Verifying | Phase::Installing | Phase::Ready
)
}
pub fn ready(&self) -> bool {
self.status().phase == Phase::Ready
}
pub fn critical(&self) -> bool {
matches!(
self.status().phase,
Phase::Downloading | Phase::Verifying | Phase::Installing | Phase::Ready
)
}
}
#[derive(Clone, Debug, PartialEq)]
enum PackageMode {
Automatic { platform: &'static str, msi: bool },
Manual,
}
fn package_mode(
os: &str,
arch: &str,
bundle: Option<tauri::utils::config::BundleType>,
) -> PackageMode {
use tauri::utils::config::BundleType;
match (os, arch, bundle) {
("linux", "x86_64", Some(BundleType::AppImage)) => PackageMode::Automatic {
platform: "linux-x86_64",
msi: false,
},
("windows", "x86_64", Some(BundleType::Nsis)) => PackageMode::Automatic {
platform: "windows-x86_64",
msi: false,
},
("windows", "x86_64", Some(BundleType::Msi)) => PackageMode::Automatic {
platform: "windows-x86_64",
msi: true,
},
("macos", "aarch64", Some(BundleType::App)) => PackageMode::Automatic {
platform: "darwin-aarch64",
msi: false,
},
("macos", "x86_64", Some(BundleType::App)) => PackageMode::Automatic {
platform: "darwin-x86_64",
msi: false,
},
_ => PackageMode::Manual,
}
}
// The stamped bundle type survives extracting an AppImage. It does not identify
// the runtime file which the plugin will replace. Only the frozen Tauri Env is
// shared with the plugin's executable_path selection; do not reread process env.
#[cfg(any(target_os = "linux", test))]
fn appimage_context_valid(path: Option<&Path>, ordinary_file: bool, header: &[u8]) -> bool {
path.is_some_and(Path::is_absolute)
&& ordinary_file
&& format::verify(
&PackageMode::Automatic {
platform: "linux-x86_64",
msi: false,
},
header,
)
.is_ok()
}
#[cfg(any(target_os = "linux", test))]
fn appimage_file_ready(path: Option<&Path>) -> bool {
use std::io::Read;
let Some(path) = path else { return false };
let ordinary_file = std::fs::symlink_metadata(path)
.map(|metadata| metadata.file_type().is_file())
.unwrap_or(false);
if !path.is_absolute() || !ordinary_file {
return false;
}
let mut header = [0; 20];
if std::fs::File::open(path)
.and_then(|mut file| file.read_exact(&mut header))
.is_err()
{
return false;
}
appimage_context_valid(Some(path), ordinary_file, &header)
}
// Binding APPDIR to the actual executable rejects APPIMAGE/APPDIR inherited
// from an unrelated parent application. The fixed relative path is the verified
// Tauri AppDir layout for this application's configured binary name.
#[cfg(any(target_os = "linux", test))]
fn appdir_matches_executable(appdir: Option<&Path>, executable: Option<&Path>) -> bool {
let (Some(appdir), Some(executable)) = (appdir, executable) else {
return false;
};
if !appdir.is_absolute() || !executable.is_absolute() {
return false;
}
let (Ok(appdir), Ok(executable)) = (appdir.canonicalize(), executable.canonicalize()) else {
return false;
};
appdir.is_dir()
&& executable.is_file()
&& executable.strip_prefix(&appdir).ok() == Some(Path::new("usr/bin/shacraft-launcher"))
}
#[cfg(target_os = "linux")]
fn appimage_runtime_ready(app: &AppHandle) -> bool {
let environment = app.env();
let executable = std::env::current_exe().ok();
appimage_file_ready(environment.appimage.as_deref().map(Path::new))
&& appdir_matches_executable(
environment.appdir.as_deref().map(Path::new),
executable.as_deref(),
)
}
fn current_mode(_app: &AppHandle) -> PackageMode {
// Bare binaries, distro packages and dev runs must never be overwritten as an AppImage/.app.
if cfg!(debug_assertions) {
return PackageMode::Manual;
}
#[cfg(target_os = "linux")]
if !appimage_runtime_ready(_app) {
return PackageMode::Manual;
}
#[cfg(target_os = "macos")]
{
let app_bundle = std::env::current_exe()
.ok()
.and_then(|path| path.parent().map(|p| p.ends_with("Contents/MacOS")))
.unwrap_or(false);
if !app_bundle {
return PackageMode::Manual;
}
}
package_mode(
std::env::consts::OS,
std::env::consts::ARCH,
tauri::utils::platform::bundle_type(),
)
}
fn client() -> Result<Client, String> {
Client::builder()
.https_only(true)
.connect_timeout(Duration::from_secs(15))
.timeout(Duration::from_secs(300))
.user_agent("ShaCraft-Launcher-Updater/1")
.redirect(reqwest::redirect::Policy::custom(|attempt| {
if attempt.previous().len() <= 5 && protocol::redirect_allowed(attempt.url()) {
attempt.follow()
} else {
attempt.error("Untrusted update redirect")
}
}))
.build()
.map_err(|_| "Не удалось подготовить соединение для обновлений".into())
}
fn response_bytes(
client: &Client,
url: &str,
limit: u64,
progress: impl FnMut(u64),
) -> Result<Option<Vec<u8>>, String> {
let response = client
.get(url)
.send()
.map_err(|_| "Не удалось связаться с GitHub. Проверьте сеть и повторите проверку.")?;
if response.status() == reqwest::StatusCode::NO_CONTENT {
return Ok(None);
}
if !response.status().is_success() {
return Err(format!(
"GitHub не отдал обновление (HTTP {}). Повторите позже.",
response.status().as_u16()
));
}
if response.content_length().is_some_and(|size| size > limit) {
return Err("Размер ответа превышает подписанный предел".into());
}
protocol::read_bounded(response, limit, progress).map(Some)
}
pub(crate) fn check(app: &AppHandle, state: &UpdaterState) -> Result<UpdateStatus, String> {
if !state.may_check() {
return Err("Сначала завершите текущее обновление лаунчера".into());
}
let Some(key) = configured_key() else {
return Ok(state.phase(
app,
Phase::Unconfigured,
Some("Подписанные обновления ещё не настроены для этой сборки.".into()),
));
};
state.change(app, |data| {
data.candidate = None;
data.status.phase = Phase::Checking;
data.status.can_retry = false;
data.status.message = None;
data.status.downloaded_bytes = 0;
data.status.total_bytes = None;
data.status.available_version = None;
data.status.release_notes = None;
});
let client = client()?;
let Some(verified) = protocol::fetch_release(
|url, limit| response_bytes(&client, url, limit, |_| {}),
key,
env!("CARGO_PKG_VERSION"),
)?
else {
return Ok(state.phase(app, Phase::NoUpdate, None));
};
let manual = current_mode(app) == PackageMode::Manual;
Ok(state.change(app, |data| {
data.status.available_version = Some(verified.release.version.clone());
data.status.release_notes = Some(verified.release.notes.clone());
data.status.phase = if manual { Phase::Manual } else { Phase::Available };
data.status.message = manual.then(|| "Эта сборка обновляется вручную. Для .deb используйте менеджер пакетов. Автообновление AppImage доступно при запуске исходного файла .AppImage; распакованная копия обновляется вручную.".into());
data.candidate = Some(verified);
}))
}
fn select_artifact(release: &VerifiedRelease, mode: &PackageMode) -> Result<Artifact, String> {
match mode {
PackageMode::Automatic { platform, msi } => {
let artifact = if *msi {
release.release.manual_packages.get("windows-x86_64-msi")
} else {
release.release.platforms.get(*platform)
};
artifact
.cloned()
.ok_or_else(|| "Нет подписанного пакета для текущей платформы".into())
}
PackageMode::Manual => {
Err("Эту сборку необходимо обновить вручную через официальный выпуск".into())
}
}
}
pub(crate) fn download_install(
app: &AppHandle,
state: &UpdaterState,
directory: &Path,
operations: &LauncherOperations,
) -> Result<UpdateStatus, String> {
let key = configured_key().ok_or("Подписанные обновления не настроены")?;
let release = state
.inner
.lock()
.unwrap()
.candidate
.clone()
.ok_or("Сначала проверьте доступность обновления")?;
if !release.is_newer_than(env!("CARGO_PKG_VERSION"))? {
return Err("Эта версия уже установлена".into());
}
let mode = current_mode(app);
let artifact = select_artifact(&release, &mode)?;
// Includes cross-process game lease and lifecycle exclusion; held through restart/handoff.
let guard = UpdateGuard::acquire(directory, operations)?;
state.change(app, |data| {
data.status.phase = Phase::Downloading;
data.status.can_retry = false;
data.status.message = None;
data.status.downloaded_bytes = 0;
data.status.total_bytes = Some(artifact.size);
});
let client = client()?;
let mut last = Instant::now();
let bytes = response_bytes(&client, &artifact.url, artifact.size, |count| {
if last.elapsed() >= Duration::from_millis(100) || count == artifact.size {
state.change(app, |data| {
data.status.downloaded_bytes = count;
});
last = Instant::now();
}
})?
.ok_or("Сервер не вернул пакет обновления")?;
state.phase(app, Phase::Verifying, None);
protocol::verify_artifact(&bytes, &artifact, key)?;
format::verify(&mode, &bytes)?;
let platform = match mode {
PackageMode::Automatic { platform, .. } => platform,
PackageMode::Manual => unreachable!(),
};
// The plugin's constructor is private. Its check creates the native installer
// context from a fixed version URL; accept only the signed metadata already read.
let builder = app
.updater_builder()
.pubkey(key)
.target(platform)
.endpoints(vec![release
.pinned_endpoint()
.parse()
.map_err(|_| "Некорректный адрес выпуска")?])
.map_err(|_| "Не удалось настроить установщик обновления")?
.configure_client(|builder| {
builder
.https_only(true)
.connect_timeout(Duration::from_secs(15))
.timeout(Duration::from_secs(30))
.redirect(reqwest_updater::redirect::Policy::custom(|attempt| {
if attempt.previous().len() <= 5 && protocol::redirect_allowed(attempt.url()) {
attempt.follow()
} else {
attempt.error("Untrusted update redirect")
}
}))
});
let mut update = tauri::async_runtime::block_on(
builder
.build()
.map_err(|_| "Не удалось подготовить установщик")?
.check(),
)
.map_err(|_| "Не удалось сверить подписанный выпуск с установщиком. Повторите попытку.")?
.ok_or("Подписанный выпуск больше не доступен установщику")?;
if update.raw_json != release.json
|| update.version != release.release.version
|| update.target != platform
{
return Err(
"Метаданные выпуска изменились. Установка остановлена; проверьте обновления заново."
.into(),
);
}
// MSI installations stay MSI; the signed descriptor is never supplied by JS.
update.download_url = artifact
.url
.parse()
.map_err(|_| "Некорректный адрес пакета")?;
update.signature = artifact.signature.clone();
// Update::install does NOT verify bytes itself. Keep this immediately before it.
protocol::install_verified(&bytes, &artifact, key, |verified_bytes| {
guard.begin_install(env!("CARGO_PKG_VERSION"), &release.release.version)?;
state.phase(
app,
Phase::Installing,
Some("Лаунчер перезапустится после установки. Не выключайте компьютер.".into()),
);
update.install(verified_bytes).map_err(|_| "Установка прервалась. Запись о незавершённом обновлении сохранена; следуйте инструкции восстановления.".to_string())
})?;
// Windows exits inside plugin install after handing off to NSIS/MSI. There is
// no installer PID API; the persistent lifecycle marker guards the new process.
state.phase(
app,
Phase::Ready,
Some("Обновление установлено. Перезапускаем лаунчер.".into()),
);
app.restart()
}
pub(crate) fn open_release_page() -> Result<(), String> {
// Fixed executable/argument structure and URL; no shell or webview-supplied input.
#[cfg(target_os = "linux")]
let mut command = {
let mut command = Command::new("xdg-open");
command.arg(protocol::RELEASES);
command
};
#[cfg(target_os = "macos")]
let mut command = {
let mut command = Command::new("open");
command.arg(protocol::RELEASES);
command
};
#[cfg(target_os = "windows")]
let mut command = {
let mut command = Command::new("rundll32.exe");
command.args(["url.dll,FileProtocolHandler", protocol::RELEASES]);
command
};
command
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map(|_| ())
.map_err(|_| "Не удалось открыть страницу официальных выпусков".into())
}
#[cfg(test)]
mod tests {
use super::*;
use tauri::utils::config::BundleType;
#[test]
fn package_formats_never_cross_installers_or_architectures() {
assert_eq!(
package_mode("linux", "x86_64", Some(BundleType::Deb)),
PackageMode::Manual
);
assert_eq!(package_mode("linux", "x86_64", None), PackageMode::Manual);
assert_eq!(
package_mode("windows", "aarch64", Some(BundleType::Nsis)),
PackageMode::Manual
);
assert_eq!(
package_mode("windows", "x86_64", Some(BundleType::Msi)),
PackageMode::Automatic {
platform: "windows-x86_64",
msi: true
}
);
assert_eq!(
package_mode("macos", "aarch64", Some(BundleType::App)),
PackageMode::Automatic {
platform: "darwin-aarch64",
msi: false
}
);
assert_eq!(
package_mode("macos", "x86_64", Some(BundleType::App)),
PackageMode::Automatic {
platform: "darwin-x86_64",
msi: false
}
);
}
#[test]
fn appimage_runtime_requires_absolute_ordinary_image_file() {
let absolute = std::env::temp_dir().join("launcher.AppImage");
let mut header = [0; 20];
header[..6].copy_from_slice(b"\x7fELF\x02\x01");
header[8..11].copy_from_slice(b"AI\x02");
header[18..20].copy_from_slice(b"\x3e\x00");
assert!(appimage_context_valid(Some(&absolute), true, &header));
// A stamped extracted binary has no APPIMAGE runtime path. A relative
// path, missing file/directory/symlink or ordinary ELF is also manual.
assert!(!appimage_context_valid(None, true, &header));
assert!(!appimage_context_valid(
Some(Path::new("launcher.AppImage")),
true,
&header
));
assert!(!appimage_context_valid(Some(&absolute), false, &header));
assert!(!appimage_context_valid(
Some(&absolute),
true,
&header[..10]
));
header[8..11].fill(0);
assert!(!appimage_context_valid(Some(&absolute), true, &header));
}
#[test]
fn appimage_file_context_rejects_missing_directory_symlink_and_raw_binary() {
let root = std::env::temp_dir().join(format!(
"shacraft-appimage-context-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos(),
));
std::fs::create_dir_all(&root).unwrap();
let path = root.join("launcher.AppImage");
assert!(!appimage_file_ready(None));
assert!(!appimage_file_ready(Some(&path)));
assert!(!appimage_file_ready(Some(&root)));
let mut header = [0; 20];
header[..6].copy_from_slice(b"\x7fELF\x02\x01");
header[18..20].copy_from_slice(b"\x3e\x00");
std::fs::write(&path, header).unwrap();
assert!(!appimage_file_ready(Some(&path))); // ordinary extracted ELF
header[8..11].copy_from_slice(b"AI\x02");
std::fs::write(&path, header).unwrap();
assert!(appimage_file_ready(Some(&path))); // runtime image header fixture
#[cfg(unix)]
{
let link = root.join("linked.AppImage");
std::os::unix::fs::symlink(&path, &link).unwrap();
assert!(!appimage_file_ready(Some(&link)));
}
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn appdir_binding_rejects_inherited_parent_context_and_escaped_executable() {
let root = std::env::temp_dir().join(format!(
"shacraft-appdir-context-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos(),
));
let appdir = root.join("ShaCraft.AppDir");
let executable = appdir.join("usr/bin/shacraft-launcher");
std::fs::create_dir_all(executable.parent().unwrap()).unwrap();
std::fs::write(&executable, b"fixture").unwrap();
let parent_appdir = root.join("Other.AppDir");
std::fs::create_dir(&parent_appdir).unwrap();
let bare = root.join("shacraft-launcher");
std::fs::write(&bare, b"fixture").unwrap();
assert!(appdir_matches_executable(Some(&appdir), Some(&executable)));
assert!(!appdir_matches_executable(None, Some(&executable)));
assert!(!appdir_matches_executable(Some(&appdir), None));
assert!(!appdir_matches_executable(
Some(Path::new("relative.AppDir")),
Some(&executable)
));
assert!(!appdir_matches_executable(
Some(&parent_appdir),
Some(&executable)
));
assert!(!appdir_matches_executable(Some(&appdir), Some(&bare)));
assert!(!appdir_matches_executable(Some(&root), Some(&executable))); // arbitrary ancestor is not sufficient
#[cfg(unix)]
{
std::fs::remove_file(&executable).unwrap();
std::os::unix::fs::symlink(&bare, &executable).unwrap();
assert!(!appdir_matches_executable(Some(&appdir), Some(&executable)));
}
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn updater_single_flight_releases_after_failure() {
let state = UpdaterState::default();
let permit = state.acquire().unwrap();
assert!(state.acquire().is_err());
drop(permit);
assert!(state.acquire().is_ok());
}
}
+94
View File
@@ -0,0 +1,94 @@
//! Do not let the plugin's byte sniffing select a different Windows installer
//! than the installed package. Metadata, signature and these checks all agree.
use super::PackageMode;
pub(super) fn verify(mode: &PackageMode, bytes: &[u8]) -> Result<(), String> {
let valid = match mode {
PackageMode::Automatic {
platform: "windows-x86_64",
msi: true,
} => bytes.starts_with(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"),
PackageMode::Automatic {
platform: "windows-x86_64",
msi: false,
} => {
let pe = bytes
.get(0x3c..0x40)
.map(|raw| u32::from_le_bytes(raw.try_into().unwrap()) as usize);
bytes.starts_with(b"MZ")
&& pe.and_then(|at| at.checked_add(4).and_then(|end| bytes.get(at..end)))
== Some(b"PE\0\0".as_slice())
}
PackageMode::Automatic {
platform: "linux-x86_64",
msi: false,
} => {
bytes.starts_with(b"\x7fELF\x02\x01") // ELF64, little-endian
&& bytes.get(8..11) == Some(b"AI\x02".as_slice()) // AppImage Type2 magic
&& bytes.get(18..20) == Some(b"\x3e\x00".as_slice())
} // EM_X86_64
PackageMode::Automatic {
platform: "darwin-aarch64" | "darwin-x86_64",
msi: false,
} => bytes.starts_with(b"\x1f\x8b\x08"), // gzip archive; actual .app architecture is signed in metadata
_ => false,
};
if valid {
Ok(())
} else {
Err("Формат подписанного пакета не соответствует установленному лаунчеру. Автоматическая смена типа установки запрещена.".into())
}
}
pub(super) fn installed_label() -> &'static str {
use tauri::utils::{config::BundleType, platform::bundle_type};
if cfg!(debug_assertions) {
return "development";
}
match bundle_type() {
Some(BundleType::AppImage) => "AppImage",
Some(BundleType::Deb) => "deb",
Some(BundleType::Rpm) => "rpm",
Some(BundleType::Msi) => "MSI",
Some(BundleType::Nsis) => "NSIS",
Some(BundleType::App | BundleType::Dmg) => "app",
None => "unpackaged",
}
}
#[cfg(test)]
mod tests {
use super::*;
fn mode(platform: &'static str, msi: bool) -> PackageMode {
PackageMode::Automatic { platform, msi }
}
#[test]
fn windows_installers_cannot_silently_switch_formats() {
let msi = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1";
let mut exe = vec![0; 96];
exe[..2].copy_from_slice(b"MZ");
exe[0x3c] = 64;
exe[64..68].copy_from_slice(b"PE\0\0");
assert!(verify(&mode("windows-x86_64", true), msi).is_ok());
assert!(verify(&mode("windows-x86_64", false), &exe).is_ok());
assert!(verify(&mode("windows-x86_64", true), &exe).is_err());
assert!(verify(&mode("windows-x86_64", false), msi).is_err());
exe[0x3c..0x40].fill(255); // malformed offset cannot panic/wrap
assert!(verify(&mode("windows-x86_64", false), &exe).is_err());
}
#[test]
fn linux_requires_the_expected_appimage_arch_and_mac_requires_archive() {
let mut image = vec![0; 32];
image[..6].copy_from_slice(b"\x7fELF\x02\x01");
image[8..11].copy_from_slice(b"AI\x02");
image[18..20].copy_from_slice(b"\x3e\x00");
assert!(verify(&mode("linux-x86_64", false), &image).is_ok());
image[18] = 183; // ARM64 ELF is never an x86_64 update.
assert!(verify(&mode("linux-x86_64", false), &image).is_err());
for platform in ["darwin-aarch64", "darwin-x86_64"] {
assert!(verify(&mode(platform, false), b"\x1f\x8b\x08").is_ok());
assert!(verify(&mode(platform, false), b"MSI").is_err());
}
assert!(verify(&PackageMode::Manual, b"\x1f\x8b\x08").is_err());
}
}
+294
View File
@@ -0,0 +1,294 @@
//! Signed launcher releases are a separate trust domain from the ShaCraft pack.
//! Authenticate the exact metadata bytes before parsing any URLs or versions.
use base64::{engine::general_purpose::STANDARD, Engine};
use minisign_verify::{PublicKey, Signature};
use semver::Version;
use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::{collections::BTreeMap, io::Read};
use url::Url;
pub(crate) const RELEASES: &str = "https://github.com/emil28092005/shacraft-launcher/releases";
pub(crate) const LATEST: &str =
"https://github.com/emil28092005/shacraft-launcher/releases/latest/download/latest.json";
pub(crate) const MAX_METADATA: u64 = 32768;
pub(crate) const MAX_SIGNATURE: u64 = 2048;
pub(crate) const MAX_PACKAGE: u64 = 1024 * 1024 * 1024;
const PINNED_KEY: &str = include_str!("../../updater-public-key.txt");
pub(crate) fn test_build() -> bool {
option_env!("SHACRAFT_UPDATER_TEST_BUILD") == Some("1")
}
pub(crate) fn configured_key() -> Option<&'static str> {
let injected = option_env!("SHACRAFT_UPDATER_PUBLIC_KEY").map(str::trim);
let pinned = PINNED_KEY.trim();
// Explicit CI-only builds may use a disposable real signing key. Release CI
// forbids this switch; every such binary exposes its test provenance in status.
if test_build() {
return injected.filter(|key| key_is_valid(key));
}
if key_is_valid(pinned) && injected.is_none_or(|key| key == pinned) {
Some(pinned)
} else {
None
}
}
const PLATFORMS: [(&str, &str); 4] = [
("windows-x86_64", "windows-x86_64-setup.exe"),
("linux-x86_64", "linux-x86_64.AppImage"),
("darwin-aarch64", "darwin-aarch64.app.tar.gz"),
("darwin-x86_64", "darwin-x86_64.app.tar.gz"),
];
const MANUAL: [(&str, &str); 4] = [
("windows-x86_64-msi", "windows-x86_64.msi"),
("linux-x86_64-deb", "linux-x86_64.deb"),
("darwin-aarch64-dmg", "darwin-aarch64.dmg"),
("darwin-x86_64-dmg", "darwin-x86_64.dmg"),
];
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Artifact {
pub url: String,
pub signature: String,
pub sha256: String,
pub size: u64,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct Release {
pub schema_version: u32,
pub version: String,
pub tag: String,
pub notes: String,
#[serde(rename = "pub_date")]
pub pub_date: String,
pub platforms: BTreeMap<String, Artifact>,
pub manual_packages: BTreeMap<String, Artifact>,
}
#[derive(Clone)]
pub(crate) struct VerifiedRelease {
pub release: Release,
pub json: serde_json::Value,
}
fn decode_text(encoded: &str) -> Result<String, String> {
let bytes = STANDARD
.decode(encoded.trim())
.map_err(|_| "Некорректный формат подписи обновления")?;
String::from_utf8(bytes).map_err(|_| "Некорректный формат подписи обновления".into())
}
pub(crate) fn key_is_valid(key: &str) -> bool {
key.len() <= MAX_SIGNATURE as usize
&& decode_text(key)
.ok()
.and_then(|text| PublicKey::decode(&text).ok())
.is_some()
}
pub(crate) fn verify_signature(bytes: &[u8], signature: &str, key: &str) -> Result<(), String> {
if signature.len() > MAX_SIGNATURE as usize || key.len() > MAX_SIGNATURE as usize {
return Err("Некорректный размер подписи обновления".into());
}
let public = PublicKey::decode(&decode_text(key)?)
.map_err(|_| "Не настроен доверенный ключ обновлений")?;
let signature = Signature::decode(&decode_text(signature)?)
.map_err(|_| "Некорректный формат подписи обновления")?;
// Same minisign primitive/legacy compatibility as tauri-plugin-updater 2.11.0.
public
.verify(bytes, &signature, true)
.map_err(|_| "Подпись обновления не прошла проверку".into())
}
fn stable_version(value: &str) -> Result<Version, String> {
let v = Version::parse(value).map_err(|_| "Некорректная версия обновления")?;
if !v.pre.is_empty()
|| !v.build.is_empty()
|| v.to_string() != value
|| v.major > 255
|| v.minor > 255
|| v.patch > 65535
{
return Err("Разрешены только стабильные версии обновлений".into());
}
Ok(v)
}
impl VerifiedRelease {
pub fn parse(bytes: &[u8], signature: &str, key: &str) -> Result<Self, String> {
if bytes.len() as u64 > MAX_METADATA {
return Err("Слишком большой список обновлений".into());
}
verify_signature(bytes, signature, key)?;
let release: Release = serde_json::from_slice(bytes)
.map_err(|_| "Некорректные подписанные метаданные обновления")?;
stable_version(&release.version)?;
if release.schema_version != 1
|| release.tag != format!("v{}", release.version)
|| release.notes.len() > 4096
|| release.pub_date.len() != 20
|| !release.pub_date.ends_with('Z')
{
return Err("Неподдерживаемые метаданные обновления".into());
}
let date = time::OffsetDateTime::parse(
&release.pub_date,
&time::format_description::well_known::Rfc3339,
)
.map_err(|_| "Некорректная дата подписанного выпуска")?;
if date
.format(&time::format_description::well_known::Rfc3339)
.map_err(|_| "Некорректная дата выпуска")?
!= release.pub_date
{
return Err("Некорректная дата подписанного выпуска".into());
}
validate_artifacts(&release.platforms, &PLATFORMS, &release)?;
validate_artifacts(&release.manual_packages, &MANUAL, &release)?;
Ok(Self {
release,
json: serde_json::from_slice(bytes).map_err(|_| "Некорректные метаданные")?,
})
}
pub fn is_newer_than(&self, current: &str) -> Result<bool, String> {
let available = stable_version(&self.release.version)?;
let installed = stable_version(current)?;
if available < installed {
return Err(
"Сервер предложил более старую версию. Понижение версии заблокировано.".into(),
);
}
Ok(available > installed)
}
pub fn pinned_endpoint(&self) -> String {
format!("{RELEASES}/download/{}/latest.json", self.release.tag)
}
}
fn validate_artifacts(
values: &BTreeMap<String, Artifact>,
expected: &[(&str, &str)],
release: &Release,
) -> Result<(), String> {
if values.len() != expected.len() {
return Err("Неполный список платформ обновления".into());
}
for (platform, suffix) in expected {
let artifact = values
.get(*platform)
.ok_or("Отсутствует ожидаемая платформа обновления")?;
let expected_url = format!(
"{RELEASES}/download/{}/shacraft-launcher_{}_{}",
release.tag, release.version, suffix
);
if artifact.url != expected_url
|| artifact.size == 0
|| artifact.size > MAX_PACKAGE
|| artifact.sha256.len() != 64
|| !artifact
.sha256
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|| artifact.signature.is_empty()
|| artifact.signature.len() > MAX_SIGNATURE as usize
{
return Err("Неверная привязка пакета к версии, платформе или репозиторию".into());
}
let text = decode_text(&artifact.signature)?;
Signature::decode(&text).map_err(|_| "Некорректная подпись пакета обновления")?;
}
Ok(())
}
/// Only these GitHub release hosts may participate in HTTPS redirects. CDN query
/// parameters are GitHub's signed delivery URLs; initial URLs are exact literals.
pub(crate) fn redirect_allowed(url: &Url) -> bool {
if url.scheme() != "https"
|| !url.username().is_empty()
|| url.password().is_some()
|| url.port_or_known_default() != Some(443)
|| url.fragment().is_some()
{
return false;
}
match url.host_str() {
Some("github.com") => {
url.query().is_none()
&& url
.path()
.starts_with("/emil28092005/shacraft-launcher/releases/")
}
Some("release-assets.githubusercontent.com") => true,
_ => false,
}
}
pub(crate) fn read_bounded(
mut reader: impl Read,
limit: u64,
mut progress: impl FnMut(u64),
) -> Result<Vec<u8>, String> {
let mut result = Vec::new();
let mut buffer = [0_u8; 64 * 1024];
loop {
let count = reader
.read(&mut buffer)
.map_err(|_| "Соединение прервалось. Повторите загрузку обновления.")?;
if count == 0 {
break;
}
if result.len() as u64 + count as u64 > limit {
return Err("Размер ответа превышает подписанный предел".into());
}
result.extend_from_slice(&buffer[..count]);
progress(result.len() as u64);
}
Ok(result)
}
pub(crate) fn verify_artifact(bytes: &[u8], artifact: &Artifact, key: &str) -> Result<(), String> {
if bytes.len() as u64 != artifact.size
|| format!("{:x}", Sha256::digest(bytes)) != artifact.sha256
{
return Err(
"Размер или SHA-256 пакета обновления не совпал. Установка остановлена.".into(),
);
}
verify_signature(bytes, &artifact.signature, key)
}
pub(crate) fn install_verified<T>(
bytes: &[u8],
artifact: &Artifact,
key: &str,
installer: impl FnOnce(&[u8]) -> Result<T, String>,
) -> Result<T, String> {
verify_artifact(bytes, artifact, key)?;
installer(bytes)
}
/// A narrow injectable read boundary; production uses only the fixed HTTPS client.
pub(crate) fn fetch_release(
mut fetch: impl FnMut(&str, u64) -> Result<Option<Vec<u8>>, String>,
key: &str,
current: &str,
) -> Result<Option<VerifiedRelease>, String> {
let Some(bytes) = fetch(LATEST, MAX_METADATA)? else {
return Ok(None);
};
let signature = fetch(&format!("{LATEST}.sig"), MAX_SIGNATURE)?
.ok_or("Отсутствует подпись списка обновлений")?;
let signature =
std::str::from_utf8(&signature).map_err(|_| "Некорректная подпись списка обновлений")?;
let verified = VerifiedRelease::parse(&bytes, signature, key)?;
Ok(verified.is_newer_than(current)?.then_some(verified))
}
#[cfg(test)]
mod tests;
+204
View File
@@ -0,0 +1,204 @@
use super::*;
use std::{
cell::Cell,
collections::VecDeque,
io::{self, Cursor},
};
const KEY: &str = include_str!("../../../tests/fixtures/updater/public-key.txt");
const METADATA: &[u8] = include_bytes!("../../../tests/fixtures/updater/latest.json");
const SIG: &str = include_str!("../../../tests/fixtures/updater/latest.json.sig");
const PACKAGE: &[u8] = include_bytes!("../../../tests/fixtures/updater/package.txt");
fn release() -> VerifiedRelease {
VerifiedRelease::parse(METADATA, SIG, KEY).unwrap()
}
#[test]
fn real_tauri_signatures_authenticate_metadata_and_fake_installer_input() {
assert!(key_is_valid(KEY));
assert!(!key_is_valid("unconfigured"));
let release = release();
let artifact = &release.release.platforms["linux-x86_64"];
let calls = Cell::new(0);
install_verified(PACKAGE, artifact, KEY, |bytes| {
calls.set(calls.get() + 1);
assert_eq!(bytes, PACKAGE);
Ok(())
})
.unwrap();
assert_eq!(calls.get(), 1);
// Real signed test data only. This fake installer never executes the fixture.
}
#[test]
fn tampered_metadata_version_platform_or_package_cannot_reach_installer() {
for (from, to) in [
("0.3.0", "0.9.0"),
("darwin-aarch64", "darwin-mips64"),
("shacraft-launcher/releases", "other-repository/releases"),
] {
let changed = String::from_utf8(METADATA.to_vec())
.unwrap()
.replace(from, to);
assert!(VerifiedRelease::parse(changed.as_bytes(), SIG, KEY).is_err());
}
let release = release();
let artifact = &release.release.platforms["linux-x86_64"];
let calls = Cell::new(0);
let mut corrupt = PACKAGE.to_vec();
corrupt[0] ^= 1;
assert!(install_verified(&corrupt, artifact, KEY, |_| {
calls.set(1);
Ok(())
})
.is_err());
let mut wrong_signature = artifact.clone();
wrong_signature.signature = release.release.platforms["windows-x86_64"]
.signature
.clone();
assert!(install_verified(PACKAGE, &wrong_signature, KEY, |_| {
calls.set(1);
Ok(())
})
.is_err());
assert_eq!(calls.get(), 0);
}
#[test]
fn same_version_is_no_update_and_downgrades_or_prereleases_are_rejected() {
let release = release();
assert!(release.is_newer_than("0.2.0").unwrap());
assert!(!release.is_newer_than("0.3.0").unwrap());
assert!(release.is_newer_than("0.4.0").is_err());
for bad in [
"v0.3.0",
"0.3.0-rc.1",
"0.3.0+other",
"01.2.3",
"256.0.0",
"0.256.0",
"0.0.65536",
] {
assert!(stable_version(bad).is_err(), "{bad}");
}
}
#[test]
fn all_platform_descriptors_bind_repository_tag_filename_size_and_hash() {
let release = release();
let mut missing = release.release.platforms.clone();
missing.remove("darwin-aarch64");
assert!(validate_artifacts(&missing, &PLATFORMS, &release.release).is_err());
let mut wrong_arch = release.release.platforms.clone();
wrong_arch.insert(
"darwin-aarch64".into(),
release.release.platforms["darwin-x86_64"].clone(),
);
assert!(validate_artifacts(&wrong_arch, &PLATFORMS, &release.release).is_err());
for url in [
"https://github.com/other/repo/releases/download/v0.3.0/file",
"https://github.com/emil28092005/shacraft-launcher/releases/download/v0.2.0/file",
"https://evil.invalid/update",
] {
let mut wrong = release.release.platforms.clone();
wrong.get_mut("linux-x86_64").unwrap().url = url.into();
assert!(validate_artifacts(&wrong, &PLATFORMS, &release.release).is_err());
}
for size in [0, MAX_PACKAGE + 1] {
let mut wrong = release.release.platforms.clone();
wrong.get_mut("linux-x86_64").unwrap().size = size;
assert!(validate_artifacts(&wrong, &PLATFORMS, &release.release).is_err());
}
let mut wrong = release.release.platforms.clone();
wrong.get_mut("linux-x86_64").unwrap().sha256 = "A".repeat(64);
assert!(validate_artifacts(&wrong, &PLATFORMS, &release.release).is_err());
}
#[test]
fn github_redirects_cannot_leave_release_hosts_or_downgrade_https() {
for url in [
LATEST,
"https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/latest.json",
"https://release-assets.githubusercontent.com/github-production-release-asset/123?sig=test",
] {
assert!(redirect_allowed(&Url::parse(url).unwrap()), "{url}");
}
for url in [
"http://github.com/emil28092005/shacraft-launcher/releases",
"https://github.com/other/repository/releases/latest",
"https://release-assets.githubusercontent.com.evil.invalid/file",
"https://evil.invalid/file",
"https://user@github.com/emil28092005/shacraft-launcher/releases/a",
"https://github.com:8443/emil28092005/shacraft-launcher/releases/a",
"https://github.com/emil28092005/shacraft-launcher/releases/a#extra",
] {
assert!(!redirect_allowed(&Url::parse(url).unwrap()), "{url}");
}
}
#[test]
fn bounded_reads_reject_oversized_or_interrupted_downloads() {
assert!(read_bounded(Cursor::new(b"12345"), 4, |_| {}).is_err());
assert_eq!(
read_bounded(Cursor::new(b"1234"), 4, |_| {}).unwrap(),
b"1234"
);
struct Broken;
impl Read for Broken {
fn read(&mut self, _: &mut [u8]) -> io::Result<usize> {
Err(io::Error::other("test disconnect"))
}
}
assert!(read_bounded(Broken, 10, |_| {}).is_err());
}
#[test]
fn network_failure_and_bad_signature_retry_fetch_whole_signed_release() {
let mut replies = VecDeque::from([
Err("offline".into()),
Ok(Some(METADATA.to_vec())),
Ok(Some(b"bad-signature".to_vec())),
Ok(Some(METADATA.to_vec())),
Ok(Some(SIG.as_bytes().to_vec())),
]);
let mut urls = Vec::new();
let mut fetch = |url: &str, _| {
urls.push(url.to_string());
replies.pop_front().unwrap()
};
assert!(fetch_release(&mut fetch, KEY, "0.2.0").is_err());
assert!(fetch_release(&mut fetch, KEY, "0.2.0").is_err());
assert_eq!(
fetch_release(&mut fetch, KEY, "0.2.0")
.unwrap()
.unwrap()
.release
.version,
"0.3.0"
);
assert_eq!(
urls,
[
LATEST,
LATEST,
&format!("{LATEST}.sig"),
LATEST,
&format!("{LATEST}.sig")
]
);
assert!(fetch_release(|_, _| Ok(None), KEY, "0.2.0")
.unwrap()
.is_none());
}
#[test]
fn installer_failure_is_reported_and_a_fresh_attempt_reverifies_input() {
let release = release();
let artifact = &release.release.platforms["linux-x86_64"];
assert!(install_verified(PACKAGE, artifact, KEY, |_| Err::<(), _>(
"simulated installer failure".into()
))
.is_err());
assert!(install_verified(PACKAGE, artifact, KEY, |_| Ok(())).is_ok());
}
+18 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ShaCraft Launcher",
"version": "0.1.1",
"version": "0.2.0",
"identifier": "ru.shacraft.launcher",
"build": {
"beforeDevCommand": "npm run dev",
@@ -35,6 +35,22 @@
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
],
"windows": {
"wix": {
"upgradeCode": "2058b1df-56a1-51ef-bd48-d296479cd59a"
},
"nsis": {
"installMode": "currentUser"
}
}
},
"plugins": {
"updater": {
"pubkey": "",
"windows": {
"installMode": "passive"
}
}
}
}
+59
View File
@@ -0,0 +1,59 @@
{
"manualPackages": {
"darwin-aarch64-dmg": {
"sha256": "409ed0537cccf2724f405fd0031f6e84718442b263b75513532e03ecc991a678",
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheUlYSjNaNG55a084WTlCTFlhQTdOdlNNZ3JYYjNaeXZLOThXWERjN3kwWkRtcnNCN0NMVDJBOEpUdVVlS2JJYkpZc3dkdWtyOXQvQ2k3dGJ1bldyRXc4PQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzIwCWZpbGU6c2hhY3JhZnQtbGF1bmNoZXJfMC4zLjBfZGFyd2luLWFhcmNoNjQuZG1nCnRnRlh2dnFUdnJ1MG4vTTF6SExvNzk3ZVpvUkZ5Nk0vdjRiSFpiaHFZUkw5a3N4TkkyODV3RDY0MmJyTXpNdFUwL1NBSlluTG5WeVlMdE1xa3pkWUNnPT0K",
"size": 97,
"url": "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/shacraft-launcher_0.3.0_darwin-aarch64.dmg"
},
"darwin-x86_64-dmg": {
"sha256": "70496d49bf410231a2575c384a306023ab5eeaf312d4dd93b6e8aed3e2ee9492",
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheUJ2biswTzY5QmNNU2hmRHVRV2xpUEtBay94SW1NdHArVnJDQmhLZ3N4UEVQSkxJbG92dmF1bSt4RmJkSm0wOExzNlRDUHJJaVpEUWZvVGoxd0NkNFFrPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzIwCWZpbGU6c2hhY3JhZnQtbGF1bmNoZXJfMC4zLjBfZGFyd2luLXg4Nl82NC5kbWcKVEFaRkxCUFlSRUQ1T0NOa0doclVucnVLNWw0STcxV05ybkxvcWNrazlPdGVEdU5FTlVlMTFSNFlTUEV1RUNvREZqTFZ0UlN3MTJRcGttQWpxblZUQUE9PQo=",
"size": 96,
"url": "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/shacraft-launcher_0.3.0_darwin-x86_64.dmg"
},
"linux-x86_64-deb": {
"sha256": "cefbadc09d008ff4b0f26ee788f524644b7dadf9de4549bd8d841e44ad92e292",
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheU9BMWNrS1hCWHdJWlNFczFJNklxMDJsdHMwZ1NFakpxTDExS0xFUXVwWVFRTVh3Q2Vpc0MvUEI4NXZmTjJjMnZaRFoydVk1SHdMSkkwNFRqMkZIUUFFPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzE5CWZpbGU6c2hhY3JhZnQtbGF1bmNoZXJfMC4zLjBfbGludXgteDg2XzY0LmRlYgpjMVZYeVJuUEZWWithQ2lFVHdkWXBqMDA4NGlKT3FYL3djclhyK1hTSE1RVzVrN0p6TVV1V3plOFlKZHQ5K1pzWDMzcmtPYWFPaCtZeEE4WWt6cW5EZz09Cg==",
"size": 95,
"url": "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/shacraft-launcher_0.3.0_linux-x86_64.deb"
},
"windows-x86_64-msi": {
"sha256": "fac4bdeb3c95d4c0a91b5f03215a0dd8f3e676b7e7f9140eef1616d2fefa5c4b",
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheUdYODluWEtUd2RGNUVyRzNTQWpNV1VoOXpFaXVCZ05SbUpwQTQzYnU2WmI0bzIxVTBTdXFNN2p0Z3NYRndhNmlqTGFzRkZiYllhb0pzbFN4UkMrUUFrPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzE5CWZpbGU6c2hhY3JhZnQtbGF1bmNoZXJfMC4zLjBfd2luZG93cy14ODZfNjQubXNpClZxRlZTL2NobmcreDBLVnc5QzdyZHRkS01NVVZOY2ZpcDNBTWcvUGt6dm15bUNTUzEra09RRDk1Z1VWbmwyU1NjblFXQ0NCcXhTUlpnUEpocWpqQkRRPT0K",
"size": 97,
"url": "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/shacraft-launcher_0.3.0_windows-x86_64.msi"
}
},
"notes": "Synthetic test metadata.",
"platforms": {
"darwin-aarch64": {
"sha256": "73f3eea52be58fb872d2f6423f5cc604b1599403a556a2208173f8b3ea1e66cb",
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheVBPN2xlbzEyQ3h2SkRCdzNlUnFxVEhzQjNYRGNNT1B2enc5TUxKQ1U5TDdwaGhkOUxqNTRnZ3FheDN6UE82SWpMR0YvQUZQR255bWRZcTdYYy9qMWdjPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzE5CWZpbGU6c2hhY3JhZnQtbGF1bmNoZXJfMC4zLjBfZGFyd2luLWFhcmNoNjQuYXBwLnRhci5negp5ckVPVnhvSE1yNitYZm9SRi9KRVNjTWhMVUY3dnNDYmViSHNSWmE2TlFpOVJqd2RxZVJuY0NqVHd6UU5PYUt2NXVQN1JKSHlhZVdiY3JvTjdLcUZCQT09Cg==",
"size": 104,
"url": "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/shacraft-launcher_0.3.0_darwin-aarch64.app.tar.gz"
},
"darwin-x86_64": {
"sha256": "548d2042689d797dbcb29f09d206e1c36814880a831a26945bf0c550f2617a64",
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheVB5MVRuUU52OEdLZDJHYWhmMUtWaDNQMnM0U0xCcC9sdkE0TWhSK0svNzRuTVBtVW5NUTh6MEQ5amVKd0Vpa1l4cWZ2YzNtWW41Y0RQV0VrTitUbVF3PQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzE5CWZpbGU6c2hhY3JhZnQtbGF1bmNoZXJfMC4zLjBfZGFyd2luLXg4Nl82NC5hcHAudGFyLmd6ClFuY3IwOXZKaWsrUTIwMGZmTFdFZmh2K3ZEcDAzYXBpSi8wVnJNS05Zb25YdVgyd1IzLzZud1JqaFNHdXZwa0FpclEyQnplZTFjR1RqNUpzQm9qdUJnPT0K",
"size": 103,
"url": "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/shacraft-launcher_0.3.0_darwin-x86_64.app.tar.gz"
},
"linux-x86_64": {
"sha256": "150e75098117b452b56bfd3925e2c6af03129b47e17be4529f4498b17a9ce9ae",
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheUh1Znd0d2k0c3VGd085c3N2RWVmZnliem04Tzc4MjBaOE1Sd1NWdU9pNU9zZjlMZWNoRnBxaDN6eGlER0hxTDVyRUtVdXdjOUhWK29oM1hSSC84Qmc4PQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzIwCWZpbGU6c2hhY3JhZnQtbGF1bmNoZXJfMC4zLjBfbGludXgteDg2XzY0LkFwcEltYWdlCmdFZTNDdmt0ckRySnB3NWtBeUdYV3drSVZidmNKZEpsOGptcERGK0VjQmpUUDU0c1ZWV1diTWVTZTlhRzVnUVJFRlVITVNhVGxrbS8rMkdTREtUQUNBPT0K",
"size": 100,
"url": "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/shacraft-launcher_0.3.0_linux-x86_64.AppImage"
},
"windows-x86_64": {
"sha256": "a9773409a6545e98af6a9aad2de2b7d89be257c33af6566bd7ffefc1f443d1be",
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheUduVjJYSS83QzVIMXRBK0ZabHk1UGxZMUxkNVkzYXE4VXgranlqRnluSlhOdlBBWU5qaTB5MmQySTZ6M3pZMmZHNHI4dkZpRzk2Q2NXWS9GbXJ2N3dVPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzIwCWZpbGU6c2hhY3JhZnQtbGF1bmNoZXJfMC4zLjBfd2luZG93cy14ODZfNjQtc2V0dXAuZXhlCmlneXpmSGc0WlM3STgxTHUzbldxdVpqbjhraUp6K1c3djhQSWdmOHh0WTFwMnB2cmIwMHdjMVVOajJKVWR0MTVwTTBYd0NvYUk1OVVuemRUakh3SkJBPT0K",
"size": 103,
"url": "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/shacraft-launcher_0.3.0_windows-x86_64-setup.exe"
}
},
"pub_date": "2026-09-09T00:00:00Z",
"schemaVersion": 1,
"tag": "v0.3.0",
"version": "0.3.0"
}
+1
View File
@@ -0,0 +1 @@
dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheUVnVElINVRoVDVpb2VGV21XT0wzYXN1WmxxTEhGZHVvYzg3S1FVcUFPVnVqa3FpRXFWbFl2T2FHN2ZmOU1FRVpQR1krREhCcU00RVpXMXYzd2s5UndnPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzIxCWZpbGU6bGF0ZXN0Lmpzb24KVkxhbGNUZE9NSUpTWlR3L1hDczFTTkFzbjkrK1ZyWE9EeG5xTUVBUnRYcW1kZHphNXVTTEs4OTU0WitORWdQdk9PaXNhVWVvbTFJRHFBeWRtQmdzQXc9PQo=
+2
View File
@@ -0,0 +1,2 @@
ShaCraft synthetic fixture; never execute or install.
shacraft-launcher_0.3.0_linux-x86_64.AppImage
+1
View File
@@ -0,0 +1 @@
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEM4NUEyNTNEQjlERDZDMkIKUldRcmJOMjVQU1ZheUQ2UzVTN0NHS21ydFp1c1REajVucjlXYnFPK3ZSYWYrQVFDaUgvL3lpQ2UK
+1
View File
@@ -0,0 +1 @@
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDIyODAzRjlGOUFFNDM4QzYKUldUR09PU2FueitBSWhoU1Y4S2VFK21OeWRmamVkMlBreWRjbWdtRmNtbEZrTzMwNE92MDU2d3YK
+38 -9
View File
@@ -1,4 +1,5 @@
import { useCallback, useState } from 'react'
import { LegacyModsDialog } from './components/LegacyModsDialog'
import { Library } from './components/Library'
import { RecoveryCodesModal } from './components/RecoveryCodesModal'
import { PlayDock } from './components/PlayDock'
@@ -10,6 +11,7 @@ import { useAccount } from './hooks/useAccount'
import { useLauncher } from './hooks/useLauncher'
import { useServerStatus } from './hooks/useServerStatus'
import { useSettings } from './hooks/useSettings'
import { useUpdater } from './hooks/useUpdater'
import { isNative } from './services/native'
import { launchAccess } from './state/account'
import { installStageLabels } from './state/game'
@@ -17,6 +19,7 @@ import { installStageLabels } from './state/game'
export function App() {
const [selected, setSelected] = useState(servers[0])
const [settingsOpen, setSettingsOpen] = useState(false)
const [legacyOpen, setLegacyOpen] = useState(false)
const [windowError, setWindowError] = useState<string | null>(null)
const preferences = useSettings()
const session = useAccount()
@@ -25,19 +28,32 @@ export function App() {
const closeSettings = useCallback(() => setSettingsOpen(false), [])
const desktop = isNative()
const profile = launcher.profiles[selected.profileId]
const metadata = launcher.metadata[selected.profileId]
const displayed = { ...selected, version: metadata ? `Minecraft ${metadata.minecraftVersion}` : 'Версия уточняется',
loader: metadata ? `${metadata.loaderKind} ${metadata.loaderVersion}` : 'По подписанной сборке' }
const ready = profile?.inspection?.upToDate === true
const operation = launcher.game.operation
const busy = operation.phase !== 'idle'
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 updaterBlocked = busy ? 'Завершите игру или дождитесь окончания работы со сборкой.'
: settingsBlocked ? 'Дождитесь сохранения настроек; при ошибке повторите сохранение.'
: session.busy || access === 'loading' ? 'Дождитесь завершения работы с аккаунтом.'
: session.linking ? 'Завершите подтверждение игрового ника перед обновлением лаунчера.'
: session.recoveryCodes.length ? 'Сначала сохраните коды восстановления аккаунта.'
: legacyOpen ? 'Закройте проверку старых модов перед обновлением лаунчера.' : null
const updater = useUpdater(updaterBlocked)
const updaterRecovery = updater.state.status?.phase === 'error' && !updater.state.status.canRetry
const updateLocked = updater.mutating || updater.state.status?.phase === 'ready' || updaterRecovery
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
let label = 'Играть'
if (!desktop) label = 'В приложении'
else if (updateLocked) label = updaterRecovery ? 'Нужно восстановить лаунчер' : 'Обновление лаунчера'
else if (operation.phase === 'running') label = 'Игра запущена'
else if (operation.phase === 'launching') label = 'Запускаем…'
else if (operation.phase === 'installing') label = operation.progress ? `${installStageLabels[operation.progress.stage]}` : 'Подготовка…'
@@ -50,6 +66,11 @@ export function App() {
else if (checking) label = 'Проверяем…'
else if (!ready) label = 'Проверить'
const onboard = async (nickname: string) => {
if (busy || updateLocked || settingsBlocked || !launcher.eventsReady) return
const result = await launcher.onboard(selected.profileId, nickname)
if (result?.onboarding) session.acceptChallenge(result.onboarding)
}
const primary = () => {
if (disabled) return
if (access !== 'ready') setSettingsOpen(true)
@@ -59,19 +80,27 @@ export function App() {
return (
<div className="app-shell">
<Titlebar host={launcher.host} onError={setWindowError} />
<div className="workspace" inert={session.recoveryCodes.length > 0}>
<div className="workspace" inert={session.recoveryCodes.length > 0 || legacyOpen}>
<Library selected={selected} profiles={launcher.profiles} account={session.account}
native={desktop} locked={busy || session.busy} onSelect={setSelected} onSettings={() => setSettingsOpen(true)} />
<ServerStage server={selected} status={serverStatus}>
<PlayDock server={selected} operation={operation} profile={profile}
native={desktop} locked={busy || updateLocked || session.busy} onSelect={setSelected} onSettings={() => setSettingsOpen(true)} />
<ServerStage server={displayed} status={serverStatus} javaMajor={metadata?.javaMajor}>
{(updater.state.status?.phase === 'available' || (updater.state.status?.phase === 'manual' && updater.state.status.availableVersion) || updater.state.status?.phase === 'ready' || updater.mutating || updaterRecovery) &&
<button className="launcher-update-notice" onClick={() => setSettingsOpen(true)}>
{updaterRecovery ? 'Нужно восстановить лаунчер' : updater.state.status?.phase === 'ready' ? 'Перезапустите лаунчер после обновления'
: updater.mutating ? 'Обновление лаунчера…' : `Доступен лаунчер ${updater.state.status?.availableVersion ?? ''}`}
<span>Открыть настройки</span>
</button>}
<PlayDock server={displayed} operation={operation} profile={profile}
memoryGb={preferences.settings.memoryMb / 1024} native={desktop}
needsLogin={access === 'login'} needsLink={access === 'link'}
error={error} label={label} primaryDisabled={disabled} repairDisabled={repairDisabled}
onPrimary={primary} onRepair={() => { if (!repairDisabled) void launcher.repair(selected.profileId) }} />
onLegacy={() => setLegacyOpen(true)} onPrimary={primary} onRepair={() => { if (!repairDisabled) void launcher.repair(selected.profileId) }} />
</ServerStage>
</div>
<SettingsDrawer open={settingsOpen && !session.recoveryCodes.length} locked={busy} preferences={preferences}
session={session} host={launcher.host} java={launcher.java} onClose={closeSettings} />
<SettingsDrawer open={settingsOpen && !session.recoveryCodes.length} locked={busy || updateLocked} preferences={preferences}
updater={updater} native={desktop}
session={session} host={launcher.host} java={launcher.java} requiredJava={metadata?.javaMajor} onOnboard={onboard} onClose={closeSettings} />
{legacyOpen && <LegacyModsDialog profileId={selected.profileId} onClose={() => setLegacyOpen(false)} onChanged={() => { void launcher.refreshProfile(selected.profileId) }} />}
<RecoveryCodesModal codes={session.recoveryCodes} onAcknowledge={session.acknowledgeRecoveryCodes} />
</div>
)
+9 -3
View File
@@ -3,7 +3,7 @@ import { LogOut, Users } from 'lucide-react'
import type { useAccount } from '../hooks/useAccount'
import { isNative } from '../services/native'
export function AccountSettings({ session, locked }: { session: ReturnType<typeof useAccount>; locked: boolean }) {
export function AccountSettings({ session, locked, onOnboard }: { session: ReturnType<typeof useAccount>; locked: boolean; onOnboard?: (nickname: string) => Promise<void> }) {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [registering, setRegistering] = useState(false)
@@ -49,11 +49,17 @@ export function AccountSettings({ session, locked }: { session: ReturnType<typeo
<small>Подтвердите владение ником на сервере Aeronautics.</small>
</label>
<button className="setting-row" type="submit" disabled={disabled || session.linking}>
<span>{session.linking ? 'Ожидаем подтверждения…' : 'Привязать ник'}</span>
<span>{session.linking ? 'Ожидаем подтверждения…' : 'Привязать ник через открытую игру'}</span>
</button>
{onOnboard && <button className="setting-row" type="button" disabled={disabled || session.linking || !/^[A-Za-z0-9_]{3,16}$/.test(nickname)}
onClick={() => { void onOnboard(nickname) }}><span>Установить и войти для подтверждения</span></button>}
<p className="account-hint">Нужна действующая проходка и ник в whitelist. Новый ник: /register, существующий: /login с игровым паролем. После входа выполните команду подтверждения.</p>
</form>
)}
{account && linkedNickname && <div className="setting-row static"><span>Игровой ник</span><small>{linkedNickname}</small></div>}
{account && linkedNickname && <>
<div className="setting-row static"><span>Игровой ник</span><small>{linkedNickname}</small></div>
<button className="setting-row" type="button" disabled={disabled || session.linking} onClick={() => { void session.startLink(linkedNickname) }}><span>Повторно подтвердить этот ник в игре</span></button>
</>}
{session.linkMessage && <p className="account-hint" role="status">{session.linkMessage}</p>}
{session.error && <p className="status-error account-hint" role="alert">{session.error}</p>}
{account && <button className="setting-row" disabled={disabled} onClick={session.logout}><span><LogOut />Выйти из ShaCraft</span></button>}
+76
View File
@@ -0,0 +1,76 @@
import { Download, RefreshCw } from 'lucide-react'
import { canRunUpdater, updaterPercent } from '../state/updater'
import type { UpdaterState } from '../state/updater'
export interface LauncherUpdateProps {
state: UpdaterState
native: boolean
installedVersion?: string
onCheck: () => void
onInstall: () => void
onRestart: () => void
onOpenRelease: () => void
}
export function LauncherUpdate({ state, native, installedVersion, onCheck, onInstall, onRestart, onOpenRelease }: LauncherUpdateProps) {
const status = state.status
const phase = status?.phase
const percent = updaterPercent(status)
const downloadedBytes = status?.downloadedBytes ?? 0
const error = state.error ?? (phase === 'error' ? status?.message : null)
const needsRecovery = phase === 'error' && status?.canRetry === false
const checking = state.pending?.command === 'check' || phase === 'checking' || state.pending?.command === 'status'
const downloading = phase === 'downloading'
const progressing = downloading || phase === 'verifying' || phase === 'installing' || state.pending?.command === 'install'
const packageFormat = status?.packageFormat === 'development' ? 'для разработки'
: status?.packageFormat === 'unpackaged' ? 'без установщика' : status?.packageFormat
const titles = {
idle: 'Можно проверить новую версию', checking: 'Проверяем обновления…',
available: 'Доступно обновление', downloading: 'Скачиваем обновление…',
verifying: 'Проверяем подпись обновления…', installing: 'Устанавливаем обновление…',
ready: 'Обновление установлено — нужен перезапуск', no_update: 'Установлена актуальная версия',
unconfigured: 'Автообновление пока не настроено', manual: 'Обновление пакета вручную', error: 'Не удалось обновить лаунчер',
}
return (
<section className="launcher-update" aria-labelledby="launcher-update-title">
<div className="launcher-update-heading"><h3 id="launcher-update-title">Лаунчер</h3>
<span>Версия {status?.installedVersion ?? installedVersion ?? 'уточняется'}{packageFormat && ` · ${packageFormat}`}</span></div>
{status?.testBuild && <p className="launcher-update-blocked">Тестовая сборка · тестовый канал обновлений</p>}
<p className="launcher-update-status" role="status">{!native ? 'Обновления доступны в приложении лаунчера.'
: phase ? titles[phase] : checking ? 'Читаем состояние обновления…' : 'Проверьте доступные обновления.'}</p>
{status?.availableVersion && <p>Новая версия: <strong>{status.availableVersion}</strong></p>}
{phase === 'manual' && <p>{status?.packageFormat === 'deb'
? 'Этот пакет обновляется вручную. Для установки .deb используйте системный менеджер пакетов; версия и инструкция доступны на странице выпусков.'
: 'Эта сборка обновляется вручную. Выберите пакет для своей системы на странице выпусков.'}</p>}
{phase === 'unconfigured' && <p>Для этой сборки канал обновлений недоступен. Проверка не меняет установленный лаунчер.</p>}
{phase === 'available' && <p>Установка закроет и перезапустит лаунчер. Игру потребуется завершить, изменения настроек сохранить.</p>}
{phase === 'ready' && <p>Чтобы продолжить работу в новой версии, перезапустите лаунчер.</p>}
{needsRecovery && <p>Автоматическое продолжение недоступно. Восстановите лаунчер из пакета на странице выпусков.</p>}
{status?.message && phase !== 'error' && <p>{status.message}</p>}
{status?.releaseNotes && <details className="launcher-update-notes"><summary>Что изменилось</summary><p tabIndex={0} aria-label="Описание изменений">{status.releaseNotes}</p></details>}
{progressing && <div className="launcher-update-progress" role="progressbar" aria-label="Обновление лаунчера"
aria-valuemin={0} aria-valuemax={100} aria-valuenow={downloading && percent !== null ? percent : undefined}>
<i style={downloading && percent !== null ? { width: `${percent}%` } : undefined} />
</div>}
{downloading && <p>{percent === null
? `Скачано: ${(Math.max(0, Number.isFinite(downloadedBytes) ? downloadedBytes : 0) / 1024 / 1024).toFixed(1)} МБ`
: `Скачано: ${percent}%`}</p>}
{error && <p className="status-error" role="alert">{error}</p>}
{state.blockedReason && native && <p className="launcher-update-blocked">{state.blockedReason}</p>}
<div className="launcher-update-actions">
{phase !== 'ready' && !needsRecovery && <button type="button" disabled={!native || !canRunUpdater(state, 'check')} onClick={onCheck}>
<RefreshCw size={15} />{checking ? 'Проверяем…' : error ? 'Повторить проверку' : 'Проверить обновления'}
</button>}
{phase === 'available' && <button type="button" className="launcher-update-primary" disabled={!native || !canRunUpdater(state, 'install')} onClick={onInstall}>
<Download size={15} />Установить и перезапустить
</button>}
{phase === 'ready' && <button type="button" className="launcher-update-primary" disabled={!native || !canRunUpdater(state, 'restart')} onClick={onRestart}>
{state.pending?.command === 'restart' ? 'Перезапускаем…' : 'Перезапустить лаунчер'}
</button>}
{(phase === 'manual' || needsRecovery) && <button type="button" disabled={!native || !canRunUpdater(state, 'open')} onClick={onOpenRelease}>
{state.pending?.command === 'open' ? 'Открываем…' : 'Открыть страницу выпусков'}
</button>}
</div>
</section>
)
}
+96
View File
@@ -0,0 +1,96 @@
import { useEffect, useRef, useState } from 'react'
import { native } from '../services/native'
import { errorMessage } from '../services/async'
import type { LegacyMod } from '../types/launcher'
export function LegacyModsDialog({ profileId, onClose, onChanged }: { profileId: string; onClose: () => void; onChanged: () => void }) {
const [files, setFiles] = useState<LegacyMod[]>([])
const [selected, setSelected] = useState<Set<string>>(new Set())
const [busy, setBusy] = useState(true)
const [moving, setMoving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [backup, setBackup] = useState<string | null>(null)
const dialog = useRef<HTMLElement>(null)
const closeButton = useRef<HTMLButtonElement>(null)
const request = useRef(0)
const moveInFlight = useRef(false)
const mounted = useRef(false)
useEffect(() => {
mounted.current = true
const previous = document.activeElement
closeButton.current?.focus()
return () => {
mounted.current = false
if (previous instanceof HTMLElement && previous.isConnected) previous.focus()
}
}, [])
useEffect(() => {
const current = ++request.current
setFiles([]); setSelected(new Set()); setBackup(null); setError(null); setBusy(true)
void native.legacyMods(profileId).then((value) => { if (request.current === current) setFiles(value) })
.catch((reason) => { if (request.current === current) setError(errorMessage(reason, 'Не удалось проверить моды')) })
.finally(() => { if (request.current === current) setBusy(false) })
return () => { request.current++ }
}, [profileId])
const move = async () => {
if (busy || moveInFlight.current || !selected.size) return
const current = request.current
moveInFlight.current = true
setBusy(true); setMoving(true); setError(null)
dialog.current?.focus()
try {
const result = await native.backupLegacyMods(profileId, files.filter((f) => selected.has(f.path)).map(({path,sha256}) => ({path,sha256})))
moveInFlight.current = false
if (mounted.current) setMoving(false)
if (request.current !== current) return
setBackup(result.backupRoot); setSelected(new Set())
onChanged()
const remaining = await native.legacyMods(profileId)
if (request.current === current) setFiles(remaining)
} catch (reason) {
if (request.current === current) setError(errorMessage(reason, 'Не удалось перенести выбранные моды'))
} finally {
moveInFlight.current = false
if (mounted.current) setMoving(false)
if (request.current === current) setBusy(false)
}
}
return <div className="legacy-overlay"><section ref={dialog} className="legacy-dialog" role="dialog" aria-modal="true"
aria-labelledby="legacy-title" aria-describedby="legacy-description" tabIndex={-1}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault(); event.stopPropagation()
if (!moveInFlight.current) onClose()
}
if (event.key !== 'Tab') return
const elements = dialog.current?.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled)')
const first = elements?.[0]
const last = elements?.[elements.length - 1]
if (!first) { event.preventDefault(); dialog.current?.focus() }
else if (event.shiftKey && (document.activeElement === first || document.activeElement === dialog.current)) {
event.preventDefault(); last?.focus()
} else if (!event.shiftKey && (document.activeElement === last || document.activeElement === dialog.current)) {
event.preventDefault(); first.focus()
}
}}>
<h2 id="legacy-title">Проверка старых и изменённых модов</h2>
<p id="legacy-description">Эти файлы сохранены: их происхождение или содержимое отличается от ожидаемого. Здесь могут быть ваши моды. Выберите только те, которые хотите убрать из сборки в резервную копию. По умолчанию ничего не выбрано.</p>
{busy && <p role="status">{moving ? 'Переносим выбранные файлы в резервную копию…' : 'Проверяем файлы…'}</p>}
{!busy && files.length === 0 && <p>Файлов для ручного разбора нет.</p>}
<div className="legacy-list">{files.map((file) => <label key={file.path}>
<input type="checkbox" checked={selected.has(file.path)} disabled={busy} onChange={(e) => setSelected((old) => {
const next = new Set(old); if (e.target.checked) next.add(file.path); else next.delete(file.path); return next
})} />
<span><strong>{file.path}</strong><small>{(file.size / 1024 / 1024).toFixed(1)} МБ · {file.reason === 'changed_managed' ? 'Изменён после установки' : 'Неизвестное происхождение'}</small>
<small>SHA-256: {file.sha256}</small></span>
</label>)}</div>
{backup && <p role="status">Копии сохранены: {backup}. Теперь можно повторить проверку сборки.</p>}
{error && <p className="status-error" role="alert">{error}</p>}
<div className="legacy-actions"><button disabled={busy || selected.size === 0} onClick={() => { void move() }}>Перенести выбранные ({selected.size}) в резервную копию</button>
<button ref={closeButton} disabled={moving} onClick={onClose}>Закрыть</button></div>
</section></div>
}
+6 -2
View File
@@ -18,6 +18,7 @@ interface PlayDockProps {
repairDisabled: boolean
onPrimary: () => void
onRepair: () => void
onLegacy: () => void
}
export function PlayDock(props: PlayDockProps) {
@@ -41,7 +42,9 @@ export function PlayDock(props: PlayDockProps) {
} else {
title = needsLogin ? 'Нужен вход ShaCraft' : props.needsLink ? 'Нужно привязать ник' : profile?.status === 'checking' ? 'Проверяем сборку'
: profile?.inspection?.upToDate ? 'Сборка готова' : 'Требуется проверка'
detail = profile?.inspection ? `${profile.inspection.managedFiles} файлов под контролем` : 'Проверяем локальные файлы'
detail = profile?.inspection
? `${profile.inspection.upToDate ? 'Проверено файлов сборки' : 'Файлов в сборке'}: ${profile.inspection.managedFiles}`
: 'Проверяем локальные файлы'
}
return (
@@ -58,7 +61,8 @@ export function PlayDock(props: PlayDockProps) {
<span><Globe2 size={15} /> {server.version}</span>
<span><Gauge size={15} /> {memoryGb} ГБ памяти</span>
</div>
<button className="repair-button" onClick={props.onRepair} disabled={props.repairDisabled} aria-label="Проверить файлы"><RotateCcw size={19} /></button>
<button className="legacy-button" onClick={props.onLegacy} disabled={props.repairDisabled}>Разобрать моды</button>
<button className="repair-button" onClick={props.onRepair} disabled={props.repairDisabled} aria-label="Проверить сборку и восстановить игру" title="Проверить сборку и восстановить игру"><RotateCcw size={19} /></button>
<button className="play-button" disabled={props.primaryDisabled} onClick={props.onPrimary}>
<Play size={21} fill="currentColor" /><span>{props.label}</span>
</button>
+2 -2
View File
@@ -3,7 +3,7 @@ import { Users } from 'lucide-react'
import { isNative } from '../services/native'
import type { Server, ServerStatus } from '../types/launcher'
export function ServerStage({ server, status, children }: { server: Server; status: ServerStatus | null; children: ReactNode }) {
export function ServerStage({ server, status, javaMajor, children }: { server: Server; status: ServerStatus | null; javaMajor?: number; children: ReactNode }) {
const online = status?.reachable && status.online !== null && status.max !== null
? `${status.online} / ${status.max}` : !isNative() ? 'В приложении' : status === null ? 'Проверяем…' : 'Нет связи'
const label = !isNative() ? 'Статус в приложении' : status === null ? 'Проверяем сервер' : status.reachable ? 'Сервер доступен' : 'Сервер недоступен'
@@ -17,7 +17,7 @@ export function ServerStage({ server, status, children }: { server: Server; stat
<p>{server.kicker}</p><h1>{server.name}</h1><h2>{server.subtitle}</h2>
<dl className="hero-meta">
<div><dt>Загрузчик</dt><dd>{server.loader}</dd></div>
<div><dt>Java</dt><dd>Версия 21</dd></div>
<div><dt>Java</dt><dd>{javaMajor ? `Версия ${javaMajor}` : 'По подписанной сборке'}</dd></div>
</dl>
</section>
{children}
+17 -6
View File
@@ -1,6 +1,8 @@
import { useEffect, useRef } from 'react'
import { FolderOpen, Wrench, X } from 'lucide-react'
import { AccountSettings } from './AccountSettings'
import { LauncherUpdate } from './LauncherUpdate'
import type { useUpdater } from '../hooks/useUpdater'
import type { useAccount } from '../hooks/useAccount'
import type { useSettings } from '../hooks/useSettings'
import type { JavaInstallation, NativeHost } from '../types/launcher'
@@ -13,9 +15,13 @@ interface SettingsDrawerProps {
preferences: ReturnType<typeof useSettings>
session: ReturnType<typeof useAccount>
onClose: () => void
requiredJava?: number
onOnboard?: (nickname: string) => Promise<void>
updater: ReturnType<typeof useUpdater>
native: boolean
}
export function SettingsDrawer({ open, locked, host, java, preferences, session, onClose }: SettingsDrawerProps) {
export function SettingsDrawer({ open, locked, host, java, preferences, session, onClose, requiredJava, onOnboard, updater, native }: SettingsDrawerProps) {
const { settings, loaded, saving, error } = preferences
const closeButton = useRef<HTMLButtonElement>(null)
useEffect(() => {
@@ -25,7 +31,9 @@ export function SettingsDrawer({ open, locked, host, java, preferences, session,
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose()
if (event.key !== 'Tab') return
const elements = closeButton.current?.closest('aside')?.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled)')
const elements = Array.from(closeButton.current?.closest('aside')?.querySelectorAll<HTMLElement>(
'button:not(:disabled), input:not(:disabled), select:not(:disabled), summary, [tabindex="0"]',
) ?? []).filter((element) => element.getClientRects().length > 0)
const first = elements?.[0]
const last = elements?.[elements.length - 1]
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus() }
@@ -47,25 +55,28 @@ export function SettingsDrawer({ open, locked, host, java, preferences, session,
<div><p>Настройки</p><h2 id="settings-title">Игра</h2></div>
<button ref={closeButton} onClick={onClose} aria-label="Закрыть настройки"><X /></button>
</div>
<LauncherUpdate state={updater.state} native={native} installedVersion={host?.launcherVersion}
onCheck={updater.check} onInstall={updater.install} onRestart={updater.restart} onOpenRelease={updater.openRelease} />
<label className="range-setting">
<span><strong>Оперативная память</strong><b>{settings.memoryMb / 1024} ГБ</b></span>
<input type="range" min="3" max="12" step="1" value={settings.memoryMb / 1024} disabled={!loaded || locked}
onChange={(event) => preferences.updateRam(Number(event.target.value))} />
<small>Для Aeronautics рекомендуется 6 ГБ</small>
</label>
<AccountSettings session={session} locked={locked || saving} />
<AccountSettings session={session} locked={locked || saving} onOnboard={onOnboard} />
<div className="setting-row static"><span><FolderOpen />Папка игры</span><small>{host ? 'В каталоге лаунчера' : 'Определяется…'}</small></div>
<div className="setting-row static">
<span><Wrench />Java</span>
<small>{java === undefined ? host ? 'Проверяем…' : 'Проверяется в приложении'
: java?.major === 21 ? 'Java 21 найдена' : java ? `Нужна Java 21 · найдена ${java.major}`
: 'Лаунчер установит Java 21 автоматически'}</small>
: requiredJava === undefined ? 'Требование Java уточняется по сборке'
: java?.major === requiredJava ? `Java ${requiredJava} найдена` : java ? `Нужна Java ${requiredJava} · найдена ${java.major}`
: `Лаунчер установит Java ${requiredJava} автоматически`}</small>
</div>
<div className="settings-feedback" aria-live="polite">
{error && <><p className="status-error">{error}</p><button onClick={preferences.retry} disabled={locked || saving}>Повторить</button></>}
{saving && <p>Сохраняем настройки</p>}
</div>
<div className="drawer-note">{host ? `Данные лаунчера: ${host.dataDir}` : 'Java 21 будет управляться лаунчером автоматически.'}</div>
<div className="drawer-note">{host ? `Данные лаунчера: ${host.dataDir}` : 'Java будет подобрана по подписанной сборке.'}</div>
</aside>
</>
)
+52
View File
@@ -3,6 +3,10 @@ import { test } from 'node:test'
import { renderToStaticMarkup } from 'react-dom/server'
import { AccountSettings } from './AccountSettings'
import { RecoveryCodesModal } from './RecoveryCodesModal'
import { LegacyModsDialog } from './LegacyModsDialog'
import { PlayDock } from './PlayDock'
import { launchAccess } from '../state/account'
import { servers } from '../data/servers'
import type { useAccount } from '../hooks/useAccount'
function session(): ReturnType<typeof useAccount> {
@@ -10,6 +14,7 @@ function session(): ReturnType<typeof useAccount> {
account: null, error: null, busy: false, recoveryCodes: [], linkMessage: null,
linking: false, linkedNickname: null, authenticate: async () => true,
logout: async () => {}, startLink: async () => {}, clearError: () => {},
acceptChallenge: () => {},
acknowledgeRecoveryCodes: () => {},
}
}
@@ -42,3 +47,50 @@ test('recovery codes render only until explicitly acknowledged', () => {
match(html, /Я сохранил коды/)
equal(renderToStaticMarkup(<RecoveryCodesModal codes={[]} onAcknowledge={() => {}} />), '')
})
test('onboarding is visible only for a signed-in unlinked account and needs an explicit nickname', () => {
const onOnboard = async () => {}
const unlinked = { username: 'website_login', links: [] }
const html = renderToStaticMarkup(<AccountSettings session={{ ...session(), account: unlinked }} locked={false} onOnboard={onOnboard} />)
match(html, /Установить и войти для подтверждения/)
match(html, /<button[^>]*type="button"[^>]*disabled=""[^>]*><span>Установить и войти для подтверждения/)
match(html, /minLength="3" maxLength="16" pattern="\[A-Za-z0-9_\]\{3,16\}" required=""/)
equal(launchAccess(unlinked), 'link')
const signedOut = renderToStaticMarkup(<AccountSettings session={session()} locked={false} onOnboard={onOnboard} />)
doesNotMatch(signedOut, /Установить и войти для подтверждения/)
const linked = { username: 'website_login', links: [{ server_id: 'aoc', mc_username: 'Bound_Name' }] }
const linkedHtml = renderToStaticMarkup(<AccountSettings session={{ ...session(), account: linked, linkedNickname: 'Bound_Name' }} locked={false} onOnboard={onOnboard} />)
doesNotMatch(linkedHtml, /Установить и войти для подтверждения/)
equal(launchAccess(linked), 'ready')
})
test('a verified pack does not make an unlinked account ready for normal play', () => {
const access = launchAccess({ username: 'website_login', links: [] })
const html = renderToStaticMarkup(<PlayDock server={servers[0]} operation={{ phase: 'idle' }}
profile={{ status: 'checked', error: null, inspection: { root: '/test/profile', managedFiles: 12, missingFiles: 0, mismatchedFiles: 0, upToDate: true, legacyFiles: 2 } }}
memoryGb={6} native needsLogin={access === 'login'} needsLink={access === 'link'} error={null}
label="Привязать ник" primaryDisabled={false} repairDisabled={false} onPrimary={() => {}} onRepair={() => {}} onLegacy={() => {}} />)
match(html, /Нужно привязать ник/)
doesNotMatch(html, /Сборка готова|файлов под контролем/)
match(html, /Проверено файлов сборки: 12/)
})
test('incomplete inspection does not claim that all pack files were verified', () => {
const html = renderToStaticMarkup(<PlayDock server={servers[0]} operation={{ phase: 'idle' }}
profile={{ status: 'checked', error: null, inspection: { root: '/test/profile', managedFiles: 12, missingFiles: 1, mismatchedFiles: 2, upToDate: false } }}
memoryGb={6} native needsLogin={false} needsLink={false} error={null}
label="Проверить" primaryDisabled={false} repairDisabled={false} onPrimary={() => {}} onRepair={() => {}} onLegacy={() => {}} />)
match(html, /Требуется проверка/)
match(html, /Файлов в сборке: 12/)
doesNotMatch(html, /Проверено файлов сборки|файлов под контролем/)
})
test('legacy review opens with no selected files and remains closable while its list is loading', () => {
const html = renderToStaticMarkup(<LegacyModsDialog profileId="aeronautics" onClose={() => {}} onChanged={() => {}} />)
match(html, /role="dialog" aria-modal="true" aria-labelledby="legacy-title" aria-describedby="legacy-description"/)
match(html, /По умолчанию ничего не выбрано/)
match(html, /<button disabled="">Перенести выбранные \(0\) в резервную копию<\/button>/)
match(html, /<button>Закрыть<\/button>/)
doesNotMatch(html, /checked=""/)
})
+86
View File
@@ -0,0 +1,86 @@
import { doesNotMatch, match } from 'node:assert/strict'
import { test } from 'node:test'
import { renderToStaticMarkup } from 'react-dom/server'
import { LauncherUpdate } from './LauncherUpdate'
import { initialUpdaterState } from '../state/updater'
import type { UpdaterState } from '../state/updater'
import type { UpdaterStatus } from '../types/updater'
function status(phase: UpdaterStatus['phase'], extra: Partial<UpdaterStatus> = {}): UpdaterStatus {
return { revision: 1, installedVersion: '0.2.0', testBuild: false, packageFormat: 'development', phase, availableVersion: null, releaseNotes: null,
downloadedBytes: 0, totalBytes: null, canRetry: false, message: null, ...extra }
}
function render(value: UpdaterStatus, extra: Partial<UpdaterState> = {}, native = true) {
return renderToStaticMarkup(<LauncherUpdate state={{ ...initialUpdaterState, status: value, ...extra }} native={native}
onCheck={() => {}} onInstall={() => {}} onRestart={() => {}} onOpenRelease={() => {}} />)
}
test('available updater notes are plain text and installation explicitly includes restart', () => {
const html = render(status('available', { availableVersion: '0.3.0', releaseNotes: '<img src=x onerror="alert(1)">\n[Link](https://example.invalid)' }))
match(html, /Версия 0\.2\.0/)
match(html, /Новая версия: <strong>0\.3\.0/)
match(html, /Установить и перезапустить/)
match(html, /Установка закроет и перезапустит лаунчер/)
match(html, /&lt;img src=x/)
doesNotMatch(html, /<img|<a\s|dangerouslySetInnerHTML|authenticode|notarization/i)
})
test('an unconfigured build still reports its installed native version', () => {
const html = render(status('unconfigured'))
match(html, /Версия 0\.2\.0/)
match(html, /Автообновление пока не настроено/)
doesNotMatch(html, /Установить и перезапустить/)
})
test('test updater builds have a visible native-provided notice', () => {
match(render(status('available', { testBuild: true })), /Тестовая сборка · тестовый канал обновлений/)
doesNotMatch(render(status('available')), /Тестовая сборка/)
})
test('manual Linux packages offer release instructions without a native install button or frontend link', () => {
const html = render(status('manual', { availableVersion: '0.3.0', packageFormat: 'deb' }))
match(html, /Версия 0\.2\.0 · deb/)
match(html, /системный менеджер пакетов/)
match(html, /Открыть страницу выпусков/)
doesNotMatch(html, /Установить и перезапустить|href=/)
})
test('pending game or settings work disables the explicit update action and explains why', () => {
const html = render(status('available'), { blockedReason: 'Игра запущена' })
match(html, /Игра запущена/)
match(html, /<button[^>]*class="launcher-update-primary"[^>]*disabled=""/)
})
test('unknown download size does not announce a false percentage', () => {
const html = render(status('downloading', { downloadedBytes: 1048576 }))
match(html, /Скачано: 1\.0 МБ/)
match(html, /role="progressbar"/)
doesNotMatch(html, /aria-valuenow|Скачано: 0%/)
match(render(status('downloading', { downloadedBytes: 50, totalBytes: 100 })), /aria-valuenow="50"/)
})
test('verification, installation and restart fallback remain distinct', () => {
match(render(status('verifying')), /Проверяем подпись обновления/)
match(render(status('installing')), /Устанавливаем обновление/)
const ready = render(status('ready'), { error: 'Не удалось перезапустить' })
match(ready, /Перезапустить лаунчер/)
match(ready, /role="alert">Не удалось перезапустить/)
doesNotMatch(ready, /Установить и перезапустить|Проверить обновления/)
})
test('check failures allow a clear retry, and an up-to-date result needs no install action', () => {
match(render(status('error', { canRetry: true, message: 'Сеть недоступна' })), /Повторить проверку/)
const current = render(status('no_update'))
match(current, /Установлена актуальная версия/)
doesNotMatch(current, /Установить и перезапустить/)
const preview = render(status('available'), {}, false)
match(preview, /Обновления доступны в приложении лаунчера/)
match(preview, /class="launcher-update-primary" disabled=""/)
})
test('an indeterminate install failure offers manual recovery rather than retrying installation', () => {
const html = render(status('error', { canRetry: false, message: 'Не удалось определить результат установки.' }))
match(html, /Восстановите лаунчер из пакета/)
match(html, /Открыть страницу выпусков/)
doesNotMatch(html, /Повторить проверку|Установить и перезапустить|Перезапустить лаунчер/)
})
+2 -2
View File
@@ -8,8 +8,8 @@ export const servers: readonly [Server, ...Server[]] = [
kicker: 'Основная сборка',
name: 'Aeronautics',
subtitle: 'Строй корабли. Поднимай города в небо.',
version: '1.21.1 · NeoForge 21.1.248',
loader: 'NeoForge 21.1.248',
version: 'Версия уточняется',
loader: 'По подписанной сборке',
profileId: 'aeronautics',
},
]
+11 -7
View File
@@ -3,7 +3,7 @@ import { createRequestScope, errorMessage } from '../services/async'
import { isNative, native } from '../services/native'
import { linkedNickname, validCredentials } from '../state/account'
import { isValidNickname } from '../state/settings'
import type { ShaCraftAccount } from '../types/launcher'
import type { ShaCraftAccount, LinkChallenge } from '../types/launcher'
interface PendingLink {
challengeId: number
@@ -125,6 +125,14 @@ export function useAccount() {
}
}
const acceptChallenge = (started: LinkChallenge) => {
requests.current.invalidate()
const current = requests.current.capture()
setLinkMessage(`${started.registered_on_server ? 'Войдите на Aeronautics: /login <пароль>.' : 'Войдите на Aeronautics: /register <пароль> <пароль>.'} Затем подтвердите свой аккаунт командой /shacraft link ${started.challenge_id} ${started.proof_code}`)
setChallenge({ challengeId: started.challenge_id,
expiresAt: Date.now() + started.expires_in_seconds * 1000, isCurrent: current })
}
const startLink = async (nickname: string) => {
if (!isNative() || pending.current || challenge || !account) return
if (!isValidNickname(nickname)) {
@@ -139,11 +147,7 @@ export function useAccount() {
try {
const started = await native.startLink(nickname)
if (!currentRequest()) return
setLinkMessage(started.registered_on_server
? 'Зайдите на Aeronautics с этим ником и выполните /login.'
: 'Зайдите на Aeronautics с этим ником и выполните /register.')
setChallenge({ challengeId: started.challenge_id,
expiresAt: Date.now() + started.expires_in_seconds * 1000, isCurrent: currentRequest })
acceptChallenge(started)
} catch (reason) {
setLinkMessage(errorMessage(reason, 'Не удалось начать привязку'))
} finally {
@@ -154,7 +158,7 @@ export function useAccount() {
return {
account, error, busy, recoveryCodes, linkMessage, linking: challenge !== null,
linkedNickname: linkedNickname(account), authenticate, logout, startLink,
linkedNickname: linkedNickname(account), authenticate, logout, startLink, acceptChallenge,
clearError: () => setError(null),
acknowledgeRecoveryCodes: () => setRecoveryCodes([]),
}
+28 -42
View File
@@ -4,7 +4,7 @@ import { errorMessage } from '../services/async'
import { isNative, native, watchGame } from '../services/native'
import { gameReducer, initialGameState } from '../state/game'
import { profilesReducer } from '../state/profiles'
import type { JavaInstallation, NativeHost } from '../types/launcher'
import type { JavaInstallation, NativeHost, ProfileMetadata, PreparationResult } from '../types/launcher'
export function useLauncher() {
const [host, setHost] = useState<NativeHost | null>(null)
@@ -14,6 +14,7 @@ export function useLauncher() {
const [game, dispatch] = useReducer(gameReducer, initialGameState)
const [eventsReady, setEventsReady] = useState(false)
const busy = useRef(false)
const [metadata, setMetadata] = useState<Record<string, ProfileMetadata>>({})
useEffect(() => {
if (!isNative()) return
@@ -41,6 +42,9 @@ export function useLauncher() {
})
for (const server of servers) {
const profileId = server.profileId
void native.metadata(profileId).then((value) => {
if (active) setMetadata((old) => ({ ...old, [profileId]: value }))
}).catch(() => { /* Missing verified metadata is shown as unknown. */ })
updateProfile({ type: 'check', profileId })
void native.inspectProfile(profileId).then((inspection) => {
if (active) updateProfile({ type: 'checked', profileId, inspection })
@@ -51,56 +55,38 @@ export function useLauncher() {
return () => { active = false; subscription.dispose() }
}, [])
const repair = async (profileId: string) => {
if (!isNative() || busy.current || game.operation.phase !== 'idle' || profiles[profileId]?.status === 'checking') return
const prepare = async (profileId: string, mode: 'repair' | 'play' | 'onboarding', nickname?: string): Promise<PreparationResult | null> => {
if (!isNative() || !eventsReady || busy.current || game.operation.phase !== 'idle') return null
busy.current = true
dispatch({ type: 'sync', profileId })
// A repair may replace only some files before failing. Never keep an older
// up-to-date inspection as permission to launch that partial installation.
dispatch({ type: 'install', profileId })
updateProfile({ type: 'check', profileId })
try {
const result = await native.syncProfile(profileId)
updateProfile({ type: 'checked', profileId,
inspection: { root: result.root, managedFiles: result.downloadedFiles + result.reusedFiles,
missingFiles: 0, mismatchedFiles: 0, upToDate: true },
})
dispatch({ type: 'synced', profileId })
// The native command owns one snapshot and one lock across every stage.
const result = mode === 'repair' ? await native.installGame(profileId)
: mode === 'onboarding' ? await native.launchOnboarding(profileId, nickname ?? '')
: await native.launchGame(profileId)
updateProfile({ type: 'checked', profileId, inspection: result.inspection })
setMetadata((old) => ({ ...old, [profileId]: result.metadata }))
if (mode === 'repair') dispatch({ type: 'repaired', profileId })
else dispatch({ type: 'started', profileId })
return result
} catch (reason) {
const error = errorMessage(reason, 'Не удалось синхронизировать сборку')
const error = errorMessage(reason, mode === 'repair' ? 'Не удалось восстановить игру' : 'Не удалось запустить игру')
updateProfile({ type: 'failed', profileId, error })
dispatch({ type: 'failed', error })
} finally {
busy.current = false
}
return null
} finally { busy.current = false }
}
const launch = async (profileId: string) => {
if (!isNative() || !eventsReady || busy.current || game.operation.phase !== 'idle') return
busy.current = true
dispatch({ type: 'sync', profileId })
const refreshProfile = async (profileId: string) => {
updateProfile({ type: 'check', profileId })
try {
// Reconcile the current signed modpack before every Play, even when a
// previous inspection succeeded. Game installation alone omits mods.
const synced = await native.syncProfile(profileId)
updateProfile({ type: 'checked', profileId, inspection: {
root: synced.root, managedFiles: synced.downloadedFiles + synced.reusedFiles,
missingFiles: 0, mismatchedFiles: 0, upToDate: true,
} })
dispatch({ type: 'synced', profileId })
dispatch({ type: 'install', profileId })
await native.installGame(profileId)
dispatch({ type: 'launch', profileId })
await native.launchGame(profileId)
dispatch({ type: 'started', profileId })
} catch (reason) {
const error = errorMessage(reason, 'Не удалось запустить игру')
updateProfile({ type: 'failed', profileId, error })
dispatch({ type: 'failed', error })
} finally {
busy.current = false
}
try { updateProfile({ type: 'checked', profileId, inspection: await native.inspectProfile(profileId) }) }
catch (reason) { updateProfile({ type: 'failed', profileId, error: errorMessage(reason, 'Не удалось проверить сборку') }) }
}
return { host, java, environmentError, profiles, game, eventsReady, repair, launch }
return { host, java, environmentError, profiles, metadata, game, eventsReady, refreshProfile,
repair: (profileId: string) => prepare(profileId, 'repair'),
launch: (profileId: string) => prepare(profileId, 'play'),
onboard: (profileId: string, nickname: string) => prepare(profileId, 'onboarding', nickname),
}
}
+12 -7
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { createSerialQueue, errorMessage } from '../services/async'
import { createSaveIntent, createSerialQueue, errorMessage } from '../services/async'
import { isNative, native } from '../services/native'
import { defaultSettings } from '../state/settings'
import type { LauncherSettings } from '../types/launcher'
@@ -12,7 +12,7 @@ export function useSettings() {
const [loadAttempt, setLoadAttempt] = useState(0)
const current = useRef(settings)
const durable = useRef(settings)
const revision = useRef(0)
const intent = useRef(createSaveIntent<LauncherSettings>())
const queue = useRef(createSerialQueue())
useEffect(() => {
@@ -38,21 +38,23 @@ export function useSettings() {
setSettings(next)
setError(null)
if (!isNative()) return
const requestRevision = ++revision.current
const request = intent.current.begin(next)
setSaving(true)
void queue.current.enqueue(() => native.saveSettings(next)).then((value) => {
durable.current = value
if (revision.current === requestRevision) {
intent.current.succeeded(request)
if (intent.current.isLatest(request)) {
current.current = value
setSettings(value)
}
}).catch((reason: unknown) => {
if (revision.current !== requestRevision) return
if (!intent.current.isLatest(request)) return
intent.current.failed(request)
current.current = durable.current
setSettings(durable.current)
setError(errorMessage(reason, 'Не удалось сохранить настройки'))
}).finally(() => {
if (revision.current === requestRevision) setSaving(false)
if (intent.current.isLatest(request)) setSaving(false)
})
}
@@ -61,7 +63,10 @@ export function useSettings() {
updateRam: (memoryGb: number) => save({ memoryMb: memoryGb * 1024 }),
retry: () => {
if (!loaded) setLoadAttempt((attempt) => attempt + 1)
else save({})
else {
const failed = intent.current.retryValue()
if (failed) save(failed)
}
},
}
}
+26
View File
@@ -0,0 +1,26 @@
import { useEffect, useRef, useSyncExternalStore } from 'react'
import { isNative, native, watchUpdater } from '../services/native'
import { createUpdaterController } from '../services/updater'
import { initialUpdaterState, updaterMutating } from '../state/updater'
export function useUpdater(blockedReason: string | null) {
const controller = useRef(createUpdaterController({
status: native.updaterStatus,
check: native.checkUpdater,
install: native.installUpdater,
restart: native.restartUpdater,
open: native.openUpdaterRelease,
watch: watchUpdater,
})).current
const state = useSyncExternalStore(controller.subscribe, controller.snapshot, () => initialUpdaterState)
useEffect(() => { controller.setBlockedReason(blockedReason) }, [controller, blockedReason])
useEffect(() => {
if (isNative()) return controller.connect()
}, [controller])
return { state, mutating: updaterMutating(state),
check: () => { void controller.check() },
install: () => { void controller.install() },
restart: () => { void controller.restart() },
openRelease: () => { void controller.open() },
}
}
+48 -1
View File
@@ -1,6 +1,6 @@
import { deepStrictEqual, equal, rejects } from 'node:assert/strict'
import { test } from 'node:test'
import { createRequestScope, createSerialQueue, createSubscription, errorMessage, singleFlight } from './async.ts'
import { createSaveIntent, createRequestScope, createSerialQueue, createSubscription, errorMessage, singleFlight } from './async.ts'
function deferred<T>() {
let resolve!: (value: T) => void
@@ -111,3 +111,50 @@ test('logout or a newer challenge invalidates a delayed account/link response',
equal(belongsToNewChallenge(), true)
equal(belongsToAccount(), false)
})
test('RAM retry retains the failed 8 GB choice after rollback to durable 6 GB', async () => {
const intent = createSaveIntent<{ memoryMb: number }>()
const queue = createSerialQueue()
let current = { memoryMb: 6 * 1024 }
let durable = current
const writes: number[] = []
const save = async (next: typeof current, fail: boolean) => {
current = next
const request = intent.begin(next)
try {
const value = await queue.enqueue(async () => {
writes.push(next.memoryMb)
if (fail) throw new Error('disk full')
return next
})
durable = value
intent.succeeded(request)
} catch {
intent.failed(request)
current = durable
}
}
await save({ memoryMb: 8 * 1024 }, true)
equal(current.memoryMb, 6 * 1024)
await save(intent.retryValue()!, false)
deepStrictEqual(writes, [8 * 1024, 8 * 1024])
equal(durable.memoryMb, 8 * 1024)
equal(intent.retryValue(), null)
})
test('older settings failure cannot replace a newer choice or remain retryable after success', () => {
const intent = createSaveIntent<number>()
const older = intent.begin(8)
const newer = intent.begin(10)
intent.failed(older)
equal(intent.retryValue(), null)
intent.failed(newer)
equal(intent.retryValue(), 10)
const latest = intent.begin(12)
intent.succeeded(older)
intent.failed(latest)
equal(intent.retryValue(), 12)
intent.succeeded(latest)
equal(intent.retryValue(), null)
})
+13
View File
@@ -44,6 +44,19 @@ export function createSerialQueue() {
}
}
/** Retain the latest failed user choice even when UI rolls back to disk state. */
export function createSaveIntent<T>() {
let revision = 0
let failed: T | null = null
return {
begin: (value: T) => { failed = null; return { revision: ++revision, value } },
isLatest: (request: { revision: number }) => request.revision === revision,
succeeded: (request: { revision: number }) => { if (request.revision === revision) failed = null },
failed: (request: { revision: number; value: T }) => { if (request.revision === revision) failed = request.value },
retryValue: () => failed,
}
}
/** Handles unmount before asynchronous native listener registration finishes. */
export function createSubscription(
registrations: readonly Promise<() => void>[],
+19 -3
View File
@@ -2,10 +2,11 @@ import { invoke, isTauri } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { createSerialQueue, createSubscription, singleFlight } from './async'
import type { UpdaterStatus } from '../types/updater'
import type {
GameExitedPayload, InstallProgressPayload, JavaInstallation, LauncherSettings,
LinkChallenge, LinkStatus, NativeHost, ProfileInspection, ServerStatus,
ShaCraftAccount, ShaCraftLoginResult, SyncResult,
ShaCraftAccount, ShaCraftLoginResult, SyncResult, ProfileMetadata, PreparationResult, LegacyMod, LegacySelection, LegacyBackup,
} from '../types/launcher'
export const isNative = () => typeof window !== 'undefined' && isTauri()
@@ -15,7 +16,15 @@ const restoreAccount = singleFlight(() => accountRequests.enqueue(() => invoke<S
// Keep the IPC contract in one place. UI components never invoke native
// commands directly and cannot pass arbitrary URLs or filesystem paths.
export const native = {
updaterStatus: () => invoke<UpdaterStatus>('updater_status'),
checkUpdater: () => invoke<UpdaterStatus>('updater_check'),
installUpdater: () => invoke<UpdaterStatus>('updater_download_install'),
restartUpdater: () => invoke<void>('updater_restart'),
openUpdaterRelease: () => invoke<void>('updater_open_release_page'),
host: () => invoke<NativeHost>('native_host'),
metadata: (profileId: string) => invoke<ProfileMetadata>('profile_metadata', { profileId }),
legacyMods: (profileId: string) => invoke<LegacyMod[]>('legacy_mods', { profileId }),
backupLegacyMods: (profileId: string, selections: LegacySelection[]) => invoke<LegacyBackup>('backup_legacy_mods', { profileId, selections }),
loadSettings: () => invoke<LauncherSettings>('load_settings'),
saveSettings: (settings: LauncherSettings) => invoke<LauncherSettings>('save_settings', { settings }),
detectJava: () => invoke<JavaInstallation | null>('detect_java'),
@@ -28,8 +37,15 @@ export const native = {
startLink: (nickname: string) => accountRequests.enqueue(() => invoke<LinkChallenge>('shacraft_start_link', { nickname })),
linkStatus: (challengeId: number) => accountRequests.enqueue(() => invoke<LinkStatus>('shacraft_link_status', { challengeId })),
serverStatus: (profileId: string) => invoke<ServerStatus>('get_server_status', { profileId }),
installGame: (profileId: string) => invoke<void>('ensure_game_installed', { profileId }),
launchGame: (profileId: string) => invoke<void>('launch_game', { profileId }),
installGame: (profileId: string) => invoke<PreparationResult>('ensure_game_installed', { profileId }),
launchGame: (profileId: string) => invoke<PreparationResult>('launch_game', { profileId }),
launchOnboarding: (profileId: string, nickname: string) => invoke<PreparationResult>('launch_onboarding', { profileId, nickname }),
}
export function watchUpdater(receive: (status: UpdaterStatus) => void) {
return createSubscription([
listen<UpdaterStatus>('launcher-update-status', ({ payload }) => receive(payload)),
])
}
export const windowControls = {
+112
View File
@@ -0,0 +1,112 @@
import { errorMessage } from './async'
import { canRunUpdater, initialUpdaterState, updaterReducer } from '../state/updater'
import type { UpdaterAction, UpdaterCommand, UpdaterState } from '../state/updater'
import type { UpdaterStatus } from '../types/updater'
export interface UpdaterApi {
status: () => Promise<UpdaterStatus>
check: () => Promise<UpdaterStatus>
install: () => Promise<UpdaterStatus>
restart: () => Promise<void>
open: () => Promise<void>
watch: (receive: (status: UpdaterStatus) => void) => { ready: Promise<void>; dispose: () => void }
}
/** One window lifecycle, including StrictMode reconnects; never installs on its own. */
export function createUpdaterController(api: UpdaterApi) {
let state = initialUpdaterState
const subscribers = new Set<() => void>()
let request = 0
let generation = 0
let connected = false
let initialized = false
let automaticCheckStarted = false
let subscription: ReturnType<UpdaterApi['watch']> | null = null
const dispatch = (action: UpdaterAction) => {
const next = updaterReducer(state, action)
if (next === state) return
state = next
subscribers.forEach((notify) => notify())
}
const ensureEvents = () => {
if (!subscription) {
const current = generation
const created = api.watch((status) => {
if (connected && generation === current) dispatch({ type: 'status', status })
})
subscription = created
void created.ready.catch(() => {
if (subscription === created) { created.dispose(); subscription = null }
})
}
return subscription.ready
}
const run = async (command: Exclude<UpdaterCommand, 'status'>): Promise<boolean> => {
if (!connected || !canRunUpdater(state, command)) return false
const current = ++request
const currentGeneration = generation
dispatch({ type: 'begin', command, request: current })
try {
if (command !== 'open') await ensureEvents()
// Settings/account work may have started while the listener registered.
if (!connected || generation !== currentGeneration || (command !== 'open' && state.blockedReason)) return false
const status = await api[command]()
if (status) dispatch({ type: 'status', status })
return true
} catch (reason) {
dispatch({ type: 'failed', request: current, error: errorMessage(reason, 'Не удалось выполнить обновление лаунчера. Повторите попытку.') })
return false
} finally {
dispatch({ type: 'settled', request: current })
}
}
const automaticCheck = () => {
if (!connected || !initialized || automaticCheckStarted || !canRunUpdater(state, 'check')) return
automaticCheckStarted = true
void run('check')
}
const connect = () => {
connected = true
const current = ++generation
const read = ++request
// A user-started native operation can outlive a UI reconnect.
const ownsPending = state.pending === null
if (ownsPending) dispatch({ type: 'begin', command: 'status', request: read })
void ensureEvents().then(() => connected && generation === current ? api.status() : null).then((status) => {
if (!connected || generation !== current) return
if (status) dispatch({ type: 'status', status })
initialized = true
}).catch((reason) => {
if (connected && generation === current && ownsPending) dispatch({ type: 'failed', request: read,
error: errorMessage(reason, 'Не удалось прочитать состояние обновления лаунчера.') })
}).finally(() => {
if (ownsPending) dispatch({ type: 'settled', request: read })
if (connected && generation === current) automaticCheck()
})
return () => {
if (generation !== current) return
connected = false
initialized = false
generation++
subscription?.dispose()
subscription = null
if (ownsPending) dispatch({ type: 'settled', request: read })
}
}
return {
snapshot: (): UpdaterState => state,
subscribe: (notify: () => void) => { subscribers.add(notify); return () => { subscribers.delete(notify) } },
connect,
setBlockedReason: (reason: string | null) => { dispatch({ type: 'blocked', reason }); automaticCheck() },
check: () => { automaticCheckStarted = true; return run('check') },
install: () => run('install'),
restart: () => run('restart'),
open: () => run('open'),
}
}
+37
View File
@@ -32,6 +32,43 @@ test('a fast child exit cannot be overwritten by a late launch acknowledgement',
match(lateAcknowledgement.error ?? '', /кодом 1/)
})
test('a unified native launch can start directly from preparation, while repair only returns to idle', () => {
equal(gameReducer(installing, { type: 'started', profileId }).operation.phase, 'running')
deepStrictEqual(gameReducer(installing, { type: 'repaired', profileId }), initialGameState)
const running = gameReducer(installing, { type: 'started', profileId })
equal(gameReducer(running, { type: 'repaired', profileId }), running)
equal(gameReducer(launching, { type: 'repaired', profileId }), launching)
})
test('a child exit received during preparation survives late progress and the native launch result', () => {
for (const exitCode of [0, 1, null]) {
const exited = gameReducer(installing, { type: 'exited', result: { profileId, exitCode } })
equal(exited.operation.phase, 'idle')
if (exitCode === 0) equal(exited.error, null)
else match(exited.error ?? '', exitCode === null ? /без кода выхода/ : /кодом 1/)
const lateProgress = gameReducer(exited, { type: 'progress', progress: { stage: 'launch', currentBytes: 0, totalBytes: 0 } })
equal(lateProgress, exited)
equal(gameReducer(lateProgress, { type: 'started', profileId }), exited)
equal(gameReducer(exited, { type: 'repaired', profileId }), exited)
}
})
test('completion and launch events for another profile cannot advance or release preparation', () => {
equal(gameReducer(installing, { type: 'repaired', profileId: 'other' }), installing)
equal(gameReducer(installing, { type: 'launch', profileId: 'other' }), installing)
equal(gameReducer(installing, { type: 'started', profileId: 'other' }), installing)
equal(gameReducer(installing, { type: 'exited', result: { profileId: 'other', exitCode: 0 } }), installing)
equal(gameReducer(launching, { type: 'started', profileId: 'other' }), launching)
})
test('the native launch stage locks the launching state until a matching child event', () => {
const nativeLaunching = gameReducer(installing, { type: 'progress', progress: { stage: 'launch', currentBytes: 0, totalBytes: 0 } })
deepStrictEqual(nativeLaunching, launching)
equal(gameReducer(nativeLaunching, { type: 'repaired', profileId }), nativeLaunching)
equal(gameReducer(nativeLaunching, { type: 'install', profileId }), nativeLaunching)
equal(gameReducer(nativeLaunching, { type: 'started', profileId }).operation.phase, 'running')
})
test('foreign exit events and late install progress cannot unlock a running game', () => {
const running = gameReducer(launching, { type: 'started', profileId })
equal(gameReducer(running, { type: 'exited', result: { profileId: 'other', exitCode: 0 } }), running)
+8 -2
View File
@@ -13,6 +13,7 @@ export interface GameState {
export type GameAction =
| { type: 'sync'; profileId: string }
| { type: 'synced'; profileId: string }
| { type: 'repaired'; profileId: string }
| { type: 'install'; profileId: string }
| { type: 'progress'; progress: InstallProgressPayload }
| { type: 'launch'; profileId: string }
@@ -37,7 +38,10 @@ export function gameReducer(state: GameState, action: GameAction): GameState {
case 'synced':
return operation.phase === 'syncing' && operation.profileId === action.profileId
? initialGameState : state
case 'repaired':
return operation.phase === 'installing' && operation.profileId === action.profileId ? initialGameState : state
case 'progress':
if (operation.phase === 'installing' && action.progress.stage === 'launch') return { ...state, operation: { phase: 'launching', profileId: operation.profileId } }
return operation.phase === 'installing'
? { ...state, operation: { ...operation, progress: action.progress } } : state
case 'launch':
@@ -45,10 +49,10 @@ export function gameReducer(state: GameState, action: GameAction): GameState {
? { ...state, operation: { phase: 'launching', profileId: action.profileId } } : state
case 'started':
// A fast-exiting child can emit game-exited before invoke resolves.
return operation.phase === 'launching' && operation.profileId === action.profileId
return (operation.phase === 'launching' || operation.phase === 'installing') && operation.profileId === action.profileId
? { ...state, operation: { phase: 'running', profileId: action.profileId } } : state
case 'exited':
if ((operation.phase !== 'launching' && operation.phase !== 'running') ||
if ((operation.phase !== 'launching' && operation.phase !== 'running' && operation.phase !== 'installing') ||
operation.profileId !== action.result.profileId) return state
return {
operation: { phase: 'idle' },
@@ -62,6 +66,8 @@ export function gameReducer(state: GameState, action: GameAction): GameState {
}
export const installStageLabels: Record<InstallProgressPayload['stage'], string> = {
mods: 'Обновляем сборку',
launch: 'Запускаем игру',
java: 'Готовим Java',
neoforge: 'Устанавливаем NeoForge',
libraries: 'Скачиваем библиотеки',
+222
View File
@@ -0,0 +1,222 @@
import { deepStrictEqual, equal, match } from 'node:assert/strict'
import { test } from 'node:test'
import { canRunUpdater, initialUpdaterState, updaterMutating, updaterPercent, updaterReducer } from './updater'
import { createUpdaterController } from '../services/updater'
import type { UpdaterApi } from '../services/updater'
import type { UpdaterStatus } from '../types/updater'
function status(phase: UpdaterStatus['phase'], revision = 0): UpdaterStatus {
return { revision, installedVersion: '0.2.0', testBuild: false, packageFormat: 'development', phase, availableVersion: null, releaseNotes: null,
downloadedBytes: 0, totalBytes: null, canRetry: false, message: null }
}
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (error: unknown) => void
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no })
return { promise, resolve, reject }
}
const flush = () => new Promise<void>((resolve) => setImmediate(resolve))
function fixture(overrides: Partial<UpdaterApi> = {}) {
const calls = { status: 0, check: 0, install: 0, restart: 0, open: 0, disposed: 0 }
const receivers: Array<(value: UpdaterStatus) => void> = []
const api: UpdaterApi = {
status: async () => { calls.status++; return status('idle') },
check: async () => { calls.check++; return status('available', calls.check) },
install: async () => { calls.install++; return status('ready', 50) },
restart: async () => { calls.restart++ },
open: async () => { calls.open++ },
watch: (receive) => { receivers.push(receive); return { ready: Promise.resolve(), dispose: () => { calls.disposed++ } } },
...overrides,
}
return { calls, receivers, api, controller: createUpdaterController(api) }
}
test('newer native progress wins over an older invoke result; stale failures cannot finish another request', () => {
const started = updaterReducer(initialUpdaterState, { type: 'begin', command: 'install', request: 1 })
const progress = updaterReducer(started, { type: 'status', status: status('verifying', 4) })
equal(updaterReducer(progress, { type: 'status', status: status('downloading', 3) }), progress)
equal(updaterReducer(progress, { type: 'status', status: status('available', 4) }), progress)
const failed = updaterReducer(progress, { type: 'failed', request: 1, error: 'Network interrupted' })
const retry = updaterReducer(failed, { type: 'begin', command: 'check', request: 2 })
equal(updaterReducer(retry, { type: 'failed', request: 1, error: 'Old error' }), retry)
equal(updaterReducer(retry, { type: 'settled', request: 1 }), retry)
equal(retry.error, null)
})
test('one startup check survives a StrictMode reconnect and never downloads or restarts', async () => {
const { controller, calls } = fixture()
const disconnect = controller.connect()
disconnect() // StrictMode cleans up before listener registration has resolved.
const disconnectRemount = controller.connect()
await flush()
equal(calls.check, 1)
equal(calls.status, 1)
equal(controller.snapshot().status?.phase, 'available')
disconnectRemount()
const disconnectAgain = controller.connect()
await flush()
equal(calls.check, 1)
equal(calls.install, 0)
equal(calls.restart, 0)
disconnectAgain()
})
test('new pending account work or UI disposal before native handoff cancels an install request', async () => {
const { controller, calls } = fixture()
const disconnect = controller.connect()
await flush()
const blockedBeforeHandoff = controller.install()
controller.setBlockedReason('Аккаунт сохраняется')
equal(await blockedBeforeHandoff, false)
equal(calls.install, 0)
equal(controller.snapshot().pending, null)
controller.setBlockedReason(null)
const cancelledBeforeHandoff = controller.install()
disconnect()
equal(await cancelledBeforeHandoff, false)
equal(calls.install, 0)
})
test('disconnect before event registration resolves cancels initialization and rejects old listener events', async () => {
const ready = deferred<void>()
let receive!: (value: UpdaterStatus) => void
let disposed = 0
const { controller, calls } = fixture({ watch: (callback) => {
receive = callback
return { ready: ready.promise, dispose: () => { disposed++ } }
} })
const disconnect = controller.connect()
disconnect()
ready.resolve()
receive(status('available', 99))
await flush()
equal(disposed, 1)
equal(calls.status, 0)
equal(calls.check, 0)
equal(controller.snapshot().status, null)
equal(controller.snapshot().pending, null)
})
test('an earlier status read cannot hide an in-flight native installation', async () => {
const read = deferred<UpdaterStatus>()
const { controller, receivers, calls } = fixture({ status: () => read.promise })
const disconnect = controller.connect()
await flush()
receivers[0]?.(status('installing', 6))
read.resolve(status('idle', 0))
await flush()
equal(controller.snapshot().status?.phase, 'installing')
equal(updaterMutating(controller.snapshot()), true)
equal(calls.check, 0)
equal(await controller.install(), false)
disconnect()
})
test('startup check waits for pending work and explicit installation cannot overlap it or a double click', async () => {
const installation = deferred<UpdaterStatus>()
let installs = 0
const { controller, calls } = fixture({ install: () => { installs++; return installation.promise } })
controller.setBlockedReason('Сохраняем настройки')
const disconnect = controller.connect()
await flush()
equal(calls.check, 0)
controller.setBlockedReason(null)
await flush()
equal(calls.check, 1)
controller.setBlockedReason('Игра запущена')
equal(await controller.install(), false)
equal(installs, 0)
controller.setBlockedReason(null)
const first = controller.install()
equal(await controller.install(), false)
await flush()
equal(installs, 1)
equal(updaterMutating(controller.snapshot()), true)
installation.resolve(status('ready', 7))
equal(await first, true)
equal(calls.restart, 0) // The native install owns its restart; the UI never sends a second one.
disconnect()
})
test('a failed automatic check releases its request; retry is explicit and never installs', async () => {
let attempts = 0
const { controller, calls } = fixture({ check: async () => {
if (++attempts === 1) throw new Error('Network unavailable')
return status('available', 3)
} })
const disconnect = controller.connect()
await flush()
match(controller.snapshot().error ?? '', /Network unavailable/)
equal(controller.snapshot().pending, null)
await flush()
equal(attempts, 1)
equal(await controller.check(), true)
equal(controller.snapshot().error, null)
equal(controller.snapshot().status?.phase, 'available')
equal(calls.install, 0)
disconnect()
})
test('failed event subscription can be retried without leaving a dead pending operation', async () => {
let subscriptions = 0
let disposed = 0
const { controller, calls } = fixture({ watch: () => ({
ready: ++subscriptions === 1 ? Promise.reject(new Error('Events unavailable')) : Promise.resolve(),
dispose: () => { disposed++ },
}) })
const disconnect = controller.connect()
await flush()
match(controller.snapshot().error ?? '', /Events unavailable/)
equal(calls.check, 0)
equal(await controller.check(), true)
equal(subscriptions, 2)
equal(disposed, 1)
equal(calls.check, 1)
disconnect()
})
test('manual packages and a ready restart cannot accidentally invoke installation', () => {
const manual = { ...initialUpdaterState, status: status('manual') }
equal(canRunUpdater(manual, 'install'), false)
equal(canRunUpdater(manual, 'open'), true)
const ready = { ...initialUpdaterState, status: status('ready') }
equal(canRunUpdater(ready, 'check'), false)
equal(canRunUpdater(ready, 'install'), false)
equal(canRunUpdater(ready, 'restart'), true)
equal(canRunUpdater({ ...ready, blockedReason: 'Аккаунт сохраняется' }, 'restart'), false)
})
test('an indeterminate installer failure only permits the fixed manual recovery page', () => {
const recovery = { ...initialUpdaterState, status: status('error'), blockedReason: 'Настройки не сохранены' }
equal(canRunUpdater(recovery, 'check'), false)
equal(canRunUpdater(recovery, 'install'), false)
equal(canRunUpdater(recovery, 'restart'), false)
equal(canRunUpdater(recovery, 'open'), true)
})
test('manual recovery can open the fixed page even when a failed settings save blocks mutations', async () => {
const { controller, calls } = fixture({ status: async () => status('error', 2) })
controller.setBlockedReason('Настройки не сохранены')
const disconnect = controller.connect()
await flush()
equal(await controller.open(), true)
equal(calls.open, 1)
equal(calls.install, 0)
equal(calls.check, 0)
disconnect()
})
test('unknown download totals stay indeterminate and finite totals are bounded', () => {
equal(updaterPercent(null), null)
const downloading = status('downloading')
equal(updaterPercent(downloading), null)
equal(updaterPercent({ ...downloading, downloadedBytes: 10, totalBytes: 0 }), null)
equal(updaterPercent({ ...downloading, downloadedBytes: NaN, totalBytes: 10 }), null)
equal(updaterPercent({ ...downloading, downloadedBytes: 10, totalBytes: Infinity }), null)
equal(updaterPercent({ ...downloading, downloadedBytes: -1, totalBytes: 10 }), null)
deepStrictEqual([updaterPercent({ ...downloading, downloadedBytes: 7, totalBytes: 10 }),
updaterPercent({ ...downloading, downloadedBytes: 11, totalBytes: 10 })], [70, 100])
})
+56
View File
@@ -0,0 +1,56 @@
import type { UpdaterStatus } from '../types/updater'
export type UpdaterCommand = 'status' | 'check' | 'install' | 'restart' | 'open'
export interface UpdaterState {
status: UpdaterStatus | null
pending: { command: UpdaterCommand; request: number } | null
error: string | null
blockedReason: string | null
}
export const initialUpdaterState: UpdaterState = { status: null, pending: null, error: null, blockedReason: null }
export type UpdaterAction =
| { type: 'status'; status: UpdaterStatus }
| { type: 'begin'; command: UpdaterCommand; request: number }
| { type: 'settled'; request: number }
| { type: 'failed'; request: number; error: string }
| { type: 'blocked'; reason: string | null }
export function updaterReducer(state: UpdaterState, action: UpdaterAction): UpdaterState {
switch (action.type) {
case 'status':
// Event delivery and invoke completion may arrive in either order.
if (state.status && action.status.revision <= state.status.revision) return state
return { ...state, status: action.status, error: null }
case 'begin':
return state.pending ? state : { ...state, pending: { command: action.command, request: action.request }, error: null }
case 'settled':
return state.pending?.request === action.request ? { ...state, pending: null } : state
case 'failed':
return state.pending?.request === action.request ? { ...state, pending: null, error: action.error } : state
case 'blocked':
return state.blockedReason === action.reason ? state : { ...state, blockedReason: action.reason }
}
}
export function updaterMutating(state: UpdaterState): boolean {
return state.pending?.command === 'install' || state.pending?.command === 'restart' ||
state.status?.phase === 'downloading' || state.status?.phase === 'verifying' || state.status?.phase === 'installing'
}
export function canRunUpdater(state: UpdaterState, command: Exclude<UpdaterCommand, 'status'>): boolean {
if (state.pending) return false
const phase = state.status?.phase
if (command === 'open') return phase === 'manual' || (phase === 'error' && state.status?.canRetry === false)
if (state.blockedReason || updaterMutating(state)) return false
if (command === 'restart') return phase === 'ready'
if (command === 'install') return phase === 'available'
if (phase === 'error' && state.status?.canRetry === false) return false
return !phase || ['idle', 'available', 'no_update', 'manual', 'unconfigured', 'error'].includes(phase)
}
export function updaterPercent(status: UpdaterStatus | null): number | null {
if (!status || status.totalBytes === null || !Number.isFinite(status.totalBytes) || status.totalBytes <= 0 ||
!Number.isFinite(status.downloadedBytes) || status.downloadedBytes < 0) return null
return Math.min(100, Math.floor(100 * status.downloadedBytes / status.totalBytes))
}
+35 -1
View File
@@ -126,7 +126,7 @@ button:disabled { cursor: default; }
.drawer-backdrop { position: fixed; inset: 48px 0 0; background: rgba(0,0,0,.44); opacity: 0; pointer-events: none; transition: opacity .2s; z-index: 20; }
.drawer-backdrop.visible { opacity: 1; pointer-events: auto; }
.settings-drawer { position: fixed; top: 48px; right: 0; bottom: 0; width: 390px; overflow-y: auto; background: #121713; border-left: 1px solid var(--line); z-index: 21; padding: 28px; transform: translateX(100%); transition: transform .24s cubic-bezier(.2,.8,.2,1); box-shadow: -30px 0 70px rgba(0,0,0,.35); }
.settings-drawer { position: fixed; top: 48px; right: 0; bottom: 0; width: min(390px, 100vw); overflow-y: auto; background: #121713; border-left: 1px solid var(--line); z-index: 21; padding: clamp(18px, 3vw, 28px); transform: translateX(100%); transition: transform .24s cubic-bezier(.2,.8,.2,1); box-shadow: -30px 0 70px rgba(0,0,0,.35); }
.settings-drawer.open { transform: translateX(0); }
.drawer-title { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 34px; }
.drawer-title p { color: #737c74; font-size: 11px; margin: 0 0 5px; }
@@ -176,3 +176,37 @@ button:disabled { cursor: default; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition-duration: .01ms !important; animation-duration: .01ms !important; }
}
.legacy-button { background: transparent; color: var(--text-secondary, #b2bac7); border: 1px solid currentColor; border-radius: 8px; padding: 8px; font: inherit; font-size: 12px; cursor: pointer; }
.legacy-overlay { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; padding: 20px; background: #000a; }
.legacy-dialog { width: min(700px, 100%); max-height: 85vh; overflow: auto; padding: 24px; border-radius: 12px; background: #17201e; color: #e7eee9; }
.legacy-dialog h2 { margin: 0 0 12px; font-size: 21px; }
.legacy-dialog p { line-height: 1.5; overflow-wrap: anywhere; }
.legacy-list label { display: flex; gap: 12px; padding: 12px 0; border-bottom: 1px solid #ffffff20; }
.legacy-list span { min-width: 0; overflow-wrap: anywhere; }
.legacy-list small { display: block; margin-top: 5px; color: #bac5bd; }
.legacy-actions { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 20px; }
.legacy-actions button { font: inherit; padding: 10px 14px; cursor: pointer; }
.account-hint { white-space: pre-wrap; overflow-wrap: anywhere; user-select: text; }
.launcher-update { padding: 0 0 24px; margin-bottom: 24px; border-bottom: 1px solid var(--line); }
.launcher-update-heading { display: flex; flex-wrap: wrap; gap: 8px; justify-content: space-between; align-items: baseline; }
.launcher-update-heading h3 { margin: 0; font-size: 14px; }
.launcher-update-heading > span { color: var(--muted); font-size: 11px; }
.launcher-update p { font-size: 11px; line-height: 1.6; color: #aeb7af; overflow-wrap: anywhere; }
.launcher-update .launcher-update-status { color: var(--green); font-weight: 700; }
.launcher-update .status-error { color: #eea18f; }
.launcher-update .launcher-update-blocked { color: var(--copper); }
.launcher-update-actions { display: grid; gap: 8px; margin-top: 14px; }
.launcher-update-actions button { display: flex; gap: 8px; align-items: center; justify-content: center; border: 1px solid var(--line); border-radius: 5px; background: #222b24; padding: 10px 12px; font-size: 11px; cursor: pointer; }
.launcher-update-actions button.launcher-update-primary { background: var(--green); color: var(--ink); font-weight: 800; }
.launcher-update-actions button:disabled { opacity: .45; cursor: default; }
.launcher-update-notes { margin-top: 12px; font-size: 11px; }
.launcher-update-notes summary { cursor: pointer; color: var(--ice); }
.launcher-update-notes p { white-space: pre-wrap; max-height: 220px; overflow-y: auto; user-select: text; }
.launcher-update-notes p:focus-visible, .launcher-update-notes summary:focus-visible { outline: 2px solid var(--green); outline-offset: 2px; }
.launcher-update-progress { height: 5px; background: #283029; overflow: hidden; border-radius: 3px; }
.launcher-update-progress i { display: block; height: 100%; width: 100%; background: var(--ice); }
.launcher-update-progress:not([aria-valuenow]) i { opacity: .5; }
.launcher-update-notice { position: absolute; top: 70px; right: 26px; display: grid; gap: 3px; text-align: left; padding: 10px 14px; border: 1px solid #34553b; border-radius: 5px; background: #14261b; color: #c6e8c8; font-size: 11px; cursor: pointer; }
.launcher-update-notice span { color: #8daf95; font-size: 10px; }
+24 -1
View File
@@ -34,8 +34,28 @@ export interface ProfileInspection {
missingFiles: number
mismatchedFiles: number
upToDate: boolean
staleFiles?: number
conflicts?: string[]
pendingUpdate?: boolean
legacyFiles?: number
}
export interface ProfileMetadata {
snapshot: string
minecraftVersion: string
loaderKind: string
loaderVersion: string
javaMajor: number
}
export interface PreparationResult {
inspection: ProfileInspection
metadata: ProfileMetadata
onboarding: LinkChallenge | null
}
export interface LegacyMod { path: string; size: number; sha256: string; reason: string }
export interface LegacySelection { path: string; sha256: string }
export interface LegacyBackup { backupRoot: string; files: string[] }
export interface SyncResult {
root: string
downloadedFiles: number
@@ -57,6 +77,9 @@ export interface LinkChallenge {
challenge_id: number
expires_in_seconds: number
registered_on_server: boolean
proof_code: string
mc_username: string
player_uuid: string
}
export interface LinkStatus {
@@ -71,7 +94,7 @@ export interface ServerStatus {
}
export interface InstallProgressPayload {
stage: 'java' | 'neoforge' | 'libraries' | 'assets'
stage: 'mods' | 'java' | 'neoforge' | 'libraries' | 'assets' | 'launch'
currentBytes: number
totalBytes: number
}
+15
View File
@@ -0,0 +1,15 @@
/** Native updater owns endpoints, verification keys, package choice and versions. */
export interface UpdaterStatus {
revision: number
installedVersion: string
testBuild: boolean
packageFormat: 'development' | 'AppImage' | 'deb' | 'rpm' | 'MSI' | 'NSIS' | 'app' | 'unpackaged'
phase: 'idle' | 'checking' | 'available' | 'downloading' | 'verifying' | 'installing' |
'ready' | 'no_update' | 'unconfigured' | 'manual' | 'error'
availableVersion: string | null
releaseNotes: string | null
downloadedBytes: number
totalBytes: number | null
canRetry: boolean
message: string | null
}