Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c73057a0fe | ||
|
|
cacd1cd2bd | ||
|
|
daefc3f5e7 | ||
|
|
738399b452 | ||
|
|
2d11a328c9 | ||
|
|
cbc727829c | ||
|
|
696c6b0e52 | ||
|
|
b2ac174398 | ||
|
|
1d08396b80 | ||
|
|
7c0eb20894 | ||
|
|
189068be63 | ||
|
|
22a78530c0 | ||
|
|
cd5c675637 |
@@ -0,0 +1,2 @@
|
||||
# Signed updater fixtures must retain their exact committed bytes on every OS.
|
||||
src-tauri/tests/fixtures/updater/* -text
|
||||
+49
-39
@@ -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/*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -10,3 +10,5 @@ src-tauri/gen/
|
||||
*.p12
|
||||
*.pfx
|
||||
*.sig
|
||||
|
||||
__pycache__/
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
это исправление не меняет условия покупки. Продакшен не изменён.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
/target/
|
||||
Generated
+23
@@ -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",
|
||||
]
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Generated
+413
-18
@@ -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",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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())?
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)]
|
||||
|
||||
@@ -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-{}-{}",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheUVnVElINVRoVDVpb2VGV21XT0wzYXN1WmxxTEhGZHVvYzg3S1FVcUFPVnVqa3FpRXFWbFl2T2FHN2ZmOU1FRVpQR1krREhCcU00RVpXMXYzd2s5UndnPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzIxCWZpbGU6bGF0ZXN0Lmpzb24KVkxhbGNUZE9NSUpTWlR3L1hDczFTTkFzbjkrK1ZyWE9EeG5xTUVBUnRYcW1kZHphNXVTTEs4OTU0WitORWdQdk9PaXNhVWVvbTFJRHFBeWRtQmdzQXc9PQo=
|
||||
@@ -0,0 +1,2 @@
|
||||
ShaCraft synthetic fixture; never execute or install.
|
||||
shacraft-launcher_0.3.0_linux-x86_64.AppImage
|
||||
@@ -0,0 +1 @@
|
||||
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEM4NUEyNTNEQjlERDZDMkIKUldRcmJOMjVQU1ZheUQ2UzVTN0NHS21ydFp1c1REajVucjlXYnFPK3ZSYWYrQVFDaUgvL3lpQ2UK
|
||||
@@ -0,0 +1 @@
|
||||
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDIyODAzRjlGOUFFNDM4QzYKUldUR09PU2FueitBSWhoU1Y4S2VFK21OeWRmamVkMlBreWRjbWdtRmNtbEZrTzMwNE92MDU2d3YK
|
||||
+38
-9
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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=""/)
|
||||
})
|
||||
|
||||
@@ -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, /<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
@@ -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
@@ -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
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() },
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -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'),
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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: 'Скачиваем библиотеки',
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user