Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cacd1cd2bd | ||
|
|
daefc3f5e7 | ||
|
|
738399b452 | ||
|
|
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
|
||||
+48
-59
@@ -1,4 +1,4 @@
|
||||
name: Cross-platform build
|
||||
name: Cross-platform build (disposable signatures)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -8,87 +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 app,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 app,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
|
||||
# These are build/test artifacts. Release signing happens separately with
|
||||
# the operator-held key; CI never receives that key or publishes stable.json.
|
||||
- run: npm run tauri:build -- --config scripts/tauri-unsigned.json ${{ matrix.args }}
|
||||
- name: Prepare unsigned macOS updater archive
|
||||
if: runner.os == 'macOS'
|
||||
- name: Generate disposable CI key (never a production secret)
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tarfile
|
||||
apps = list(Path('src-tauri/target').glob('*/release/bundle/macos/*.app'))
|
||||
if len(apps) != 1:
|
||||
raise SystemExit('Expected exactly one macOS application bundle')
|
||||
app = apps[0]
|
||||
version = json.loads(Path('src-tauri/tauri.conf.json').read_text())['version']
|
||||
architecture = app.parts[2].split('-')[0]
|
||||
archive = app.parent / f'ShaCraft.Launcher_{version}_{architecture}.app.tar.gz'
|
||||
with tarfile.open(archive, 'w:gz') as output:
|
||||
output.add(app, arcname=app.name)
|
||||
PY
|
||||
- name: Upload Windows installers
|
||||
if: runner.os == 'Windows'
|
||||
uses: actions/upload-artifact@v4
|
||||
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
|
||||
src-tauri/target/*/release/bundle/macos/*.app.tar.gz
|
||||
retention-days: 7
|
||||
path: ci-packages/*
|
||||
|
||||
+13
-26
@@ -13,30 +13,6 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
publisher:
|
||||
# Ubuntu 22.04 has no minisign package. Keep the desktop compatibility
|
||||
# check there, but exercise real signature verification on Ubuntu 24.04.
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: sudo apt-get update && sudo apt-get install -y minisign
|
||||
- run: python3 -m unittest discover -s scripts -p 'test_*.py'
|
||||
admission-client:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '25'
|
||||
- name: Check the Minigames admission companion
|
||||
working-directory: admission-client
|
||||
run: ./gradlew test build --no-daemon
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: shacraft-admission-client
|
||||
if-no-files-found: error
|
||||
path: admission-client/build/libs/shacraft-admission-client-0.1.0.jar
|
||||
check:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
@@ -45,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"
|
||||
+1
-3
@@ -11,6 +11,4 @@ src-tauri/gen/
|
||||
*.pfx
|
||||
*.sig
|
||||
|
||||
admission-client/.gradle/
|
||||
admission-client/build/
|
||||
graphify-out/
|
||||
__pycache__/
|
||||
|
||||
@@ -4,9 +4,8 @@
|
||||
|
||||
Cross-platform desktop launcher for the ShaCraft Minecraft network. It is a
|
||||
Tauri 2 application: React/Vite is the UI and Rust owns all filesystem,
|
||||
network and process-adjacent work. Profiles are **Aeronautics** (Minecraft 1.21.1, NeoForge 21.1.248, Java 21)
|
||||
and **Minigames** (Minecraft 26.2, Fabric 0.19.5, Java 25). See the Minigames
|
||||
integration record below and `docs/release-0.1.6.md` for publication evidence.
|
||||
network and process-adjacent work. The current production profile is
|
||||
**Aeronautics** (Minecraft 1.21.1, NeoForge 21.1.248, Java 21).
|
||||
|
||||
This repository owns the launcher only. The server-side API and published
|
||||
payload are in `/root/shacraft` on the ShaCraft host; see
|
||||
@@ -22,9 +21,8 @@ payload are in `/root/shacraft` on the ShaCraft host; see
|
||||
|
||||
## Trust model
|
||||
|
||||
- The only supported remote profile manifest endpoints are the fixed
|
||||
`https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest` and
|
||||
`https://shacraft.ru/api/launcher/v2/profiles/minigames/signed-manifest`.
|
||||
- The only supported remote profile manifest endpoint is
|
||||
`https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest`.
|
||||
The read-only Aeronautics player-count endpoint
|
||||
`https://shacraft.ru/api/online/aoc` is also hardcoded in `remote.rs`; it
|
||||
is display-only and is never allowed to influence downloads or launching.
|
||||
@@ -32,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
|
||||
@@ -43,62 +45,29 @@ payload are in `/root/shacraft` on the ShaCraft host; see
|
||||
control a URL in any of those domains.
|
||||
- ShaCraft accounts: `src-tauri/src/shacraft_account.rs` talks only to the
|
||||
hardcoded `https://shacraft.ru` origin. Passwords are never persisted. The
|
||||
revocable session token is stored locally with mode 600 on Unix and never
|
||||
passed to Java. The new admission flow uses fixed POST endpoints
|
||||
`/api/launcher/v2/admission/nickname` and
|
||||
`/api/launcher/v2/admission/tickets`; redirects are rejected. Neither the
|
||||
manifest nor the webview can select their origin or URL.
|
||||
- Free nicknames are claimed directly for the authenticated account. Existing
|
||||
player names are reserved server-side and assigned by an administrator;
|
||||
deleting a website account must not make an existing player's name free.
|
||||
- After installation, `admission.rs` generates an ephemeral Ed25519 key with
|
||||
the OS CSPRNG. The backend binds its public key to a one-use ticket, current
|
||||
account session, canonical `aoc` nickname and server access. Only that
|
||||
returned nickname selects launch identity; `settings.json` is never a fallback.
|
||||
Ticket and PKCS#8 private key go only in the final Java child's environment
|
||||
(`SHACRAFT_ADMISSION_TICKET`, `SHACRAFT_ADMISSION_PRIVATE_KEY`). Never put
|
||||
them in global environment, argv/argfiles, settings, logs or IPC. A fresh
|
||||
launch obtains a fresh ticket; there is no shared launcher secret.
|
||||
- Once admission is enforced on Aeronautics, its server mod verifies the
|
||||
challenge/proof before world entry, and server-side whitelist enforcement
|
||||
remains a final access boundary. Replace LoginSystem only as part of the
|
||||
validated `aoc` rollout; other servers are unaffected. Old launchers without
|
||||
admission proof will be rejected after enforcement. This authenticates an
|
||||
account's permission, not the integrity of an unmodified launcher binary.
|
||||
- Application updates are a separate trust domain from Minecraft profiles.
|
||||
The only channel is `https://shacraft.ru/launcher/updates/stable.json`.
|
||||
Both release metadata and the installer need a valid Minisign signature
|
||||
under the separate updater public key embedded in `tauri.conf.json`.
|
||||
Never reuse the profile signing key, accept unsigned metadata, or allow IPC
|
||||
to select an update URL, key, version, installer argument or destination.
|
||||
Downloads require HTTPS without redirects on exact `shacraft.ru`, below
|
||||
`/downloads/shacraft-launcher/<signed-version>/`, and are bounded to 256 MiB.
|
||||
Stable versions must increase. The native layer owns every candidate.
|
||||
- Linux self-update supports AppImage and an installed `sha-craft-launcher`
|
||||
deb. AppImage preserves executable permissions and uses same-directory
|
||||
atomic replacement after verification. Deb selects only the signed
|
||||
`linux-x86_64-deb` entry; retain legacy `linux-x86_64` AppImage metadata for
|
||||
installed 0.1.3 clients. Never silently switch installation formats.
|
||||
- Deb elevation uses only `/usr/bin/pkexec --disable-internal-agent` and the
|
||||
root-owned installed `/usr/bin/shacraft-launcher --shacraft-install-deb`.
|
||||
This early helper mode never starts GTK/Tauri or account/network code. It
|
||||
receives bounded metadata/package bytes over stdin, not paths or commands,
|
||||
and re-verifies both signatures with the embedded key as root. Before the
|
||||
fixed dpkg installation, require exact package name, architecture and signed
|
||||
version, root-only staging and monotonic installed-package version; pass
|
||||
`--refuse-downgrade` to dpkg to close concurrent-update races. No system
|
||||
password collection, sudo fallback or permissive polkit policy is allowed.
|
||||
Cancellation, denied authorization and a busy package manager stay distinct.
|
||||
Windows/macOS use the pinned Tauri installer implementation; the vendored
|
||||
updater change only exposes construction from already verified metadata to
|
||||
avoid a second, unbounded remote JSON request. See its patch notes.
|
||||
Installation holds game/install/account permits until restart. The game
|
||||
permit lasts until the tracked Java child exits. These are process-local
|
||||
guards; another launcher process is not a cross-process lock.
|
||||
- The updater signing private key stays outside Git on the operator's local
|
||||
machine; CI receives no production key. Publish only verified packages,
|
||||
public signatures and signed feed. Updater signatures are separate from
|
||||
Windows Authenticode and macOS code signing/notarization.
|
||||
revocable session token is stored locally with mode 600 on Unix. At launch,
|
||||
the nickname is fetched from the verified `aoc` account link; the legacy
|
||||
nickname in `settings.json` is ignored as an identity source. Server-side
|
||||
whitelist enforcement and LoginSystem remain the final access-control
|
||||
boundary, including for old launcher versions. 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
|
||||
|
||||
@@ -112,9 +81,8 @@ 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.
|
||||
The account permit covers ticket issuance through Java spawn, preventing
|
||||
local logout/account switching from racing that handoff.
|
||||
- `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.
|
||||
- `download.rs` — shared verified-download helper (temp file, hash,
|
||||
@@ -123,22 +91,17 @@ 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
|
||||
placeholder pending ShaCraft's own Azure AD app registration and
|
||||
Minecraft-API approval.
|
||||
- `shacraft_account.rs` — local ShaCraft login/registration, session and
|
||||
nickname claim/admission API; legacy link commands remain for compatibility.
|
||||
- `admission.rs` — ephemeral key generation, strict ticket response validation,
|
||||
canonical identity and child-only admission environment.
|
||||
- `launch.rs` — builds and spawns the actual `java` process; admission secrets
|
||||
must remain outside its argument substitution and JVM argfile paths.
|
||||
- `updater.rs`, `commands/updater.rs` — authenticated release metadata,
|
||||
bounded package download, platform installation and guarded restart.
|
||||
- `deb_updater.rs` — installed-package checks, one system authentication
|
||||
prompt and the bounded, signature-verifying non-GUI root helper.
|
||||
verified nickname-link API.
|
||||
- `launch.rs` — builds and spawns the actual `java` process.
|
||||
- `src-tauri/src/settings.rs` — durable local preferences; maintain backward
|
||||
compatibility with already-written JSON.
|
||||
- `docs/manifest-v1.md` — signed manifest envelope and payload contract
|
||||
@@ -146,9 +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 CI artifacts;
|
||||
updater signing is explicitly disabled there. Local release signing and
|
||||
atomic feed publication are documented in `docs/launcher-updates.md`.
|
||||
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
|
||||
|
||||
@@ -167,14 +136,6 @@ not prove Tauri commands work.
|
||||
|
||||
See `PLAN.md` for known gaps. Never label browser preview or unit tests as
|
||||
a successful cold game install / Microsoft OAuth / Windows/macOS beta test.
|
||||
The admission implementation has local unit/UI checks and unsigned Linux
|
||||
0.1.2 AppImage/deb packages built on Ubuntu 26.04. Older Ubuntu compatibility
|
||||
has not been tested. The backend/mod rollout is active on Aeronautics as of
|
||||
2026-09-10. Real isolated NeoForge admission tests and a production rejection
|
||||
without the mod passed; these do not certify a full production modpack join.
|
||||
The server has a required early duplicate-login guard so unauthenticated
|
||||
connections cannot evict an already-online UUID. The client mod pins the actual
|
||||
socket to `135.106.154.86:25567`. See architecture and server rollout records.
|
||||
|
||||
## Working conventions
|
||||
|
||||
@@ -184,74 +145,3 @@ socket to `135.106.154.86:25567`. See architecture and server rollout records.
|
||||
them. Inspect `git status` before staging.
|
||||
- Update this file and `docs/launcher-architecture.md` whenever the trust
|
||||
model, endpoint contract, storage layout, or release workflow changes.
|
||||
|
||||
|
||||
## Cross-platform release 0.1.5 (2026-09-10)
|
||||
|
||||
Published Windows x64 EXE/MSI, macOS aarch64 and x86_64 DMG/app.tar.gz,
|
||||
and Linux x64 AppImage/DEB on https://shacraft.ru/help#launcher. The signed
|
||||
stable feed includes all platforms plus the legacy/exact Linux aliases.
|
||||
CI source b43fc610c43a9ec9f5f3ffce601de9604670ff8a, successful Actions run
|
||||
https://github.com/emil28092005/shacraft-launcher/actions/runs/34512683651.
|
||||
An initial non-Linux borrow/move compilation error in updater target selection
|
||||
was fixed before the final build. Native tests: Windows70, macOS74 per arch,
|
||||
Linux84; UI tests pass on all four runners. Local checks verify macOS bundle
|
||||
version/CPU type, Linux package identity and every artifact signature. Public
|
||||
HTTPS downloads of all eight artifacts match local SHA-256. Website418 tests
|
||||
plus48 subtests pass; only backend recreated, game containers unchanged.
|
||||
Updater signatures use the existing operator-held key, never uploaded to CI or
|
||||
server. Windows installers have no Authenticode signature; macOS is not Apple
|
||||
notarized. Native CI tests and packaging do not certify full Minecraft installs
|
||||
or desktop updater/restart behavior on Windows/macOS. Older unsupported clients
|
||||
need a manual installation of the current release. Previous releases immutable.
|
||||
|
||||
|
||||
## Admission client menu 0.1.1 (2026-09-11)
|
||||
|
||||
The signed Aeronautics payload now contains admission mod 0.1.1 at the existing
|
||||
managed path `mods/shacraft-admission-0.1.0.jar` to prevent duplicate mod IDs on
|
||||
upgrade. SHA-256: `faa9ae13cb0f2d93c03dae26ab36ae20d3fb6b66c89c09254b16808d6b183f89`.
|
||||
From the title screen (and vanilla safety acknowledgement), Multiplayer connects
|
||||
to fixed `135.106.154.86:25567`. Cancel/errors return to TitleScreen; transitions
|
||||
from other screens do not auto-connect. Client-only registration, protocol 1 and
|
||||
one-use admission remain unchanged. Running game server was not restarted.
|
||||
Linux Java 21 build and 8 mod tests pass. An opt-in native live test verifies
|
||||
signed-manifest retrieval and download/repair/restoration of the admission jar
|
||||
only in a temporary directory. Mac 0.1.5 connection failure remains unclassified
|
||||
pending exact error/log; this is not a verified macOS fix or desktop UI test.
|
||||
|
||||
## Minigames integration (2026-09-13, published in 0.1.6)
|
||||
|
||||
- Native profile mapping is fixed: aeronautics → aoc; minigames → minigames.
|
||||
Both display/claim the canonical existing aoc nickname. The backend enforces
|
||||
the current shared aoc subscription and whitelist for Minigames as well.
|
||||
Ticket requests and responses remain bound to the selected server; never
|
||||
accept an aoc ticket as a Minigames ticket.
|
||||
- `fabric.rs` adds independent exact HTTPS domains `meta.fabricmc.net` and
|
||||
`maven.fabricmc.net`. It verifies profile identity/parent/main class, bounded
|
||||
metadata, portable Maven coordinates, hashes and sizes before the existing
|
||||
atomic library installer. Unknown loaders now fail manifest validation.
|
||||
- Minigames launches with a native-owned Quick Play endpoint
|
||||
`135.106.154.86:25568`. The manifest cannot choose a game destination.
|
||||
- `admission-client/` owns the small client-only Fabric 26.2 companion. Its
|
||||
configuration-phase proof uses `minigames` in the existing Ed25519 transcript,
|
||||
verifies the actual socket, canonical nickname and nonce, and signs once per
|
||||
process. Only a fresh launch can retry a consumed ticket. Java receives only
|
||||
the ephemeral ticket and private key in its child environment, never the
|
||||
website session/password. Keep server verification on Paper before world
|
||||
entry with an early duplicate UUID guard.
|
||||
- The standalone Paper admission adapter and backend remain server-project
|
||||
responsibilities. Do not put map/SMASH source into this launcher repository.
|
||||
- Production updater publication requires a separately built, monotonically
|
||||
newer launcher release and existing operator signatures. Source tests or a
|
||||
client jar alone do not update installed 0.1.5 launchers.
|
||||
|
||||
Launcher 0.1.6 was built from `799fa692` on `codex/launcher-updater`; divergent
|
||||
`main` remains unchanged. All four native CI builds, UI/native checks and 18
|
||||
publisher signature tests pass. The unchanged Fabric companion completed a
|
||||
real Minecraft 26.2 → Paper configuration handshake with a synthetic account;
|
||||
its CI artifact is byte-identical. All eight public packages and the stable
|
||||
feed were signed with the existing local updater key and verified through
|
||||
public HTTPS downloads. See `docs/release-0.1.6.md` and its committed receipt.
|
||||
This records successful release/admission verification, not a Windows/macOS
|
||||
cold install or OS signing/notarization certification.
|
||||
|
||||
@@ -17,113 +17,32 @@
|
||||
approval и живой OAuth-тест. Текущий запуск использует ShaCraft identity.
|
||||
- [ ] Cold install / repair / update / game exit на чистых Windows/Linux/macOS.
|
||||
Unit tests и web preview не заменяют эти прогоны.
|
||||
- [ ] Windows Authenticode / macOS signing-notarization и проверка установщиков.
|
||||
- [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. Не имитировать данные.
|
||||
|
||||
## Вход по одноразовому разрешению: внедрение 2026-09-10
|
||||
## Исправления по handoff (исходники, до выкладки)
|
||||
|
||||
- [x] Новый native claim закрепляет свободный ник за аккаунтом без входа в игру.
|
||||
Старые имена резервируются backend и переносятся администратором.
|
||||
- [x] После установки Rust создаёт Ed25519 ключ через OS CSPRNG и запрашивает
|
||||
ticket на фиксированном ShaCraft API. Ник для запуска берётся из ticket;
|
||||
локальные настройки не могут его заменить.
|
||||
- [x] Ticket и PKCS#8 private key передаются только окружением дочерней Java;
|
||||
session сайта остаётся в native. В argv, argfile, settings, логах и IPC
|
||||
секретов нет. Account permit держится до spawn.
|
||||
- [x] Ошибки и результаты привязки показаны в диалоге; двойной клик не создаёт
|
||||
параллельных запросов, старого polling нет.
|
||||
- [x] Локально пройдены 64 Rust-теста (5 live-тестов пропущены), 22 UI-теста,
|
||||
TypeScript/Vite и сборка Linux x86-64 `tauri:build -- --no-bundle`.
|
||||
Шесть браузерных сценариев используют только замоканный Tauri IPC.
|
||||
- [x] Собраны неподписанные AppImage и deb версии 0.1.2 на Ubuntu 26.04;
|
||||
проверены извлечение AppImage и metadata deb. Работа на старых Ubuntu и
|
||||
установка пакетов на чистой машине этим не подтверждены.
|
||||
- [x] Выложены backend, подписанный клиентский мод и серверный мод Aeronautics;
|
||||
сервер перезапущен и healthy. Whitelist сохранён; LoginSystem заменён только
|
||||
на `aoc`. Пакеты Linux 0.1.2 опубликованы на сайте, AppImage запущен локально.
|
||||
- [x] Настоящий изолированный NeoForge: новый ticket допускает в мир, отсутствие
|
||||
мода/билета и повтор билета отклоняются. Старый LoginSystem на клиенте не мешает.
|
||||
Подключение дубликата без авторизации не выбивает уже играющего владельца.
|
||||
- [x] На публичном сервере подключение без мода отклонено до входа в мир;
|
||||
HTTPS verifier из контейнера, подпись manifest и SHA-256 мода проверены.
|
||||
Backend Docker: 404 tests + 48 subtests, включая истечение, отзыв session,
|
||||
чужую identity, whitelist, резервирование имён и атомарное погашение.
|
||||
- [x] Полный вход в production Aeronautics через установленный лаунчер
|
||||
подтверждён пользователем 2026-09-10: «Присоединился!». Это пользовательское
|
||||
подтверждение, а не автоматизированный cold-install тест.
|
||||
- [ ] Проверить cold install и этот протокол на Windows/macOS, выпустить
|
||||
подписанные пакеты. Локальная Linux-сборка не подтверждает эти платформы.
|
||||
- [ ] Удобное переподключение: сейчас использованный или истёкший ticket
|
||||
требует нового запуска игры из лаунчера; автоматического обновления нет.
|
||||
- [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 по ОС.
|
||||
|
||||
## Самообновление лаунчера 0.1.3: реализация 2026-09-10
|
||||
|
||||
- [x] Отдельный канал обновления приложения на фиксированном HTTPS endpoint.
|
||||
Выделенный публичный ключ проверяет подпись metadata и пакета; версия,
|
||||
заметки и URL связаны подписью. Redirect, downgrade и произвольные пути
|
||||
из webview запрещены; размеры metadata и загрузки ограничены.
|
||||
- [x] Vendored Tauri updater 2.11.0 принимает уже проверенный JSON через
|
||||
`check_metadata` без второго HTTP-запроса. На Linux AppImage заменяется
|
||||
атомарно через временный файл в том же каталоге с проверкой подписи и fsync.
|
||||
- [x] Проверка при старте без автоматической установки; доступны уведомление
|
||||
о новой версии, ручная проверка, заметки, прогресс, ошибки, повторная попытка
|
||||
и явный перезапуск. Сбой проверки не блокирует установленный лаунчер.
|
||||
- [x] Native guards исключают обновление во время игры и конфликтующих
|
||||
операций. На время установки и до перезапуска заблокированы запуск игры,
|
||||
ремонт сборки и изменения аккаунта. Для deb и development binary показано
|
||||
сообщение об установке вручную; версия 0.1.2 требует первого ручного обновления.
|
||||
- [x] Пройдены 76 Rust-тестов, 28 UI-тестов и 11 тестов publisher;
|
||||
TypeScript/Vite успешно собраны. Девять браузерных сценариев с mock Tauri IPC
|
||||
проверяют обновление, ошибки, повтор, блокировки и восстановление состояния;
|
||||
внешняя сеть и реальные аккаунты в этих сценариях не используются.
|
||||
- [x] Живой native-прогон скачал 0.1.3 с production HTTPS: повреждение
|
||||
отклонено без изменения старого файла, подлинный пакет атомарно заменил
|
||||
временную копию 0.1.2. Исходный AppImage сохранён, хеши проверены.
|
||||
- [x] Опубликованы Linux 0.1.3 и подписанный stable feed; HTTPS 200,
|
||||
`Cache-Control: no-store`, подписи и хеши проверены. Ссылка на сайте обновлена.
|
||||
AppImage установлен в `~/Applications`, добавлен ярлык и проверен запуск.
|
||||
- [ ] Полный GUI-цикл «Обновить → Перезапустить» проверить на следующем
|
||||
релизе; текущий прогон проверяет native-установку и запуск пакета отдельно.
|
||||
- [ ] Проверить установку и самообновление Windows/macOS перед публикацией
|
||||
пакетов этих платформ; Authenticode/notarization остаются отдельными задачами.
|
||||
|
||||
## Обновление DEB 0.1.4: 2026-09-10
|
||||
|
||||
- [x] Отдельный подписанный deb entry, определение установленного формата
|
||||
и сохранение AppImage-совместимости для клиентов 0.1.3.
|
||||
- [x] Одно системное подтверждение через pkexec. Root helper до запуска GUI
|
||||
повторно проверяет подписи и точные Package/Version/Architecture, получает
|
||||
только bounded bytes через stdin и устанавливает пакет из root-only staging.
|
||||
Пароль не попадает в лаунчер; отмена не открывает запасные окна авторизации.
|
||||
- [x] Dpkg отказывается от downgrade и сохраняет системную блокировку пакетов.
|
||||
Ошибки частичной установки не маскируются; автоматических повторов нет.
|
||||
Read-only inspection имеет ограничение вывода и времени для группы процессов;
|
||||
работающий dpkg не прерывается таймаутом посреди изменения пакета.
|
||||
- [x] 32 UI-теста, TypeScript/Vite и 12 браузерных сценариев с mock IPC;
|
||||
18 publisher-тестов с настоящими minisign и deb, включая переход feed 0.1.3.
|
||||
- [x] 84 native-теста прошли. Реальный root-helper в изолированном Ubuntu 26.04
|
||||
прошёл 10 сценариев: установка подписанного 0.1.4, повреждения, неверные
|
||||
права/временный каталог, занятый dpkg, повтор и защита от downgrade.
|
||||
Dpkg подтвердил версию, тестовый маркер профиля сохранён. GUI-подтверждение
|
||||
PolicyKit этим контейнерным прогоном не проверялось; отмена покрыта UI/unit.
|
||||
- [x] Опубликованы подписанные AppImage/deb 0.1.4, проверены байты feed и deb,
|
||||
обе кнопки сайта и системная инструкция. Backend: 404 tests + 48 subtests,
|
||||
Ruff clean. Minecraft не перезапускался, данные аккаунтов не менялись.
|
||||
Deb 0.1.3 и ниже требуют первого ручного обновления до 0.1.4.
|
||||
|
||||
## Связанные серверные риски
|
||||
|
||||
Серверный план находится в `/root/shacraft/PLAN.md`. Для admission обязательны
|
||||
атомарная одноразовая проверка ticket, привязка к текущим session/аккаунту/нику,
|
||||
проверка до входа в мир и сохранение whitelist как серверного ограничения.
|
||||
Старые игровые имена нельзя отдавать первому зарегистрировавшемуся; их
|
||||
резервирование и назначение аккаунтам — отдельный этап миграции. Старый
|
||||
NoGravity challenge-поток должен быть закрыт при включённом admission, чтобы
|
||||
он не обходил резервирование имени. Остальные серверные задачи включают
|
||||
enforcement реферальных правил и очередь повторов whitelist. Не менять
|
||||
протокол незаметно в клиентском рефакторинге и не считать ticket доказательством
|
||||
неизменённости клиентского бинарника.
|
||||
Сопутствующий серверный код содержит read-only status, nonce proof с legacy
|
||||
migration и durable grant/revoke outbox. Оплата и доставка whitelist — разные
|
||||
состояния. Реферальные правила остаются отдельной задачей серверного PLAN;
|
||||
это исправление не меняет условия покупки. Продакшен не изменён.
|
||||
|
||||
@@ -3,15 +3,19 @@
|
||||
Кроссплатформенный Tauri 2 лаунчер для [ShaCraft](https://shacraft.ru/):
|
||||
React/TypeScript интерфейс, Rust — файлы, сеть и запуск процессов.
|
||||
|
||||
Реализованы отдельные профили Aeronautics (Minecraft 1.21.1, NeoForge, Java 21)
|
||||
и Minigames (Minecraft 26.2, Fabric, Java 25), подписанная синхронизация файлов,
|
||||
проверка/восстановление модов и конфигурации, установка игры и Java, настройки
|
||||
памяти, обработка установки/запуска/выхода. Minigames опубликован в 0.1.6;
|
||||
проверки и результаты публикации — в [записи релиза](docs/release-0.1.6.md).
|
||||
Реализованы подписанная синхронизация Aeronautics, проверка/восстановление
|
||||
модов и конфигурации, Java discovery/provisioning, bootstrap Minecraft и
|
||||
NeoForge, настройки памяти, обработка установки/запуска/выхода. Проверка
|
||||
сборки теперь восстанавливает и игровые файлы; старые неизменённые managed-моды
|
||||
убираются в резервную копию, неизвестные моды разбираются явно пользователем.
|
||||
|
||||
Вход выполняется через аккаунт ShaCraft — тот же, что на сайте. Игровой ник
|
||||
общий для обоих профилей и берётся только из подтверждённой привязки `aoc`.
|
||||
Тикет входа привязан к выбранному серверу; локальные настройки не выбирают личность. Пароли не сохраняются; сессию можно отозвать.
|
||||
берётся только из подтверждённой привязки Aeronautics, а не из редактируемых
|
||||
локальных настроек. Пароли не сохраняются; сессию можно отозвать.
|
||||
Для первого входа без привязки добавлено отдельное действие в настройках
|
||||
аккаунта: установка → серверное разрешение → LoginSystem → одноразовая команда
|
||||
подтверждения. Оно требует совместной выкладки backend, Game Bridge и
|
||||
подписанного модпака; наличие исходников не означает публикацию этого сценария.
|
||||
Microsoft OAuth-модуль сохранён отдельно, но не используется текущим
|
||||
сценарием запуска; для его активации потребуются client ID и API approval.
|
||||
|
||||
@@ -39,30 +43,14 @@ cargo test --locked --manifest-path src-tauri/Cargo.toml
|
||||
|
||||
Build включает строгий TypeScript. GitHub Actions проверяет UI и Rust на
|
||||
push/PR; workflow на main-push/ручном запуске собирает Windows x64, Linux x64, macOS Intel
|
||||
и Apple Silicon и сохраняет неподписанные артефакты. Релизный оператор
|
||||
подписывает проверенные пакеты и metadata локальным ключом; CI его не получает.
|
||||
и Apple Silicon и сохраняет артефакты. Подпись релиза/автообновления ещё впереди.
|
||||
Используемые macOS runners соответствуют [списку GitHub](https://docs.github.com/en/actions/reference/runners/github-hosted-runners).
|
||||
|
||||
## Обновление лаунчера
|
||||
|
||||
С версии 0.1.3 настройки содержат проверку обновлений, установку с прогрессом
|
||||
и перезапуск. Лаунчер также проверяет новые версии при старте, но устанавливает
|
||||
их только по кнопке. Подписи пакета и сведений о версии обязательны.
|
||||
Закройте запущенный Minecraft перед установкой обновления.
|
||||
|
||||
В Linux обновляются AppImage и установленный deb (с версии 0.1.4).
|
||||
Для deb система запрашивает права администратора; пароль не передаётся лаунчеру.
|
||||
Deb 0.1.3 и ниже нужно один раз обновить вручную до 0.1.4. Dev-бинарник
|
||||
обновляется вручную. С версии 0.1.2 нужен ручной переход на новый AppImage.
|
||||
Пакеты Windows/macOS 0.1.5 опубликованы. Проверка реальной установки на каждой
|
||||
платформе остаётся отдельной от сборки и автоматических тестов.
|
||||
|
||||
## Навигация
|
||||
|
||||
- [Архитектура](docs/launcher-architecture.md) — компоненты, данные, IPC.
|
||||
- [Trust boundaries](docs/game-trust-boundary.md) — доверенные источники игры.
|
||||
- [Manifest](docs/manifest-v1.md) — подписанный контракт модпака.
|
||||
- [Обновления](docs/launcher-updates.md) — подпись, публикация и восстановление.
|
||||
- [PLAN.md](PLAN.md) — ограничения и следующие шаги.
|
||||
- [AGENTS.md](AGENTS.md) — инструкции для следующего разработчика/агента.
|
||||
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
# ShaCraft Minigames admission client
|
||||
|
||||
Client-only Fabric companion for Minecraft 26.2, Java 25, Fabric Loader 0.19.5
|
||||
and Fabric API 0.160.0+26.2. This is account admission, not an anti-cheat or a
|
||||
proof that the original launcher binary is running.
|
||||
|
||||
Build with Java 25: `./gradlew test build --no-daemon`.
|
||||
Output: `build/libs/shacraft-admission-client-0.1.0.jar`.
|
||||
Publish that jar and the pinned Fabric API jar in the signed Minigames profile.
|
||||
|
||||
The native launcher supplies `SHACRAFT_ADMISSION_TICKET` and
|
||||
`SHACRAFT_ADMISSION_PRIVATE_KEY` only to the final Java child environment.
|
||||
The client accepts a CONFIGURATION payload on `shacraft_admission:challenge`
|
||||
with three Minecraft UTF strings: server ID (16), nickname (16), nonce (43).
|
||||
It verifies server `minigames`, the exact current game nickname and actual
|
||||
socket `135.106.154.86:25568`, then signs once with the ephemeral Ed25519 key.
|
||||
The response on `shacraft_admission:proof` contains ticket (43) and standard
|
||||
Base64 signature (88). The transcript has no final newline:
|
||||
|
||||
```
|
||||
shacraft-admission-v1
|
||||
{ticket_id}
|
||||
minigames
|
||||
{mc_username}
|
||||
{nonce}
|
||||
```
|
||||
|
||||
The private key and website session never go onto the Minecraft wire. Errors
|
||||
are redacted. A fresh game launch is needed for another connection after a
|
||||
proof has been sent. `SHACRAFT_ADMISSION_ALLOW_LOOPBACK=1` additionally permits
|
||||
literal loopback sockets for isolated tests; normal releases do not set it.
|
||||
Paper must fail closed before world entry and reject unauthenticated duplicate
|
||||
UUIDs before the vanilla duplicate-player eviction. The backend checks the
|
||||
current shared aoc account access and atomically redeems the server-bound ticket.
|
||||
|
||||
Three unit tests cover exact signature binding, invalid/cross-server fields
|
||||
and socket allowlisting. The unchanged production companion also passed actual Minecraft 26.2
|
||||
configuration negotiation and entered a local Paper lobby with a synthetic
|
||||
backend ticket; see [receipt](../docs/verification/minigames-fabric-2026-09-13.json).
|
||||
The public server and other platforms still require their own rollout checks.
|
||||
|
||||
The client explicitly advertises its single challenge receiver with vanilla
|
||||
`minecraft:register` at the start of configuration. Fabric normally waits for
|
||||
the server's registration first, while Paper gates plugin sends on that client
|
||||
advertisement. This bootstrap uses the pinned Fabric API's RegistrationPayload;
|
||||
update it and rerun live negotiation checks when upgrading Fabric API. It is
|
||||
queued after INIT so vanilla has switched outbound protocol to CONFIGURATION.
|
||||
@@ -1,37 +0,0 @@
|
||||
plugins {
|
||||
id 'net.fabricmc.fabric-loom' version "${loom_version}"
|
||||
}
|
||||
|
||||
repositories { mavenCentral() }
|
||||
|
||||
loom {
|
||||
splitEnvironmentSourceSets()
|
||||
mods {
|
||||
'shacraft_admission' {
|
||||
sourceSet sourceSets.main
|
||||
sourceSet sourceSets.client
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
minecraft "com.mojang:minecraft:${project.minecraft_version}"
|
||||
implementation "net.fabricmc:fabric-loader:${project.loader_version}"
|
||||
implementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}"
|
||||
testImplementation platform('org.junit:junit-bom:5.12.2')
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter'
|
||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||
}
|
||||
|
||||
processResources {
|
||||
inputs.property 'version', project.version
|
||||
filesMatching('fabric.mod.json') { expand version: project.version }
|
||||
}
|
||||
|
||||
tasks.withType(JavaCompile).configureEach { options.release = 25 }
|
||||
java { toolchain.languageVersion = JavaLanguageVersion.of(25); withSourcesJar() }
|
||||
test { useJUnitPlatform() }
|
||||
|
||||
// Pure HTTP/validation tests use the same client implementation without launching Minecraft.
|
||||
sourceSets.test.compileClasspath += sourceSets.client.output
|
||||
sourceSets.test.runtimeClasspath += sourceSets.client.output
|
||||
@@ -1,9 +0,0 @@
|
||||
org.gradle.jvmargs=-Xmx2G
|
||||
org.gradle.parallel=false
|
||||
org.gradle.configuration-cache=false
|
||||
minecraft_version=26.2
|
||||
loader_version=0.19.5
|
||||
loom_version=1.17.20
|
||||
fabric_api_version=0.160.0+26.2
|
||||
version=0.1.0
|
||||
group=ru.shacraft
|
||||
Binary file not shown.
@@ -1,8 +0,0 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
|
||||
distributionSha256Sum=bafc141b619ad6350fd975fc903156dd5c151998cc8b058e8c1044ab5f7b031f
|
||||
networkTimeout=30000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
Vendored
-248
@@ -1,248 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
Vendored
-82
@@ -1,82 +0,0 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||
setlocal EnableExtensions
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||
@rem which allows us to clear the local environment before executing the java command
|
||||
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
||||
|
||||
:exitWithErrorLevel
|
||||
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||
@@ -1,8 +0,0 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
maven { url = 'https://maven.fabricmc.net/' }
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
rootProject.name = 'shacraft-admission-client'
|
||||
@@ -1,57 +0,0 @@
|
||||
package ru.shacraft.admission;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import net.fabricmc.api.ClientModInitializer;
|
||||
import net.fabricmc.fabric.api.client.networking.v1.ClientConfigurationNetworking;
|
||||
import net.fabricmc.fabric.api.client.networking.v1.ClientConfigurationConnectionEvents;
|
||||
import net.fabricmc.fabric.impl.networking.RegistrationPayload;
|
||||
import net.minecraft.network.protocol.common.ServerboundCustomPayloadPacket;
|
||||
import java.util.List;
|
||||
import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry;
|
||||
import net.minecraft.network.chat.Component;
|
||||
|
||||
/** The account session remains in the native launcher, never in Minecraft. */
|
||||
public final class ClientAdmission implements ClientModInitializer {
|
||||
private static final AtomicBoolean USED = new AtomicBoolean();
|
||||
|
||||
@Override public void onInitializeClient() {
|
||||
PayloadTypeRegistry.clientboundConfiguration().register(AdmissionPayloads.Challenge.TYPE, AdmissionPayloads.Challenge.CODEC);
|
||||
PayloadTypeRegistry.serverboundConfiguration().register(AdmissionPayloads.Proof.TYPE, AdmissionPayloads.Proof.CODEC);
|
||||
ClientConfigurationNetworking.registerGlobalReceiver(AdmissionPayloads.Challenge.TYPE, ClientAdmission::challenge);
|
||||
// Paper waits for vanilla channel advertisement, while Fabric normally waits
|
||||
// for the server's registration first. Bootstrap our one fixed receiver.
|
||||
// INIT runs in the listener constructor; schedule() queues until after vanilla
|
||||
// has switched the outbound protocol from LOGIN to CONFIGURATION.
|
||||
ClientConfigurationConnectionEvents.INIT.register((listener, client) -> client.schedule(() ->
|
||||
listener.send(new ServerboundCustomPayloadPacket(new RegistrationPayload(
|
||||
RegistrationPayload.REGISTER, List.of(AdmissionPayloads.Challenge.TYPE.id()))))));
|
||||
}
|
||||
|
||||
private static void challenge(AdmissionPayloads.Challenge challenge, ClientConfigurationNetworking.Context context) {
|
||||
var connection = context.packetContext().orElseThrow(net.fabricmc.fabric.api.networking.v1.context.PacketContext.CONNECTION);
|
||||
boolean loopback = "1".equals(System.getenv("SHACRAFT_ADMISSION_ALLOW_LOOPBACK"));
|
||||
if (!(connection.getRemoteAddress() instanceof InetSocketAddress remote)
|
||||
|| remote.getAddress() == null
|
||||
|| !AdmissionProof.allowedTarget(remote.getAddress().getHostAddress(), remote.getPort(), loopback)
|
||||
|| !AdmissionProof.SERVER_ID.equals(challenge.serverId())
|
||||
|| !context.client().getUser().getName().equals(challenge.nickname())
|
||||
|| !AdmissionProof.validOpaque(challenge.nonce())) {
|
||||
deny(context); return;
|
||||
}
|
||||
String ticket = System.getenv("SHACRAFT_ADMISSION_TICKET");
|
||||
String privateKey = System.getenv("SHACRAFT_ADMISSION_PRIVATE_KEY");
|
||||
if (!AdmissionProof.validOpaque(ticket) || !USED.compareAndSet(false, true)) {
|
||||
deny(context); return;
|
||||
}
|
||||
try {
|
||||
String signature = AdmissionProof.sign(privateKey, ticket, challenge.serverId(), challenge.nickname(), challenge.nonce());
|
||||
context.responseSender().sendPacket(new AdmissionPayloads.Proof(ticket, signature));
|
||||
} catch (Exception invalidKey) { deny(context); }
|
||||
}
|
||||
|
||||
private static void deny(ClientConfigurationNetworking.Context context) {
|
||||
context.responseSender().disconnect(Component.literal(
|
||||
"Не удалось подтвердить вход ShaCraft. Закройте игру и запустите её заново через ShaCraft Launcher."));
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package ru.shacraft.admission;
|
||||
|
||||
import net.minecraft.network.FriendlyByteBuf;
|
||||
import net.minecraft.network.codec.StreamCodec;
|
||||
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
public final class AdmissionPayloads {
|
||||
private AdmissionPayloads() {}
|
||||
|
||||
public record Challenge(String serverId, String nickname, String nonce) implements CustomPacketPayload {
|
||||
public static final Type<Challenge> TYPE = new Type<>(Identifier.fromNamespaceAndPath("shacraft_admission", "challenge"));
|
||||
public static final StreamCodec<FriendlyByteBuf, Challenge> CODEC = StreamCodec.of(
|
||||
(buffer, value) -> { buffer.writeUtf(value.serverId, 16); buffer.writeUtf(value.nickname, 16); buffer.writeUtf(value.nonce, 43); },
|
||||
buffer -> new Challenge(buffer.readUtf(16), buffer.readUtf(16), buffer.readUtf(43)));
|
||||
@Override public Type<Challenge> type() { return TYPE; }
|
||||
@Override public String toString() { return "AdmissionChallenge[redacted]"; }
|
||||
}
|
||||
|
||||
public record Proof(String ticket, String signature) implements CustomPacketPayload {
|
||||
public static final Type<Proof> TYPE = new Type<>(Identifier.fromNamespaceAndPath("shacraft_admission", "proof"));
|
||||
public static final StreamCodec<FriendlyByteBuf, Proof> CODEC = StreamCodec.of(
|
||||
(buffer, value) -> { buffer.writeUtf(value.ticket, 43); buffer.writeUtf(value.signature, 88); },
|
||||
buffer -> new Proof(buffer.readUtf(43), buffer.readUtf(88)));
|
||||
@Override public Type<Proof> type() { return TYPE; }
|
||||
@Override public String toString() { return "AdmissionProof[redacted]"; }
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package ru.shacraft.admission;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.Signature;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.util.Base64;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** The only client credential is an ephemeral private key supplied by the launcher. */
|
||||
public final class AdmissionProof {
|
||||
public static final String SERVER_ID = "minigames";
|
||||
private static final Pattern OPAQUE = Pattern.compile("[A-Za-z0-9_-]{43}");
|
||||
private static final Pattern NICKNAME = Pattern.compile("[A-Za-z0-9_]{3,16}");
|
||||
|
||||
private AdmissionProof() {}
|
||||
|
||||
public static boolean validOpaque(String value) {
|
||||
return value != null && OPAQUE.matcher(value).matches();
|
||||
}
|
||||
|
||||
public static byte[] transcript(String ticket, String serverId, String nickname, String nonce) {
|
||||
if (!validOpaque(ticket) || !validOpaque(nonce) || !SERVER_ID.equals(serverId)
|
||||
|| nickname == null || !NICKNAME.matcher(nickname).matches()) {
|
||||
throw new IllegalArgumentException("Invalid admission challenge");
|
||||
}
|
||||
return ("shacraft-admission-v1\n" + ticket + "\n" + serverId + "\n"
|
||||
+ nickname + "\n" + nonce).getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
public static String sign(String encodedPrivateKey, String ticket, String serverId,
|
||||
String nickname, String nonce) throws Exception {
|
||||
if (encodedPrivateKey == null || encodedPrivateKey.length() > 256) {
|
||||
throw new IllegalArgumentException("Missing admission key");
|
||||
}
|
||||
byte[] encoded = Base64.getDecoder().decode(encodedPrivateKey);
|
||||
try {
|
||||
PrivateKey key = KeyFactory.getInstance("Ed25519")
|
||||
.generatePrivate(new PKCS8EncodedKeySpec(encoded));
|
||||
Signature signer = Signature.getInstance("Ed25519");
|
||||
signer.initSign(key);
|
||||
signer.update(transcript(ticket, serverId, nickname, nonce));
|
||||
return Base64.getEncoder().encodeToString(signer.sign());
|
||||
} finally {
|
||||
java.util.Arrays.fill(encoded, (byte) 0);
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean validSignature(String value) {
|
||||
if (value == null || value.length() != 88) return false;
|
||||
try {
|
||||
byte[] decoded = Base64.getDecoder().decode(value);
|
||||
return decoded.length == 64 && Base64.getEncoder().encodeToString(decoded).equals(value);
|
||||
} catch (IllegalArgumentException invalid) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean allowedTarget(String host, int port, boolean allowLoopback) {
|
||||
if (host == null) return false;
|
||||
if (allowLoopback && (host.equals("127.0.0.1") || host.equals("::1") || host.equals("[::1]") || host.equals("0:0:0:0:0:0:0:1"))) {
|
||||
return port > 0 && port <= 65535;
|
||||
}
|
||||
return port == 25568 && (host.equalsIgnoreCase("shacraft.ru") || host.equals("135.106.154.86"));
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "shacraft_admission",
|
||||
"version": "${version}",
|
||||
"name": "ShaCraft Minigames Admission",
|
||||
"description": "Account-bound admission to ShaCraft Minigames.",
|
||||
"environment": "client",
|
||||
"entrypoints": { "client": ["ru.shacraft.admission.ClientAdmission"] },
|
||||
"depends": { "fabricloader": ">=0.19.5", "minecraft": "26.2", "java": ">=25", "fabric-networking-api-v1": "*" }
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package ru.shacraft.admission;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.Signature;
|
||||
import java.util.Base64;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class AdmissionProofTest {
|
||||
private static final String TICKET = "A".repeat(43), NONCE = "B".repeat(43);
|
||||
@Test void signsExactServerBoundTranscript() throws Exception {
|
||||
var pair=KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
|
||||
String signed=AdmissionProof.sign(Base64.getEncoder().encodeToString(pair.getPrivate().getEncoded()), TICKET,"minigames","Pilot_1",NONCE);
|
||||
var verifier=Signature.getInstance("Ed25519"); verifier.initVerify(pair.getPublic());
|
||||
verifier.update(("shacraft-admission-v1\n"+TICKET+"\nminigames\nPilot_1\n"+NONCE).getBytes(StandardCharsets.UTF_8));
|
||||
assertTrue(verifier.verify(Base64.getDecoder().decode(signed)));
|
||||
verifier.update(AdmissionProof.transcript(TICKET,"minigames","Other",NONCE));
|
||||
assertFalse(verifier.verify(Base64.getDecoder().decode(signed)));
|
||||
}
|
||||
@Test void rejectsCrossServerAndMalformedFields() {
|
||||
for (String[] v:new String[][]{{TICKET,"aoc","Pilot",NONCE},{TICKET,"minigames","Bad\nName",NONCE},{"bad","minigames","Pilot",NONCE},{TICKET,"minigames","Pilot","bad"}})
|
||||
assertThrows(IllegalArgumentException.class,()->AdmissionProof.transcript(v[0],v[1],v[2],v[3]));
|
||||
}
|
||||
@Test void trustsOnlyMinigamesSocketAndExplicitLocalTests() {
|
||||
assertTrue(AdmissionProof.allowedTarget("135.106.154.86",25568,false));
|
||||
assertFalse(AdmissionProof.allowedTarget("135.106.154.86",25567,false));
|
||||
assertFalse(AdmissionProof.allowedTarget("127.0.0.1",25568,false));
|
||||
assertTrue(AdmissionProof.allowedTarget("127.0.0.1",25570,true));
|
||||
assertFalse(AdmissionProof.allowedTarget("attacker.invalid",25568,true));
|
||||
}
|
||||
}
|
||||
+19
-15
@@ -91,20 +91,24 @@ these domains. When adding a new game-related download, verify its host is
|
||||
one of the ones above (or add a new hardcoded constant following the same
|
||||
pattern) rather than accepting a URL from anywhere else.
|
||||
|
||||
## 5. Fabric (`fabric.rs`, Minigames)
|
||||
|
||||
Metadata comes only from `https://meta.fabricmc.net/v2/versions/loader/` for
|
||||
manifest-selected, validated version identifiers. Profile ID, parent and
|
||||
KnotClient main class must match. At most 512 KiB metadata and 32 libraries
|
||||
are accepted. Library URLs are constructed only below
|
||||
`https://maven.fabricmc.net/` from portable three-part Maven coordinates;
|
||||
other metadata origins are rejected. Libraries require a 40-hex SHA-1 and a
|
||||
positive size up to 64 MiB. When Fabric metadata omits either for its loader
|
||||
jar, the fixed Maven's `.sha1` sidecar and HEAD provide them. Existing verified
|
||||
downloads perform the hash/size check and atomic rename. The ShaCraft signed
|
||||
manifest cannot select Fabric metadata URLs, repositories or launch targets.
|
||||
## Generated NeoForge artifacts and Java selection
|
||||
|
||||
Minecraft 26.2's official version metadata requires Java 25. `runtime.rs`
|
||||
already provisions a separate Adoptium Java 25 runtime without changing the
|
||||
Aeronautics Java 21 runtime. Client companion mods and Fabric API are separately
|
||||
approved ShaCraft managed files in the signed Minigames profile.
|
||||
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.
|
||||
|
||||
+167
-265
@@ -2,43 +2,37 @@
|
||||
|
||||
## Current capability
|
||||
|
||||
The launcher persists local settings, synchronises profile mod/config
|
||||
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 current
|
||||
admission implementation claims a free nickname for that account and requests
|
||||
a one-use permission immediately before launching Java. The game identity
|
||||
comes only from the canonical Aeronautics nickname in that permission; the
|
||||
legacy editable nickname setting is not trusted at launch.
|
||||
|
||||
The backend and admission mod were deployed to Aeronautics on 2026-09-10;
|
||||
the server is healthy with whitelist enforcement retained. Real isolated
|
||||
NeoForge connections verified successful admission, absent/replayed proof
|
||||
rejection and protection of an online player from duplicate login. A public
|
||||
production connection without the mod was rejected before world entry.
|
||||
This does not certify a full cold installation or Windows/macOS operation.
|
||||
signs in with the same local ShaCraft account used on the website. The game
|
||||
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.
|
||||
|
||||
Version 0.1.3 adds signed application updates, separate from modpack sync.
|
||||
Version 0.1.4 adds installed deb updates with system administrator confirmation.
|
||||
The first AppImage upgrade from 0.1.2 and deb upgrade from 0.1.3 are manual.
|
||||
Windows/macOS 0.1.5 packages are published; actual desktop installation tests remain pending.
|
||||
Not yet implemented: a user-selectable profile directory and a "reset managed
|
||||
files only" recovery action. OS code signing/notarization is separate from the
|
||||
updater signatures and is not certified by this implementation.
|
||||
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
|
||||
|
||||
Managed files, game installation and account admission meet at Java spawn:
|
||||
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)
|
||||
@@ -46,14 +40,9 @@ Game itself (never controlled by the manifest above)
|
||||
-> NeoForge's own installer, run headlessly (neoforge.rs)
|
||||
-> generic inheritsFrom merge of the two version JSONs (mojang.rs)
|
||||
-> SHA-1-verified merged libraries + platform natives (mojang.rs)
|
||||
|
||||
Admission (fixed ShaCraft account API; never controlled by a manifest)
|
||||
native website session -> direct free-nickname claim or admin migration
|
||||
-> after installation: OS CSPRNG -> ephemeral Ed25519 key (admission.rs)
|
||||
-> public key + bearer session -> one-use ticket for canonical aoc nickname
|
||||
-> deterministic offline UUID for that nickname (session.rs)
|
||||
-> Java with merged classpath/args + child-only ticket/private-key environment
|
||||
-> client mod signs server challenge; server validates before world entry
|
||||
-> verified ShaCraft account link (shacraft_account.rs)
|
||||
-> deterministic offline UUID for the linked nickname (session.rs)
|
||||
-> java process spawned with the merged classpath/args (launch.rs)
|
||||
```
|
||||
|
||||
Profiles (ShaCraft-managed mods/config, and the player's own worlds/
|
||||
@@ -63,8 +52,7 @@ install (versions/libraries/assets/runtime, reused across profiles that
|
||||
target the same Minecraft version) lives at `app_data_dir()/game`. Settings
|
||||
live at `app_data_dir()/settings.json`, and the revocable ShaCraft session at
|
||||
`app_data_dir()/shacraft-session` (mode 600 on Unix). Passwords are never
|
||||
written to disk. The admission private key and ticket are ephemeral native
|
||||
values and are not persisted. None of these directories should be assumed to
|
||||
written to disk. None of these should be assumed to
|
||||
be the system `.minecraft` directory.
|
||||
|
||||
## Aeronautics contract
|
||||
@@ -79,81 +67,18 @@ be the system `.minecraft` directory.
|
||||
- ShaCraft download files: HTTPS only, exact hosts `shacraft.ru` and
|
||||
`cdn.shacraft.ru`.
|
||||
- Account API origin: fixed `https://shacraft.ru`; redirects are rejected.
|
||||
- Launch identity: the canonical `aoc` nickname returned with the admission
|
||||
ticket. Local nickname edits cannot select an identity.
|
||||
|
||||
## Admission contract (implementation: 2026-09-10)
|
||||
|
||||
Both endpoints use the fixed account API origin, HTTPS without redirects and
|
||||
the native website session as bearer authentication. The session is never
|
||||
passed to the client mod or Java process.
|
||||
|
||||
`POST /api/launcher/v2/admission/nickname` accepts
|
||||
`{server_id: "aoc", mc_username: "Chosen_Name"}` and returns the existing
|
||||
account shape `{username, links}`. The backend atomically assigns a free name
|
||||
to that account. Existing player names remain reserved and require explicit
|
||||
administrator migration. The launcher no longer asks the player to enter the
|
||||
game to complete this claim. Legacy challenge IPC remains available to older
|
||||
flows, but an admission-enabled backend must block those legacy endpoints
|
||||
from bypassing reserved-name ownership.
|
||||
|
||||
After installation completes, the native launcher generates a fresh Ed25519
|
||||
key using the OS CSPRNG and calls
|
||||
`POST /api/launcher/v2/admission/tickets` with
|
||||
`{server_id: "aoc", public_key: "<standard base64 raw 32-byte public key>"}`.
|
||||
The response is `{ticket_id, mc_username, server_id, expires_in_seconds}`.
|
||||
The native boundary requires a canonical 43-character base64url ticket ID,
|
||||
an ASCII Minecraft nickname of 3–16 letters/digits/underscores, server `aoc`
|
||||
and a positive lifetime of at most 600 seconds. The backend checks the current
|
||||
account session, bound nickname and server access before issuing it.
|
||||
|
||||
`admission.rs` has no secret-bearing `Debug` or `Serialize` implementation.
|
||||
Only the final Java child's environment receives:
|
||||
|
||||
- `SHACRAFT_ADMISSION_TICKET`: the one-use ticket ID.
|
||||
- `SHACRAFT_ADMISSION_PRIVATE_KEY`: standard base64 of the Ed25519 PKCS#8
|
||||
private-key-only DER representation accepted by Java's `KeyFactory`.
|
||||
|
||||
No global environment mutation, webview/IPC payload, argument substitution,
|
||||
JVM argfile, settings file or log stores these values. The account operation
|
||||
permit remains held from issuance through spawn so local account switching
|
||||
or logout cannot race the handoff. Missing, disabled or invalid admission
|
||||
responses fail the launch with a visible error; there is no legacy-name or
|
||||
unsigned fallback. In this first version, a consumed or expired ticket needs
|
||||
a fresh game launch from the launcher; transparent in-game reconnect is not
|
||||
implemented.
|
||||
|
||||
The client mod proves possession of the ephemeral private key by signing the
|
||||
server's challenge. The server mod gates world entry on successful backend
|
||||
verification, including one-use consumption and current account/link/access
|
||||
checks. Whitelist enforcement is retained. The 2026-09-10 Aeronautics rollout
|
||||
replaced its separate LoginSystem `/register` and `/login` flow; other servers
|
||||
keep their existing authentication. Old launchers without admission proof
|
||||
cannot join Aeronautics. A required server-only Mixin rejects a duplicate
|
||||
online UUID before vanilla can disconnect the existing player. The client pins
|
||||
the actual game socket to `135.106.154.86:25567`; changing that address requires
|
||||
an explicit mod update. Server source and rollout records live in
|
||||
`/root/shacraft/services/admission-mod` and `docs/admission-2026-09-10.md` on
|
||||
the ShaCraft host.
|
||||
|
||||
This protocol prevents entry without the account's current permission. It
|
||||
does not attest that an original launcher or game binary is unmodified:
|
||||
software running as the same user can read its own process environment, and
|
||||
a compatible client can implement the protocol. Never replace account-bound
|
||||
proof with a shared key embedded in distributed binaries.
|
||||
- Launch identity: the most recently verified `aoc` nickname returned by the
|
||||
authenticated account API. Local nickname edits cannot select an identity.
|
||||
|
||||
## Planned but not implemented
|
||||
|
||||
1. User-selectable profile directory and structured launcher logs.
|
||||
2. "Reset managed files only" recovery action that doesn't touch player
|
||||
worlds/screenshots/resourcepacks.
|
||||
3. Windows Authenticode/macOS signing-notarization and cross-platform release
|
||||
installation testing.
|
||||
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.
|
||||
5. Transparent reconnect after the admission ticket has been consumed or
|
||||
expired. The current implementation requires a new launch.
|
||||
|
||||
Do not represent these as completed features in UI or release notes.
|
||||
|
||||
@@ -170,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
|
||||
@@ -178,184 +106,158 @@ URLs and redirects. Manifest profile identity, size, signature, portable
|
||||
paths and existing symlinks are checked before managed file writes.
|
||||
Hostile same-user TOCTOU is outside this protection; it is not an OS sandbox.
|
||||
|
||||
## Signed application updates (0.1.3)
|
||||
|
||||
`updater.rs` accepts only the fixed HTTPS feed
|
||||
`https://shacraft.ru/launcher/updates/stable.json`. A dedicated embedded Tauri
|
||||
public key authenticates both the metadata payload and the selected package.
|
||||
The signed metadata binds the plain stable version, release notes, date and
|
||||
platform URLs. Artifact URLs are confined to the matching version directory
|
||||
under `https://shacraft.ru/downloads/shacraft-launcher/`. The IPC never accepts
|
||||
a URL, key, destination path or replacement executable from the webview.
|
||||
|
||||
Metadata is downloaded once, with a 192 KiB envelope/64 KiB payload bound;
|
||||
packages are capped at 256 MiB. Redirects and version downgrades are rejected.
|
||||
The small vendored Tauri 2.11.0 `check_metadata` patch constructs its update
|
||||
object without another HTTP request. Linux AppImage installation uses a
|
||||
same-directory temporary file, signature verification, preserved permissions,
|
||||
atomic rename and file/directory fsync. Windows/macOS retain Tauri's platform
|
||||
installers. Unsupported Linux formats show manual installation instructions.
|
||||
|
||||
For installed deb packages, 0.1.4 selects only `linux-x86_64-deb`. The feed also
|
||||
retains the identical legacy `linux-x86_64` and explicit `linux-x86_64-appimage`
|
||||
AppImage entries so installed 0.1.3 readers remain compatible. Remote metadata
|
||||
cannot switch a deb installation into an AppImage installation.
|
||||
|
||||
`deb_updater.rs` checks root ownership of the installed executable and its
|
||||
parents and dpkg's ownership/version record. One `pkexec` invocation starts an
|
||||
early non-GUI mode of `/usr/bin/shacraft-launcher`; no password is collected by
|
||||
the launcher and no fallback prompt runs after cancellation. Bounded stdin
|
||||
framing carries the signed envelope and package bytes, never user paths.
|
||||
The helper authenticates both again as root, checks exact package identity
|
||||
`sha-craft-launcher`, architecture and version, stages the package under a
|
||||
root-only temporary directory and invokes the fixed dpkg installer. An explicit
|
||||
`--refuse-downgrade` protects against another installation winning the version
|
||||
race. Failed/partial package transactions require honest system-package recovery;
|
||||
they are not reported as completed or automatically retried. Restart launches
|
||||
the fixed installed executable even after dpkg replaces the running inode.
|
||||
|
||||
The native updater holds installation, account and game permits while installing
|
||||
and until restart. The game permit remains held until the launched Java child
|
||||
exits. These guards cover this launcher process, not other launcher instances.
|
||||
Startup checks never silently install; settings expose check, install, progress,
|
||||
errors and restart. A failed check does not prevent using the installed version.
|
||||
|
||||
The private updater key stays on the operator's computer. Normal CI builds are
|
||||
explicitly unsigned; reviewed release artifacts and metadata are signed locally
|
||||
and published only after signature/hash verification. The Caddy feed route uses
|
||||
`Cache-Control: no-store`. See [launcher-updates.md](launcher-updates.md) for
|
||||
the envelope contract, publisher commands and recovery constraints.
|
||||
|
||||
## Verification and distribution
|
||||
|
||||
`npm test` covers asynchronous helpers and state transitions;
|
||||
`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.
|
||||
CI packages are unsigned build artifacts. Signed updater publication is a
|
||||
separate local operator step. Native cold-install and launch tests are required
|
||||
before calling a platform release-ready.
|
||||
|
||||
The local admission checkpoint passed 64 Rust tests (5 live tests ignored),
|
||||
22 UI unit tests, TypeScript/Vite build and a Linux x86-64 release build with
|
||||
`npm run tauri:build -- --no-bundle`. Six browser scenarios with mocked Tauri
|
||||
IPC covered invalid nicknames, deleted sessions, reserved-name errors,
|
||||
successful claims, duplicate clicks and a session revoked during a claim.
|
||||
They also checked modal feedback, Escape preserving the settings drawer and
|
||||
the absence of legacy link polling. These checks used no production account
|
||||
or real Minecraft connection. The Linux output is a dynamically linked
|
||||
binary, not evidence of Windows/macOS support testing.
|
||||
|
||||
Version 0.1.2 also produced unsigned Linux amd64 AppImage and deb packages with
|
||||
`npm run tauri:build -- --bundles appimage,deb`. AppImage extraction and the
|
||||
deb's version/architecture metadata were checked without running the app or
|
||||
installing the package. The build host was Ubuntu 26.04; do not claim support
|
||||
for older Ubuntu releases from this build. For local AppImage packaging,
|
||||
linuxdeploy's GTK plugin needs `librsvg-2.0.pc` from the matching `librsvg2-dev`
|
||||
package. Extracting that package into a temporary build directory and setting
|
||||
`PKG_CONFIG_PATH` supplied the missing metadata without changing host packages.
|
||||
|
||||
The 0.1.3 updater checkpoint passed 76 native tests (6 live tests ignored),
|
||||
28 UI tests, 11 publisher tests with real minisign and TypeScript/Vite build.
|
||||
Nine browser scenarios used mocked IPC. The signed Linux AppImage/deb were
|
||||
published on 2026-09-10 with a signed stable feed; feed bytes, signatures and
|
||||
public HTTPS responses were verified. A separately invoked live native test
|
||||
downloaded the production release, rejected corrupted bytes without changing
|
||||
the old file, atomically updated a temporary copy of 0.1.2 and compared hashes.
|
||||
The original source AppImage was retained. The installed 0.1.3 AppImage was
|
||||
then started from `~/Applications` and its captured runtime paths verified.
|
||||
This is not a full GUI update/restart cycle or a Windows/macOS installation test.
|
||||
The Linux build host remains Ubuntu 26.04.
|
||||
|
||||
The 0.1.4 checkpoint passed 84 native tests (6 ignored), 32 UI tests and 18
|
||||
publisher tests; 12 browser scenarios use mocked IPC. In a disposable Ubuntu
|
||||
26.04 Docker container without network or production mounts, the actual signed
|
||||
deb helper passed 10 scenarios: unprivileged invocation, truncated/trailing
|
||||
input, damaged metadata/package, dpkg lock, unsafe temporary directory,
|
||||
successful installation, replay and downgrade refusal. The fixture installed
|
||||
the genuine old 0.1.3 package and bootstrapped the new verifier binary over its
|
||||
package record; it then installed the genuine signed 0.1.4. It did not relabel
|
||||
signed versions. Dpkg reported 0.1.4 and a fixture profile marker survived.
|
||||
This tests the elevated helper and dpkg, not a real desktop PolicyKit dialog.
|
||||
Cancellation/error rendering is covered by unit/browser scenarios. Published
|
||||
metadata and deb bytes match the locally verified files; both website download
|
||||
buttons target 0.1.4. No user host package installation was performed for QA.
|
||||
The live native AppImage smoke also passed against the published 0.1.4 feed,
|
||||
including corruption rejection and replacement of only a temporary source copy.
|
||||
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.
|
||||
|
||||
|
||||
## Cross-platform release 0.1.5 (2026-09-10)
|
||||
## Reconciliation and recovery
|
||||
|
||||
Published Windows x64 EXE/MSI, macOS aarch64 and x86_64 DMG/app.tar.gz,
|
||||
and Linux x64 AppImage/DEB on https://shacraft.ru/help#launcher. The signed
|
||||
stable feed includes all platforms plus the legacy/exact Linux aliases.
|
||||
CI source b43fc610c43a9ec9f5f3ffce601de9604670ff8a, successful Actions run
|
||||
https://github.com/emil28092005/shacraft-launcher/actions/runs/34512683651.
|
||||
An initial non-Linux borrow/move compilation error in updater target selection
|
||||
was fixed before the final build. Native tests: Windows70, macOS74 per arch,
|
||||
Linux84; UI tests pass on all four runners. Local checks verify macOS bundle
|
||||
version/CPU type, Linux package identity and every artifact signature. Public
|
||||
HTTPS downloads of all eight artifacts match local SHA-256. Website418 tests
|
||||
plus48 subtests pass; only backend recreated, game containers unchanged.
|
||||
Updater signatures use the existing operator-held key, never uploaded to CI or
|
||||
server. Windows installers have no Authenticode signature; macOS is not Apple
|
||||
notarized. Native CI tests and packaging do not certify full Minecraft installs
|
||||
or desktop updater/restart behavior on Windows/macOS. Older unsupported clients
|
||||
need a manual installation of the current release. Previous releases immutable.
|
||||
`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.
|
||||
|
||||
The live native updater test passed against the published 0.1.5 feed: verified download, corrupted-byte rejection and replacement of only a temporary AppImage copy. The user-installed launcher was not modified.
|
||||
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.
|
||||
|
||||
|
||||
## Admission client menu 0.1.1 (2026-09-11)
|
||||
## Launcher self-update boundary (0.2.0)
|
||||
|
||||
The signed Aeronautics payload now contains admission mod 0.1.1 at the existing
|
||||
managed path `mods/shacraft-admission-0.1.0.jar` to prevent duplicate mod IDs on
|
||||
upgrade. SHA-256: `faa9ae13cb0f2d93c03dae26ab36ae20d3fb6b66c89c09254b16808d6b183f89`.
|
||||
From the title screen (and vanilla safety acknowledgement), Multiplayer connects
|
||||
to fixed `135.106.154.86:25567`. Cancel/errors return to TitleScreen; transitions
|
||||
from other screens do not auto-connect. Client-only registration, protocol 1 and
|
||||
one-use admission remain unchanged. Running game server was not restarted.
|
||||
Linux Java 21 build and 8 mod tests pass. An opt-in native live test verifies
|
||||
signed-manifest retrieval and download/repair/restoration of the admission jar
|
||||
only in a temporary directory. Mac 0.1.5 connection failure remains unclassified
|
||||
pending exact error/log; this is not a verified macOS fix or desktop UI test.
|
||||
`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.
|
||||
|
||||
## Minigames alongside Aeronautics (2026-09-13)
|
||||
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.
|
||||
|
||||
The new native allowlist adds profile `minigames` at
|
||||
`https://shacraft.ru/api/launcher/v2/profiles/minigames/signed-manifest` and
|
||||
its display-only count at `https://shacraft.ru/api/online/minigames`.
|
||||
Aeronautics remains a separate profile and keeps its previous managed files.
|
||||
Minigames uses Minecraft 26.2, Fabric Loader 0.19.5, Java 25 and its own
|
||||
`profiles/minigames` game directory. The signed payload supplies Fabric API
|
||||
0.160.0+26.2 and `mods/shacraft-admission-client-0.1.0.jar`; it never supplies
|
||||
Paper, the server plugins, maps, credentials or game-download URLs.
|
||||
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.
|
||||
|
||||
`fabric.rs` obtains an exact parent/loader profile from fixed Fabric metadata,
|
||||
checks its identity and KnotClient entry point, and converts bounded Maven
|
||||
library entries into the existing verified library contract. Each artifact
|
||||
URL is constructed from a validated coordinate below fixed Fabric Maven;
|
||||
metadata-supplied alternative origins are rejected. SHA-1 and size come from
|
||||
Fabric metadata, or the same Maven's hash sidecar and HEAD for the loader jar.
|
||||
Java provisioning already accepts exactly Java 25. Automatic installation on
|
||||
a platform still requires a real cold-install check on that platform.
|
||||
`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.
|
||||
|
||||
Both profiles claim/display the canonical `aoc` nickname. Only the native
|
||||
profile mapping determines ticket `server_id` (`aoc` or `minigames`), and the
|
||||
response must match that exact server. The backend is responsible for shared
|
||||
access checks at both issuance and redemption. Existing account sessions and
|
||||
settings need no migration. No copied subscription/whitelist grant is trusted.
|
||||
|
||||
Minigames adds native `--quickPlayMultiplayer 135.106.154.86:25568` at launch.
|
||||
The client-only Fabric companion validates the actual socket target and signs
|
||||
the existing configuration challenge with `minigames` in the transcript.
|
||||
Paper performs verification before entry; its early duplicate UUID gate must
|
||||
run before vanilla would evict the existing player. There is no proxy and no
|
||||
client-side shared secret. One ticket is used for one game connection; a fresh
|
||||
launcher start is required after expiry, consumption or a failed proof attempt.
|
||||
|
||||
The integration is staged until server authentication, signed profile payload,
|
||||
and a newer signed launcher release are deployed and checked together. The
|
||||
existing public 0.1.5 binary cannot select the new profile by a website-only
|
||||
catalog change. Preserve both catalog entries when publishing either profile.
|
||||
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.
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
# Signed launcher updates
|
||||
|
||||
The application updater is separate from the signed Aeronautics modpack
|
||||
manifest. Its only metadata endpoint is
|
||||
`https://shacraft.ru/launcher/updates/stable.json`. Its artifact URLs are confined
|
||||
to `https://shacraft.ru/downloads/shacraft-launcher/<version>/<filename>`.
|
||||
The webview cannot choose a URL, signing key or executable path. The native
|
||||
updater verifies signatures before installing; an unavailable or invalid feed
|
||||
does not prevent playing with the installed launcher.
|
||||
|
||||
The first version containing the updater must be installed manually. Version
|
||||
0.1.2 has no code capable of installing this feature itself. AppImage supports
|
||||
self-updates from 0.1.3; deb adds them in 0.1.4. An existing 0.1.3 deb therefore
|
||||
needs one manual upgrade to 0.1.4 before its own update button can work. A deb
|
||||
installation keeps its package format and asks for system administrator
|
||||
authorization when installing an update. The launcher itself runs as the normal
|
||||
user. Development binaries use the manual download path. Windows/macOS
|
||||
publication and actual
|
||||
installation tests remain separate release work; supporting a platform in the
|
||||
feed schema does not certify a working release on it.
|
||||
|
||||
## Release-line compatibility
|
||||
|
||||
The archived 2026-09-09 review bundle at
|
||||
`/home/emil/Desktop/shacraft-updater-review/README.md` describes a different,
|
||||
unreleased updater prototype: GitHub-hosted `latest.json`, a different pinned
|
||||
key and the former LoginSystem/Game Bridge proof flow. Its successful CI and
|
||||
prototype version numbers do not establish compatibility with the deployed
|
||||
admission protocol. The 0.1.3 and 0.1.4 releases follow the deployed 0.1.2
|
||||
admission line based on `bf43254`, using the ShaCraft-hosted feed described here.
|
||||
|
||||
Never publish the archived `CI-NOT-FOR-RELEASE`/`CI_NOT_FOR_RELEASE` packages or
|
||||
substitute the unreleased 0.2.0 prototype for an admission-compatible release.
|
||||
Do not merge its updater key, endpoint or account flow blindly: that can break
|
||||
both update continuity and server login. Future reconciliation requires an
|
||||
explicit compatibility review retaining admission support and the key/feed
|
||||
contract already distributed to players, or a separately designed migration.
|
||||
|
||||
## Authentication contract
|
||||
|
||||
The public key embedded in the application is a **dedicated Tauri updater
|
||||
key**, separate from the existing modpack manifest key. Tauri's minisign format
|
||||
wraps the entire minisign public-key/signature text in standard base64. The
|
||||
contents of a `.sig` file belong in metadata, not its filename or URL.
|
||||
|
||||
The stable feed contains the normal Tauri fields `version`, `notes`, `pub_date`
|
||||
and `platforms`, and two additional fields:
|
||||
|
||||
- `signedPayload`: standard base64 of the exact UTF-8 JSON bytes containing
|
||||
only the four normal fields. The publisher produces these bytes with sorted
|
||||
keys, compact separators, literal UTF-8 and no trailing newline.
|
||||
- `metadataSignature`: the Tauri `.sig` contents for those exact payload bytes,
|
||||
signed with the same updater key that signs the application packages.
|
||||
|
||||
The launcher authenticates the payload, requires it to equal the visible
|
||||
fields and then selects the signed platform artifact. This also authenticates
|
||||
the version and artifact URL: an old signed installer cannot be relabelled as
|
||||
a newer release by modifying unsigned metadata. Every artifact is separately
|
||||
verified through Tauri's built-in updater signature check. The current version
|
||||
must increase; there is no unsigned or automatic downgrade fallback.
|
||||
|
||||
The stable publisher accepts only plain `MAJOR.MINOR.PATCH` versions and these
|
||||
platforms: `linux-x86_64` (legacy `.AppImage`), `linux-x86_64-appimage`
|
||||
(`.AppImage`), `linux-x86_64-deb` (`.deb`), `windows-x86_64` (`.exe` or `.msi`),
|
||||
`darwin-x86_64` and `darwin-aarch64` (`.app.tar.gz`). Artifact filenames contain
|
||||
only ASCII letters, digits, dots, underscores and hyphens. Files must already
|
||||
exist in the matching version directory, must not be symlinks and must be
|
||||
between 1 byte and 256 MiB. A platform without a tested signed artifact is
|
||||
omitted, never represented by an empty signature or another platform's file.
|
||||
|
||||
Format-aware Linux feeds must contain both AppImage keys with exactly the same
|
||||
URL and signature. This keeps 0.1.3 clients on their original AppImage path.
|
||||
New AppImage clients prefer `linux-x86_64-appimage` and can read the legacy key;
|
||||
deb clients require `linux-x86_64-deb` and never fall back to an AppImage.
|
||||
Preserve all three entries when publishing a release that supports both formats.
|
||||
|
||||
After verifying each signature, the publisher also checks Linux package format.
|
||||
AppImage must have the ELF64 little-endian x86_64 and type-2 AppImage header.
|
||||
For deb, `/usr/bin/dpkg-deb` must report package `sha-craft-launcher`, architecture
|
||||
`amd64` and the exact signed release version. Inspection uses fixed arguments,
|
||||
no shell, a cleared environment, a 10-second timeout and a 4 KiB output limit.
|
||||
It does not install a package or execute its maintainer scripts. This protects
|
||||
against accidental publication of the wrong signed package; an installer
|
||||
signature remains mandatory and is checked before package inspection.
|
||||
|
||||
## Debian installation boundary
|
||||
|
||||
The installed launcher must be `/usr/bin/shacraft-launcher`, owned by root in
|
||||
root-owned directories that other users cannot write. The package database
|
||||
must assign that file to an installed `sha-craft-launcher` of the expected
|
||||
architecture. The updater needs the system `pkexec` authorization agent; it
|
||||
does not collect a password or fall back to running a shell with privileges.
|
||||
|
||||
After the normal-user downloader verifies the update, `pkexec` launches the
|
||||
fixed installed binary with `--shacraft-install-deb`. This mode runs before
|
||||
Tauri/GTK initialization. It accepts only length-bounded signed metadata and
|
||||
package bytes over stdin, never a user-provided package path. The root helper
|
||||
independently verifies the metadata, selects only the exact deb target, checks
|
||||
the package signature and requires a higher version than the current dpkg
|
||||
database. It writes the verified bytes to a root-created mode-0700 temporary
|
||||
directory under the validated `/var/tmp`; the file has mode 0600.
|
||||
|
||||
The helper checks the package's exact name, version and architecture with
|
||||
`dpkg-deb`, then invokes fixed `dpkg --refuse-downgrade --install` arguments in
|
||||
an environment without inherited variables. Dpkg's own downgrade refusal
|
||||
protects against a competing newer installation between the version check and
|
||||
the package-manager lock. A successful result also requires the package
|
||||
database to report the intended version as installed. The temporary package
|
||||
is removed on completion. A signed deb may include maintainer scripts, which
|
||||
dpkg runs with administrator privileges as part of normal installation: review
|
||||
release package contents before signing.
|
||||
|
||||
Cancellation of system authorization, missing authorization support, signature
|
||||
rejection, a busy package manager and installation failure have distinct
|
||||
messages. There is no automatic retry with weaker checks. Dpkg installation
|
||||
is not an atomic file replacement: dependency/configuration failures or power
|
||||
loss can require normal package-manager recovery. The launcher reports failure
|
||||
instead of claiming the old installation is intact. Successful deb updates
|
||||
restart the fixed installed binary as the ordinary user. These guarantees
|
||||
are separate from AppImage's same-directory atomic replacement.
|
||||
|
||||
## Keys and builds
|
||||
|
||||
The production private key stays **only on the operator's local machine** at
|
||||
`/home/emil/.local/share/shacraft-updater/production.key`, with owner-only
|
||||
permissions. Its public companion is `production.key.pub`. Never transfer the
|
||||
private key to the web server, GitHub, CI, logs, chat, a package or a public
|
||||
artifact. Signing commands below pass the local path, not the key contents.
|
||||
Keep a protected operator-controlled backup: replacing or losing the key will
|
||||
break continuity for installations trusting the existing public key. There is
|
||||
no automatic key rotation mechanism in this release.
|
||||
|
||||
Normal `build.yml` jobs explicitly merge `scripts/tauri-unsigned.json` to disable
|
||||
updater signing. They upload ordinary packages and unsigned macOS `.app.tar.gz`
|
||||
archives. CI does not receive the production key and does not publish the
|
||||
stable feed. A release operator reviews/tests these build artifacts, then signs
|
||||
the chosen packages locally. For a signed local Tauri bundle build, set
|
||||
`TAURI_SIGNING_PRIVATE_KEY` to the protected key path; never disable verification
|
||||
in the application to make a build pass.
|
||||
|
||||
Updater signatures authenticate ShaCraft's update channel. They are separate
|
||||
from Windows Authenticode, Apple signing/notarization, and Linux distribution
|
||||
package signatures; passing updater checks does not establish those assurances.
|
||||
|
||||
## Local preparation and signing
|
||||
|
||||
The publisher requires Python 3.10+ and `minisign`; releases containing deb also
|
||||
require `/usr/bin/dpkg-deb` (Debian/Ubuntu's `dpkg` package). It performs verification
|
||||
through the standard minisign CLI, without implementing cryptography in Python.
|
||||
`--minisign /absolute/path/to/minisign` supports a locally extracted tool without
|
||||
installing a global package. Run these examples from the launcher repository,
|
||||
substituting the actual release version and tested filenames.
|
||||
|
||||
1. Stage immutable, tested packages below a local downloads root. The following
|
||||
example assumes both tested Linux artifacts already exist below
|
||||
`/tmp/shacraft-release/downloads/0.1.4/`
|
||||
and release notes exist at `/tmp/shacraft-release/notes.txt`. Create signatures
|
||||
with the Tauri CLI; `.sig` is written beside each artifact:
|
||||
|
||||
```bash
|
||||
npm run tauri -- signer sign \
|
||||
--private-key-path /home/emil/.local/share/shacraft-updater/production.key \
|
||||
/tmp/shacraft-release/downloads/0.1.4/ShaCraft.Launcher_0.1.4_amd64.AppImage
|
||||
npm run tauri -- signer sign \
|
||||
--private-key-path /home/emil/.local/share/shacraft-updater/production.key \
|
||||
/tmp/shacraft-release/downloads/0.1.4/ShaCraft.Launcher_0.1.4_amd64.deb
|
||||
```
|
||||
|
||||
2. Prepare a deterministic payload after verifying every package signature.
|
||||
Repeat `--artifact PLATFORM=FILENAME` for each tested platform included in this
|
||||
release. Keep the legacy AppImage alias. Do not list a dmg, nonexistent
|
||||
package or untested architecture:
|
||||
|
||||
```bash
|
||||
python3 scripts/publish_launcher_update.py prepare \
|
||||
--version 0.1.4 \
|
||||
--downloads-root /tmp/shacraft-release/downloads \
|
||||
--artifact linux-x86_64=ShaCraft.Launcher_0.1.4_amd64.AppImage \
|
||||
--artifact linux-x86_64-appimage=ShaCraft.Launcher_0.1.4_amd64.AppImage \
|
||||
--artifact linux-x86_64-deb=ShaCraft.Launcher_0.1.4_amd64.deb \
|
||||
--notes-file /tmp/shacraft-release/notes.txt \
|
||||
--public-key /home/emil/.local/share/shacraft-updater/production.key.pub \
|
||||
--payload /tmp/shacraft-release/release.payload.json
|
||||
```
|
||||
|
||||
3. Inspect the payload and sign its exact bytes locally:
|
||||
|
||||
```bash
|
||||
npm run tauri -- signer sign \
|
||||
--private-key-path /home/emil/.local/share/shacraft-updater/production.key \
|
||||
/tmp/shacraft-release/release.payload.json
|
||||
```
|
||||
|
||||
Editing notes, timestamps, versions, signatures or URLs after this step
|
||||
invalidates the metadata signature. Prepare and sign again after any change.
|
||||
|
||||
## Publication
|
||||
|
||||
Upload **only** the packages, their `.sig` files, `release.payload.json`, its
|
||||
`.sig`, the public key and the publisher script. Stage and hash-check artifacts
|
||||
before publishing metadata. Production paths are:
|
||||
|
||||
- Downloads root: `/root/shacraft/caddy/www/downloads/shacraft-launcher`.
|
||||
- Stable feed: `/root/shacraft/data/launcher/updates/stable.json`.
|
||||
- Public feed: `https://shacraft.ru/launcher/updates/stable.json`.
|
||||
|
||||
Keep previous version directories immutable and save the current feed before
|
||||
replacing it. Run the publisher on the host with a public-key file and minisign
|
||||
available there. Neither operation needs a private key:
|
||||
|
||||
```bash
|
||||
python3 publish_launcher_update.py publish \
|
||||
--downloads-root /root/shacraft/caddy/www/downloads/shacraft-launcher \
|
||||
--public-key /path/to/production.key.pub \
|
||||
--payload /path/to/release.payload.json \
|
||||
--signature /path/to/release.payload.json.sig \
|
||||
--output /root/shacraft/data/launcher/updates/stable.json \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
After that succeeds, repeat without `--dry-run`. The publisher verifies metadata
|
||||
and all artifacts under the public key, authenticates the previous feed before
|
||||
comparing versions, and refuses same-version replacement or downgrade. It holds
|
||||
an exclusive publication lock and writes/fsyncs a temporary sibling before
|
||||
atomically replacing `stable.json`. Dry-run validates everything but does not
|
||||
replace the feed. Do not change staged artifacts concurrently with publication.
|
||||
Do not overwrite a released version to add another platform: publish a higher
|
||||
version containing the complete intended platform set.
|
||||
|
||||
Alternatively, run the same verification locally against byte-for-byte copies
|
||||
of the current feed and staged downloads, then deploy the resulting feed only
|
||||
after checking uploaded package and metadata hashes against those validated
|
||||
files. An initial publication has no previous feed; subsequent publications
|
||||
must validate against the actual deployed feed, not an empty staging directory.
|
||||
|
||||
Caddy should serve this feed as JSON with `Cache-Control: no-store`. Check the
|
||||
public response, decoded metadata, signatures and downloadable artifact hashes
|
||||
after deployment. Exercise a real installed AppImage updating to a higher
|
||||
version, including relaunch and retained settings/account state. For deb, also
|
||||
exercise administrator cancellation, package-manager lock conflicts, failed
|
||||
installation and a successful package upgrade/relaunch. Use an isolated system
|
||||
for destructive package-manager failure cases; never modify player data as a
|
||||
test fixture. Unit tests, packaging or a browser mock alone do not establish
|
||||
successful installation or distribution compatibility.
|
||||
If a release is faulty, stop offering it and publish a corrected higher version;
|
||||
do not weaken signature checks or silently downgrade users.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
python3 -m unittest discover -s scripts -p 'test_*.py'
|
||||
```
|
||||
|
||||
Publisher tests exercise the real minisign CLI with temporary test keys,
|
||||
including valid publication, modified packages and metadata, authenticated
|
||||
previous-version checks, downgrade refusal, URL/path restrictions and dry-run.
|
||||
Linux tests also build real temporary deb packages with `dpkg-deb`, validate
|
||||
package/version/architecture, ensure inspection never executes maintainer
|
||||
scripts, retain the legacy AppImage feed alias, and reject malformed signed
|
||||
Linux packages. Package-inspection output and time bounds are exercised.
|
||||
No test private key is checked into the repository. CI installs minisign so
|
||||
the signature tests run; locally they explicitly skip if the tool is absent.
|
||||
Set `SHACRAFT_TEST_MINISIGN` to use an extracted executable.
|
||||
|
||||
The artifact formats and signature encoding follow the
|
||||
[official Tauri updater documentation](https://v2.tauri.app/plugin/updater/).
|
||||
@@ -1,20 +0,0 @@
|
||||
## Cross-platform release 0.1.5 (2026-09-10)
|
||||
|
||||
Published Windows x64 EXE/MSI, macOS aarch64 and x86_64 DMG/app.tar.gz,
|
||||
and Linux x64 AppImage/DEB on https://shacraft.ru/help#launcher. The signed
|
||||
stable feed includes all platforms plus the legacy/exact Linux aliases.
|
||||
CI source b43fc610c43a9ec9f5f3ffce601de9604670ff8a, successful Actions run
|
||||
https://github.com/emil28092005/shacraft-launcher/actions/runs/34512683651.
|
||||
An initial non-Linux borrow/move compilation error in updater target selection
|
||||
was fixed before the final build. Native tests: Windows70, macOS74 per arch,
|
||||
Linux84; UI tests pass on all four runners. Local checks verify macOS bundle
|
||||
version/CPU type, Linux package identity and every artifact signature. Public
|
||||
HTTPS downloads of all eight artifacts match local SHA-256. Website418 tests
|
||||
plus48 subtests pass; only backend recreated, game containers unchanged.
|
||||
Updater signatures use the existing operator-held key, never uploaded to CI or
|
||||
server. Windows installers have no Authenticode signature; macOS is not Apple
|
||||
notarized. Native CI tests and packaging do not certify full Minecraft installs
|
||||
or desktop updater/restart behavior on Windows/macOS. Older unsupported clients
|
||||
need a manual installation of the current release. Previous releases immutable.
|
||||
|
||||
The live native updater test passed against the published 0.1.5 feed: verified download, corrupted-byte rejection and replacement of only a temporary AppImage copy. The user-installed launcher was not modified.
|
||||
@@ -1,117 +0,0 @@
|
||||
# Launcher 0.1.6 release
|
||||
|
||||
Adds a separate Minigames profile (Minecraft 26.2 / Fabric 0.19.5 / Java 25)
|
||||
while keeping Aeronautics and its installed profile intact. Both use the
|
||||
existing canonical aoc nickname and shared access. Tickets remain bound to
|
||||
the selected server. Minigames connects to 135.106.154.86:25568 and proves
|
||||
account admission during configuration before world entry.
|
||||
|
||||
Local checks: 87 native tests, 33 UI tests, TypeScript/Vite, three Java client
|
||||
proof tests, an official Fabric metadata resolution check and the Linux native
|
||||
release build. The [real Fabric/Paper smoke](verification/minigames-fabric-2026-09-13.json)
|
||||
passed with a synthetic account: the unchanged production companion entered
|
||||
the lobby and the backend consumed its Minigames ticket. Published on 2026-09-13:
|
||||
all four cross-platform jobs succeeded in [build run 34778218510](https://github.com/emil28092005/shacraft-launcher/actions/runs/34778218510)
|
||||
at runtime source `799fa692ef5e775f0044fe300f65fd4564771d47`.
|
||||
[Checks run 34778334417](https://github.com/emil28092005/shacraft-launcher/actions/runs/34778334417)
|
||||
passed at `5b741771b4668a834a2c5b757379340f11b8ae3a`; the only difference is the
|
||||
CI job that runs all 18 publisher signature tests on Ubuntu 24.04. The CI Fabric
|
||||
jar exactly matches the one used by the real client smoke.
|
||||
|
||||
All eight packages were signed locally, verified again on the server, and
|
||||
published after a successful dry-run. Full public HTTPS downloads match the
|
||||
recorded SHA-256 values; package signatures and the public feed signature pass.
|
||||
The feed payload equals the locally signed canonical bytes. See the
|
||||
[publication receipt](verification/launcher-release-0.1.6.json). The previous
|
||||
0.1.5 feed was backed up; its packages remain unchanged. The private key stayed
|
||||
on the operator workstation.
|
||||
|
||||
## Build contract
|
||||
|
||||
Git remote: `git@github.com:emil28092005/shacraft-launcher.git`.
|
||||
The production admission line is branch `codex/launcher-updater`; `main` is a
|
||||
divergent unreleased prototype and must remain unchanged for this release.
|
||||
Push the reviewed commit to the production branch, then dispatch
|
||||
`gh workflow run build.yml --ref codex/launcher-updater --repo emil28092005/shacraft-launcher`.
|
||||
The check workflow runs on branch pushes. Verify each run's head SHA equals the
|
||||
reviewed commit before downloading artifacts. A main push also triggers the
|
||||
build matrix, but was not the publication path for this release.
|
||||
CI publishes unsigned artifacts named:
|
||||
|
||||
- `shacraft-launcher-linux-x64`: AppImage and deb.
|
||||
- `shacraft-launcher-windows-x64`: NSIS exe and MSI.
|
||||
- `shacraft-launcher-macos-arm64`: aarch64 app.tar.gz and DMG.
|
||||
- `shacraft-launcher-macos-x64`: x86_64 app.tar.gz and DMG.
|
||||
|
||||
`.github/workflows/check.yml` additionally tests the Java 25 admission client
|
||||
and uploads `shacraft-admission-client`. It has no production credentials.
|
||||
Download the artifacts from the checked run at the exact reviewed commit with
|
||||
`gh run download RUN_ID --repo emil28092005/shacraft-launcher --dir STAGING`.
|
||||
|
||||
## Signing and publication
|
||||
|
||||
Stage renamed ASCII filenames below a local downloads root, for example
|
||||
`/tmp/shacraft-release-0.1.6/downloads/0.1.6/`. Preserve already published
|
||||
0.1.5 bytes. Expected updater filenames:
|
||||
|
||||
- `ShaCraft.Launcher_0.1.6_amd64.AppImage`
|
||||
- `ShaCraft.Launcher_0.1.6_amd64.deb`
|
||||
- `ShaCraft.Launcher_0.1.6_x64-setup.exe`
|
||||
- `ShaCraft.Launcher_0.1.6_aarch64.app.tar.gz`
|
||||
- `ShaCraft.Launcher_0.1.6_x86_64.app.tar.gz`
|
||||
|
||||
MSI and DMG are additional manual downloads; the updater uses EXE and app.tar.gz.
|
||||
Inspect package versions, architecture and contents before signing. Sign each
|
||||
chosen file with the existing local operator key:
|
||||
|
||||
```bash
|
||||
npm run tauri -- signer sign --private-key-path /home/emil/.local/share/shacraft-updater/production.key ARTIFACT
|
||||
```
|
||||
|
||||
The key file is mode 0600 and remains local. Never read its contents into logs,
|
||||
copy it to CI/server or substitute a different signing identity. Existing
|
||||
`production.key.pub` is sufficient for every later verification/publication.
|
||||
|
||||
After creating a UTF-8 release notes file, prepare the payload:
|
||||
|
||||
```bash
|
||||
python3 scripts/publish_launcher_update.py prepare \
|
||||
--version 0.1.6 \
|
||||
--downloads-root /tmp/shacraft-release-0.1.6/downloads \
|
||||
--artifact linux-x86_64=ShaCraft.Launcher_0.1.6_amd64.AppImage \
|
||||
--artifact linux-x86_64-appimage=ShaCraft.Launcher_0.1.6_amd64.AppImage \
|
||||
--artifact linux-x86_64-deb=ShaCraft.Launcher_0.1.6_amd64.deb \
|
||||
--artifact windows-x86_64=ShaCraft.Launcher_0.1.6_x64-setup.exe \
|
||||
--artifact darwin-aarch64=ShaCraft.Launcher_0.1.6_aarch64.app.tar.gz \
|
||||
--artifact darwin-x86_64=ShaCraft.Launcher_0.1.6_x86_64.app.tar.gz \
|
||||
--notes-file /tmp/shacraft-release-0.1.6/notes.txt \
|
||||
--public-key /home/emil/.local/share/shacraft-updater/production.key.pub \
|
||||
--payload /tmp/shacraft-release-0.1.6/release.payload.json
|
||||
npm run tauri -- signer sign \
|
||||
--private-key-path /home/emil/.local/share/shacraft-updater/production.key \
|
||||
/tmp/shacraft-release-0.1.6/release.payload.json
|
||||
```
|
||||
|
||||
Upload only public packages, signatures, payload, public key and publisher.
|
||||
Server downloads root is `/root/shacraft/caddy/www/downloads/shacraft-launcher`;
|
||||
public artifact URLs are `https://shacraft.ru/downloads/shacraft-launcher/0.1.6/`
|
||||
followed by the checked filename. Preserve the old feed before publication.
|
||||
With the exact staged server paths, run the existing publisher first with
|
||||
`--dry-run`, then without it:
|
||||
|
||||
```bash
|
||||
python3 publish_launcher_update.py publish \
|
||||
--downloads-root /root/shacraft/caddy/www/downloads/shacraft-launcher \
|
||||
--public-key PUBLIC_KEY_FILE \
|
||||
--payload SIGNED_PAYLOAD_FILE \
|
||||
--signature PAYLOAD_SIGNATURE_FILE \
|
||||
--output /root/shacraft/data/launcher/updates/stable.json \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
Publication depends on the signed Minigames profile containing the final client
|
||||
and Fabric API jars, the healthy Paper admission gate, and the shared-access
|
||||
backend endpoints being available. Verify public HTTPS package hashes, feed
|
||||
signatures and an actual client admission before updating the website buttons.
|
||||
OS Authenticode/Apple notarization and cold installations on other platforms
|
||||
remain distinct from successful native CI/builds.
|
||||
@@ -0,0 +1,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.
|
||||
@@ -1,84 +0,0 @@
|
||||
{
|
||||
"recorded_at": "2026-09-13T20:03:46.913196+00:00",
|
||||
"version": "0.1.6",
|
||||
"source_sha": "799fa692ef5e775f0044fe300f65fd4564771d47",
|
||||
"branch": "codex/launcher-updater",
|
||||
"build_run": 34778218510,
|
||||
"checks_run": 34778334417,
|
||||
"stable_feed": "https://shacraft.ru/launcher/updates/stable.json",
|
||||
"feed_sha256": "342876aee8760a984421889cec1453dd642ebf988ab2bad27f23ea4f4f203f8d",
|
||||
"feed_signature_verified": true,
|
||||
"feed_payload_equals_locally_signed_payload": true,
|
||||
"platforms": [
|
||||
"darwin-aarch64",
|
||||
"darwin-x86_64",
|
||||
"linux-x86_64",
|
||||
"linux-x86_64-appimage",
|
||||
"linux-x86_64-deb",
|
||||
"windows-x86_64"
|
||||
],
|
||||
"artifacts": [
|
||||
{
|
||||
"artifact": "ShaCraft.Launcher_0.1.6_amd64.AppImage",
|
||||
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.6/ShaCraft.Launcher_0.1.6_amd64.AppImage",
|
||||
"size": 83274232,
|
||||
"sha256": "fa01905baddebd08b59ab7855009f0b29df02fcd3813941a0190f5135ff58f7c",
|
||||
"public_signature_verified": true
|
||||
},
|
||||
{
|
||||
"artifact": "ShaCraft.Launcher_0.1.6_amd64.deb",
|
||||
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.6/ShaCraft.Launcher_0.1.6_amd64.deb",
|
||||
"size": 5997818,
|
||||
"sha256": "01621fde16f6fc1ca453acffb1d22d664c8d8b60909d22a8aa2af10ac80d8737",
|
||||
"public_signature_verified": true
|
||||
},
|
||||
{
|
||||
"artifact": "ShaCraft.Launcher_0.1.6_x64-setup.exe",
|
||||
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.6/ShaCraft.Launcher_0.1.6_x64-setup.exe",
|
||||
"size": 3793645,
|
||||
"sha256": "0bb2a577d88d4387f23efcd28b19bf83e373b1b10c5cbbb5d6e0f69428d9f9ab",
|
||||
"public_signature_verified": true
|
||||
},
|
||||
{
|
||||
"artifact": "ShaCraft.Launcher_0.1.6_x64_en-US.msi",
|
||||
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.6/ShaCraft.Launcher_0.1.6_x64_en-US.msi",
|
||||
"size": 5554176,
|
||||
"sha256": "8a53a9d8d6dbdd98238b294df604fe5050375e64709dfdff942bf60fcd61519b",
|
||||
"public_signature_verified": true
|
||||
},
|
||||
{
|
||||
"artifact": "ShaCraft.Launcher_0.1.6_aarch64.app.tar.gz",
|
||||
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.6/ShaCraft.Launcher_0.1.6_aarch64.app.tar.gz",
|
||||
"size": 5876796,
|
||||
"sha256": "c113afc0794eaa6788cb994e7d84f821d22573d559f3cddfb0bcd5c2cad557cf",
|
||||
"public_signature_verified": true
|
||||
},
|
||||
{
|
||||
"artifact": "ShaCraft.Launcher_0.1.6_aarch64.dmg",
|
||||
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.6/ShaCraft.Launcher_0.1.6_aarch64.dmg",
|
||||
"size": 6420101,
|
||||
"sha256": "0391a1d4afa6cb21663f1f8de367ecdd68a51656db48e86ad0bec617270c6036",
|
||||
"public_signature_verified": true
|
||||
},
|
||||
{
|
||||
"artifact": "ShaCraft.Launcher_0.1.6_x86_64.app.tar.gz",
|
||||
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.6/ShaCraft.Launcher_0.1.6_x86_64.app.tar.gz",
|
||||
"size": 6032564,
|
||||
"sha256": "ee025cd13af1887334d0af5a7e04259eddd6339fe8509b108e0b3ac0e7268daf",
|
||||
"public_signature_verified": true
|
||||
},
|
||||
{
|
||||
"artifact": "ShaCraft.Launcher_0.1.6_x64.dmg",
|
||||
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.6/ShaCraft.Launcher_0.1.6_x64.dmg",
|
||||
"size": 6591340,
|
||||
"sha256": "cd54c8f975bb7a1ec587bbadde35caf4a3eb9a1f86182150310590e41dec4023",
|
||||
"public_signature_verified": true
|
||||
}
|
||||
],
|
||||
"previous_feed_version": "0.1.5",
|
||||
"previous_feed_sha256": "d81ab2b007f7f9a385f2ec28d17f204e75c0c2e92cf40931064dd66d3bd2898f",
|
||||
"previous_feed_backup": "/root/shacraft/.release-staging/launcher-0.1.6/stable.before-0.1.6.json",
|
||||
"private_key_location": "remains on operator workstation; not uploaded",
|
||||
"remote_publication": "all8 local signatures verified, remote hashes/signatures verified, dry-run passed, atomic publish passed",
|
||||
"limitations": "Cross-platform CI and package inspection passed. Windows/macOS cold installation and OS signing/notarization were not exercised."
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"recorded_at": "2026-09-13T19:32:59.436884+00:00",
|
||||
"result": "joined_world",
|
||||
"client": "Minecraft 26.2 / Fabric Loader 0.19.5 / Fabric API 0.160.0+26.2",
|
||||
"java": "25.0.2",
|
||||
"client_jar_sha256": "3335626b7c8fdd233e8531ad382594398c7df48842b1b919934d1154dd4ba8f1",
|
||||
"client_jar_size": 10667,
|
||||
"profile": "minigames",
|
||||
"synthetic_player": "AdmissionPilot",
|
||||
"endpoint": "127.0.0.1:25608",
|
||||
"environment": "isolated Xvfb :95; separate temporary game directory; synthetic account/ticket; explicit local socket test flag",
|
||||
"server_evidence": [
|
||||
"[22:32:02 INFO]: AdmissionPilot joined the game",
|
||||
"[22:32:02 INFO]: AdmissionPilot[/127.0.0.1:54602] logged in with entity id 77 at ([minecraft:shacraft_lobby_v2]0.5, 96.0, 43.5)"
|
||||
],
|
||||
"verified": [
|
||||
"production companion jar bytes unchanged",
|
||||
"queued minecraft:register during configuration",
|
||||
"server-bound Ed25519 response accepted",
|
||||
"actual vanilla client entered lobby world"
|
||||
],
|
||||
"not_tested": [
|
||||
"production public endpoint login",
|
||||
"Windows/macOS cold installation",
|
||||
"full GUI launcher install/account flow"
|
||||
],
|
||||
"notes": "Offline client emits Microsoft profile-certificate HTTP401; admission and world entry succeeded. Real account/session/password not used.",
|
||||
"backend_evidence": {
|
||||
"source": "backend agent read-only synthetic SQLite query",
|
||||
"issued_minigames_tickets_for_player": 1,
|
||||
"consumed_minigames_tickets_for_player": 1
|
||||
}
|
||||
}
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "shacraft-launcher-ui",
|
||||
"version": "0.1.6",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "shacraft-launcher-ui",
|
||||
"version": "0.1.6",
|
||||
"version": "0.2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "2.11.1",
|
||||
"lucide-react": "1.41.0",
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "shacraft-launcher-ui",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"version": "0.1.6",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
__pycache__/
|
||||
@@ -1,360 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Prepare and atomically publish a signed ShaCraft stable updater feed.
|
||||
|
||||
Requires Python 3.10+ and the minisign CLI. Only public keys are inputs.
|
||||
Signing is deliberately a separate, operator-controlled action.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import binascii
|
||||
import contextlib
|
||||
from datetime import datetime, timezone
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import selectors
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
ORIGIN = "https://shacraft.ru/downloads/shacraft-launcher/"
|
||||
PLATFORMS = {
|
||||
"linux-x86_64": (".AppImage",),
|
||||
"linux-x86_64-appimage": (".AppImage",),
|
||||
"linux-x86_64-deb": (".deb",),
|
||||
"windows-x86_64": (".exe", ".msi"),
|
||||
"darwin-x86_64": (".app.tar.gz",),
|
||||
"darwin-aarch64": (".app.tar.gz",),
|
||||
}
|
||||
FIELDS = {"version", "notes", "pub_date", "platforms"}
|
||||
MAX_ARTIFACT_BYTES = 256 * 1024 * 1024
|
||||
MAX_METADATA_BYTES = 64 * 1024
|
||||
DEB_PACKAGE = "sha-craft-launcher"
|
||||
DPKG_DEB = "/usr/bin/dpkg-deb"
|
||||
PACKAGE_TOOL_ENV = {"PATH": "/usr/bin:/bin", "LC_ALL": "C"}
|
||||
|
||||
|
||||
class InvalidRelease(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def version_tuple(version):
|
||||
if not isinstance(version, str) or not re.fullmatch(
|
||||
r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)", version
|
||||
):
|
||||
raise InvalidRelease("stable version must be plain MAJOR.MINOR.PATCH")
|
||||
parts = tuple(map(int, version.split(".")))
|
||||
if any(part > 2**64 - 1 for part in parts):
|
||||
raise InvalidRelease("version component exceeds SemVer range")
|
||||
return parts
|
||||
|
||||
|
||||
def artifact_name(platform, filename):
|
||||
if platform not in PLATFORMS:
|
||||
raise InvalidRelease("unsupported updater platform")
|
||||
if not isinstance(filename, str) or not re.fullmatch(
|
||||
r"[A-Za-z0-9][A-Za-z0-9._-]{0,199}", filename
|
||||
):
|
||||
raise InvalidRelease("artifact filename must be a plain ASCII filename")
|
||||
if not filename.endswith(PLATFORMS[platform]):
|
||||
raise InvalidRelease("artifact suffix does not match updater platform")
|
||||
return filename
|
||||
|
||||
|
||||
def regular_file(path, limit):
|
||||
info = path.lstat()
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_size == 0 or info.st_size > limit:
|
||||
raise InvalidRelease("input must be a nonempty regular file within size limit")
|
||||
return info
|
||||
|
||||
|
||||
def read_file(path, limit=MAX_METADATA_BYTES):
|
||||
regular_file(path, limit)
|
||||
with path.open("rb") as stream:
|
||||
value = stream.read(limit + 1)
|
||||
if len(value) > limit:
|
||||
raise InvalidRelease("input exceeds size limit")
|
||||
return value
|
||||
|
||||
|
||||
def decode_tauri(value):
|
||||
if not isinstance(value, str) or not value or len(value) > MAX_METADATA_BYTES:
|
||||
raise InvalidRelease("invalid Tauri base64 value")
|
||||
try:
|
||||
decoded = base64.b64decode(value, validate=True)
|
||||
decoded.decode("utf-8")
|
||||
except (binascii.Error, UnicodeDecodeError) as exc:
|
||||
raise InvalidRelease("invalid Tauri base64 encoding") from exc
|
||||
if base64.b64encode(decoded).decode("ascii") != value:
|
||||
raise InvalidRelease("noncanonical Tauri base64 encoding")
|
||||
return decoded
|
||||
|
||||
|
||||
def verify_signature(artifact, signature, public_key, minisign):
|
||||
# Tauri wraps the entire standard minisign text file in base64.
|
||||
signature_bytes = decode_tauri(signature)
|
||||
key_bytes = decode_tauri(public_key)
|
||||
with tempfile.TemporaryDirectory(prefix="shacraft-update-verify-") as temporary:
|
||||
root = Path(temporary)
|
||||
signature_path = root / "signature.minisig"
|
||||
key_path = root / "public.minisign.pub"
|
||||
signature_path.write_bytes(signature_bytes)
|
||||
key_path.write_bytes(key_bytes)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[minisign, "-V", "-q", "-m", str(artifact), "-x", str(signature_path),
|
||||
"-p", str(key_path)],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, timeout=120, check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise InvalidRelease("minisign verification could not run") from exc
|
||||
if result.returncode != 0:
|
||||
raise InvalidRelease("signature verification failed")
|
||||
|
||||
|
||||
def bounded_command_output(command, limit=4096, timeout=10):
|
||||
"""Run a fixed package inspector without shell, inherited hooks or unbounded output."""
|
||||
process = None
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL, env=PACKAGE_TOOL_ENV,
|
||||
)
|
||||
deadline = time.monotonic() + timeout
|
||||
output = bytearray()
|
||||
with selectors.DefaultSelector() as selector:
|
||||
selector.register(process.stdout, selectors.EVENT_READ)
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0 or not selector.select(remaining):
|
||||
raise InvalidRelease("package inspection timed out")
|
||||
chunk = os.read(process.stdout.fileno(), min(4096, limit + 1 - len(output)))
|
||||
if not chunk:
|
||||
break
|
||||
output.extend(chunk)
|
||||
if len(output) > limit:
|
||||
raise InvalidRelease("package inspection exceeds output limit")
|
||||
returncode = process.wait(timeout=max(0.001, deadline - time.monotonic()))
|
||||
if returncode != 0:
|
||||
raise InvalidRelease("package inspection failed")
|
||||
return bytes(output)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise InvalidRelease("package inspection could not run") from exc
|
||||
finally:
|
||||
if process is not None:
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
process.wait()
|
||||
process.stdout.close()
|
||||
|
||||
|
||||
def validate_artifact_format(platform, path, version):
|
||||
if platform in {"linux-x86_64", "linux-x86_64-appimage"}:
|
||||
with path.open("rb") as stream:
|
||||
header = stream.read(64)
|
||||
if (len(header) < 64 or header[:7] != b"\x7fELF\x02\x01\x01"
|
||||
or header[8:11] != b"AI\x02" or header[18:20] != b"\x3e\x00"):
|
||||
raise InvalidRelease("AppImage must be a type-2 x86_64 ELF image")
|
||||
elif platform == "linux-x86_64-deb":
|
||||
# Inspect only authenticated package bytes; dpkg-deb does not run maintainer scripts.
|
||||
output = bounded_command_output([
|
||||
DPKG_DEB, "--showformat=${Package}\n${Version}\n${Architecture}\n", "--show", str(path),
|
||||
])
|
||||
expected = f"{DEB_PACKAGE}\n{version}\namd64\n".encode("ascii")
|
||||
if output != expected:
|
||||
raise InvalidRelease("deb identity must match sha-craft-launcher, signed version and amd64")
|
||||
|
||||
|
||||
def validate_linux_aliases(platforms):
|
||||
if "linux-x86_64-appimage" in platforms or "linux-x86_64-deb" in platforms:
|
||||
legacy = platforms.get("linux-x86_64")
|
||||
exact = platforms.get("linux-x86_64-appimage")
|
||||
if legacy is None or exact is None or legacy != exact:
|
||||
raise InvalidRelease("format-aware Linux releases require identical legacy and AppImage entries")
|
||||
|
||||
|
||||
def strict_json(data):
|
||||
def unique(pairs):
|
||||
result = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise InvalidRelease("duplicate JSON key")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
try:
|
||||
return json.loads(data, object_pairs_hook=unique)
|
||||
except (ValueError, UnicodeDecodeError) as exc:
|
||||
raise InvalidRelease("invalid release JSON") from exc
|
||||
|
||||
|
||||
def canonical(payload):
|
||||
return json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
|
||||
def verify_payload_bytes(payload_bytes, signature, public_key, minisign):
|
||||
with tempfile.TemporaryDirectory(prefix="shacraft-update-payload-") as temporary:
|
||||
immutable_payload = Path(temporary) / "payload.json"
|
||||
immutable_payload.write_bytes(payload_bytes)
|
||||
verify_signature(immutable_payload, signature, public_key, minisign)
|
||||
|
||||
|
||||
def verified_previous(data, public_key, minisign):
|
||||
envelope = strict_json(data)
|
||||
if not isinstance(envelope, dict) or set(envelope) != FIELDS | {"signedPayload", "metadataSignature"}:
|
||||
raise InvalidRelease("existing feed must have authenticated metadata")
|
||||
payload_bytes = decode_tauri(envelope["signedPayload"])
|
||||
verify_payload_bytes(payload_bytes, envelope["metadataSignature"], public_key, minisign)
|
||||
payload = strict_json(payload_bytes)
|
||||
if payload != {key: envelope[key] for key in FIELDS}:
|
||||
raise InvalidRelease("existing feed fields differ from signed metadata")
|
||||
return payload
|
||||
|
||||
|
||||
def validate_payload(payload, downloads_root, public_key, minisign):
|
||||
if not isinstance(payload, dict) or set(payload) != FIELDS:
|
||||
raise InvalidRelease("payload must contain exactly the four Tauri release fields")
|
||||
version_tuple(payload["version"])
|
||||
if not isinstance(payload["notes"], str) or len(payload["notes"]) > 8000:
|
||||
raise InvalidRelease("release notes must contain at most 8000 characters")
|
||||
if not isinstance(payload["pub_date"], str):
|
||||
raise InvalidRelease("release date must be RFC3339 UTC")
|
||||
try:
|
||||
datetime.strptime(payload["pub_date"], "%Y-%m-%dT%H:%M:%SZ")
|
||||
except ValueError as exc:
|
||||
raise InvalidRelease("release date must be RFC3339 UTC") from exc
|
||||
platforms = payload["platforms"]
|
||||
if not isinstance(platforms, dict) or not platforms:
|
||||
raise InvalidRelease("at least one signed updater artifact is required")
|
||||
validate_linux_aliases(platforms)
|
||||
release_dir = downloads_root.resolve() / payload["version"]
|
||||
if release_dir.is_symlink() or not release_dir.is_dir():
|
||||
raise InvalidRelease("release directory must be an existing real directory")
|
||||
prefix = ORIGIN + payload["version"] + "/"
|
||||
for platform, artifact in platforms.items():
|
||||
if not isinstance(artifact, dict) or set(artifact) != {"url", "signature"}:
|
||||
raise InvalidRelease("artifact requires exactly url and signature")
|
||||
url = artifact["url"]
|
||||
if not isinstance(url, str) or not url.startswith(prefix):
|
||||
raise InvalidRelease("artifact must use the fixed ShaCraft release URL")
|
||||
filename = artifact_name(platform, url[len(prefix):])
|
||||
local_path = release_dir / filename
|
||||
before = regular_file(local_path, MAX_ARTIFACT_BYTES)
|
||||
verify_signature(local_path, artifact["signature"], public_key, minisign)
|
||||
validate_artifact_format(platform, local_path, payload["version"])
|
||||
after = regular_file(local_path, MAX_ARTIFACT_BYTES)
|
||||
if (before.st_ino, before.st_size, before.st_mtime_ns) != (
|
||||
after.st_ino, after.st_size, after.st_mtime_ns
|
||||
):
|
||||
raise InvalidRelease("artifact changed during verification")
|
||||
|
||||
|
||||
def atomic_write(destination, data):
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
descriptor, temporary = tempfile.mkstemp(prefix="." + destination.name + ".", dir=destination.parent)
|
||||
try:
|
||||
with os.fdopen(descriptor, "wb") as stream:
|
||||
os.fchmod(stream.fileno(), 0o644)
|
||||
stream.write(data)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, destination)
|
||||
directory = os.open(destination.parent, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(directory)
|
||||
finally:
|
||||
os.close(directory)
|
||||
finally:
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
os.unlink(temporary)
|
||||
|
||||
|
||||
def prepare(args, public_key):
|
||||
version_tuple(args.version)
|
||||
artifacts = {}
|
||||
for item in args.artifact:
|
||||
platform, separator, filename = item.partition("=")
|
||||
if not separator or platform in artifacts:
|
||||
raise InvalidRelease("use each --artifact PLATFORM=FILENAME exactly once")
|
||||
artifact_name(platform, filename)
|
||||
signature = read_file(args.downloads_root / args.version / (filename + ".sig"), 16384).decode("ascii").strip()
|
||||
artifacts[platform] = {
|
||||
"url": ORIGIN + args.version + "/" + filename,
|
||||
"signature": signature,
|
||||
}
|
||||
payload = {
|
||||
"version": args.version,
|
||||
"notes": read_file(args.notes_file).decode("utf-8").strip(),
|
||||
"pub_date": args.pub_date or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"platforms": artifacts,
|
||||
}
|
||||
validate_payload(payload, args.downloads_root, public_key, args.minisign)
|
||||
atomic_write(args.payload, canonical(payload))
|
||||
return payload
|
||||
|
||||
|
||||
def publish(args, public_key):
|
||||
payload_bytes = read_file(args.payload)
|
||||
payload = strict_json(payload_bytes)
|
||||
if not isinstance(payload, dict) or set(payload) != FIELDS:
|
||||
raise InvalidRelease("payload must contain exactly the four Tauri release fields")
|
||||
signature = read_file(args.signature, 16384).decode("ascii").strip()
|
||||
if canonical(payload) != payload_bytes:
|
||||
raise InvalidRelease("payload must be the exact canonical file from prepare")
|
||||
# Verify the captured bytes, so a changing operator input cannot replace
|
||||
# a verified file with different bytes in the feed.
|
||||
verify_payload_bytes(payload_bytes, signature, public_key, args.minisign)
|
||||
envelope = dict(payload)
|
||||
envelope["signedPayload"] = base64.b64encode(payload_bytes).decode("ascii")
|
||||
envelope["metadataSignature"] = signature
|
||||
data = json.dumps(envelope, ensure_ascii=False, indent=2).encode("utf-8") + b"\n"
|
||||
if len(data) > MAX_METADATA_BYTES:
|
||||
raise InvalidRelease("signed metadata exceeds size limit")
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
lock = args.output.with_name("." + args.output.name + ".lock")
|
||||
descriptor = os.open(lock, os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
||||
with os.fdopen(descriptor, "wb") as lock_stream:
|
||||
fcntl.flock(lock_stream, fcntl.LOCK_EX)
|
||||
if args.output.exists() or args.output.is_symlink():
|
||||
previous = verified_previous(read_file(args.output), public_key, args.minisign)
|
||||
if version_tuple(payload["version"]) <= version_tuple(previous["version"]):
|
||||
raise InvalidRelease("stable publication must strictly increase version")
|
||||
validate_payload(payload, args.downloads_root, public_key, args.minisign)
|
||||
if not args.dry_run:
|
||||
atomic_write(args.output, data)
|
||||
return payload
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
common = argparse.ArgumentParser(add_help=False)
|
||||
common.add_argument("--downloads-root", type=Path, required=True)
|
||||
common.add_argument("--public-key", type=Path, required=True, help="Tauri outer-base64 .pub file")
|
||||
common.add_argument("--minisign", default="minisign")
|
||||
common.add_argument("--payload", type=Path, required=True)
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
prepare_parser = commands.add_parser("prepare", parents=[common])
|
||||
prepare_parser.add_argument("--version", required=True)
|
||||
prepare_parser.add_argument("--artifact", action="append", required=True, metavar="PLATFORM=FILENAME")
|
||||
prepare_parser.add_argument("--notes-file", type=Path, required=True)
|
||||
prepare_parser.add_argument("--pub-date")
|
||||
publish_parser = commands.add_parser("publish", parents=[common])
|
||||
publish_parser.add_argument("--signature", type=Path, required=True)
|
||||
publish_parser.add_argument("--output", type=Path, required=True)
|
||||
publish_parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
public_key = read_file(args.public_key, 16384).decode("ascii").strip()
|
||||
payload = prepare(args, public_key) if args.command == "prepare" else publish(args, public_key)
|
||||
except (InvalidRelease, OSError, UnicodeError) as exc:
|
||||
parser.exit(1, f"Release rejected: {exc}\n")
|
||||
print(f"{args.command}: {payload['version']} ({', '.join(sorted(payload['platforms']))})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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()
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"bundle": {
|
||||
"createUpdaterArtifacts": false
|
||||
}
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
"""Publisher policy and real minisign verification; keys exist only in tempdirs."""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import copy
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import publish_launcher_update as publisher
|
||||
|
||||
MINISIGN = os.environ.get("SHACRAFT_TEST_MINISIGN", "minisign")
|
||||
|
||||
|
||||
def appimage_fixture():
|
||||
header = bytearray(64)
|
||||
header[:7] = b"\x7fELF\x02\x01\x01"
|
||||
header[8:11] = b"AI\x02"
|
||||
header[18:20] = b"\x3e\x00"
|
||||
return bytes(header) + b"isolated format fixture; not a runnable launcher"
|
||||
|
||||
|
||||
class PolicyTests(unittest.TestCase):
|
||||
def test_stable_versions_are_strict_and_order_numerically(self):
|
||||
self.assertGreater(publisher.version_tuple("0.1.10"), publisher.version_tuple("0.1.9"))
|
||||
for value in ("v0.1.3", "0.01.3", "0.1.3-beta", "0.1.3+build", "../0.1.3", 3):
|
||||
with self.subTest(value=value), self.assertRaises(publisher.InvalidRelease):
|
||||
publisher.version_tuple(value)
|
||||
|
||||
def test_platform_filename_policy(self):
|
||||
publisher.artifact_name("linux-x86_64", "ShaCraft.Launcher_0.1.3_amd64.AppImage")
|
||||
publisher.artifact_name("linux-x86_64-appimage", "ShaCraft.Launcher_0.1.4_amd64.AppImage")
|
||||
publisher.artifact_name("linux-x86_64-deb", "ShaCraft.Launcher_0.1.4_amd64.deb")
|
||||
for platform, filename in (
|
||||
("linux-x86_64", "../bad.AppImage"), ("linux-x86_64", "foo.AppImage?secret"),
|
||||
("linux-x86_64", "%2e%2e.AppImage"), ("linux-x86_64", "install.exe"),
|
||||
("unknown", "test.AppImage"), ("darwin-aarch64", "installer.dmg"),
|
||||
("linux-x86_64", "install.deb"), ("linux-x86_64-appimage", "install.deb"),
|
||||
("linux-x86_64-deb", "install.AppImage"),
|
||||
):
|
||||
with self.subTest(filename=filename), self.assertRaises(publisher.InvalidRelease):
|
||||
publisher.artifact_name(platform, filename)
|
||||
|
||||
def test_duplicate_json_keys_are_rejected(self):
|
||||
with self.assertRaises(publisher.InvalidRelease):
|
||||
publisher.strict_json(b'{"version":"0.1.3","version":"9.0.0"}')
|
||||
|
||||
def test_package_inspection_is_bounded_and_clears_environment(self):
|
||||
with self.assertRaisesRegex(publisher.InvalidRelease, "output limit"):
|
||||
publisher.bounded_command_output([sys.executable, "-c", "print('x' * 8192)"], limit=128)
|
||||
with self.assertRaisesRegex(publisher.InvalidRelease, "timed out"):
|
||||
publisher.bounded_command_output([sys.executable, "-c", "import time; time.sleep(30)"], timeout=0.1)
|
||||
os.environ["SHACRAFT_INSPECTION_SECRET_TEST"] = "must-not-be-inherited"
|
||||
try:
|
||||
output = publisher.bounded_command_output([
|
||||
sys.executable, "-c", "import os; print(os.getenv('SHACRAFT_INSPECTION_SECRET_TEST', 'clean'))",
|
||||
])
|
||||
finally:
|
||||
del os.environ["SHACRAFT_INSPECTION_SECRET_TEST"]
|
||||
self.assertEqual(output, b"clean\n")
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which(MINISIGN), "minisign CLI required for signature integration tests")
|
||||
class SignatureTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory(prefix="shacraft-update-test-")
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.root = Path(self.temporary.name)
|
||||
self.key = self.root / "fixture.key"
|
||||
public = self.root / "fixture.pub"
|
||||
subprocess.run(
|
||||
[MINISIGN, "-G", "-W", "-p", str(public), "-s", str(self.key)],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True,
|
||||
)
|
||||
self.public_key = base64.b64encode(public.read_bytes()).decode("ascii")
|
||||
self.downloads = self.root / "downloads"
|
||||
release = self.downloads / "0.1.3"
|
||||
release.mkdir(parents=True)
|
||||
self.artifact = release / "fixture.AppImage"
|
||||
self.artifact.write_bytes(appimage_fixture())
|
||||
self.payload = {
|
||||
"version": "0.1.3", "notes": "Проверка обновления", "pub_date": "2026-09-10T00:00:00Z",
|
||||
"platforms": {"linux-x86_64": {
|
||||
"url": publisher.ORIGIN + "0.1.3/fixture.AppImage",
|
||||
"signature": self.sign(self.artifact),
|
||||
}},
|
||||
}
|
||||
self.payload_path = self.root / "payload.json"
|
||||
self.output = self.root / "stable.json"
|
||||
|
||||
def sign(self, path):
|
||||
signature_path = path.with_name(path.name + ".minisig")
|
||||
subprocess.run(
|
||||
[MINISIGN, "-S", "-s", str(self.key), "-m", str(path), "-x", str(signature_path), "-q"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True,
|
||||
)
|
||||
signature = base64.b64encode(signature_path.read_bytes()).decode("ascii")
|
||||
path.with_name(path.name + ".sig").write_text(signature, encoding="ascii")
|
||||
return signature
|
||||
|
||||
def publish(self, payload=None, dry_run=False):
|
||||
self.payload_path.write_bytes(publisher.canonical(payload or self.payload))
|
||||
self.sign(self.payload_path)
|
||||
args = argparse.Namespace(
|
||||
payload=self.payload_path, signature=self.payload_path.with_name("payload.json.sig"),
|
||||
output=self.output, downloads_root=self.downloads, minisign=MINISIGN, dry_run=dry_run,
|
||||
)
|
||||
return publisher.publish(args, self.public_key)
|
||||
|
||||
def test_valid_signed_feed_binds_metadata_and_artifact(self):
|
||||
self.publish()
|
||||
envelope = publisher.strict_json(self.output.read_bytes())
|
||||
payload_bytes = publisher.decode_tauri(envelope["signedPayload"])
|
||||
self.assertEqual(publisher.strict_json(payload_bytes), self.payload)
|
||||
self.assertEqual({key: envelope[key] for key in publisher.FIELDS}, self.payload)
|
||||
self.assertIn("metadataSignature", envelope)
|
||||
self.assertEqual(self.output.stat().st_mode & 0o777, 0o644)
|
||||
|
||||
def test_tampered_artifact_is_rejected_before_publication(self):
|
||||
self.artifact.write_bytes(b"replaced executable")
|
||||
with self.assertRaisesRegex(publisher.InvalidRelease, "signature verification failed"):
|
||||
self.publish()
|
||||
self.assertFalse(self.output.exists())
|
||||
|
||||
def test_tampered_metadata_signature_is_rejected(self):
|
||||
self.payload_path.write_bytes(publisher.canonical(self.payload))
|
||||
signature = self.sign(self.payload_path)
|
||||
self.payload["notes"] = "Changed after signing"
|
||||
self.payload_path.write_bytes(publisher.canonical(self.payload))
|
||||
with self.assertRaisesRegex(publisher.InvalidRelease, "signature verification failed"):
|
||||
publisher.verify_signature(self.payload_path, signature, self.public_key, MINISIGN)
|
||||
|
||||
def test_same_version_or_downgrade_keeps_original_feed(self):
|
||||
self.publish()
|
||||
original = self.output.read_bytes()
|
||||
for version in ("0.1.3", "0.1.2"):
|
||||
payload = copy.deepcopy(self.payload)
|
||||
payload["version"] = version
|
||||
with self.subTest(version=version), self.assertRaisesRegex(
|
||||
publisher.InvalidRelease, "strictly increase"
|
||||
):
|
||||
self.publish(payload)
|
||||
self.assertEqual(self.output.read_bytes(), original)
|
||||
|
||||
def test_foreign_url_cannot_be_signed_into_feed(self):
|
||||
self.payload["platforms"]["linux-x86_64"]["url"] = "https://example.com/test.AppImage"
|
||||
with self.assertRaisesRegex(publisher.InvalidRelease, "fixed ShaCraft release URL"):
|
||||
self.publish()
|
||||
self.assertFalse(self.output.exists())
|
||||
|
||||
def test_previous_version_must_also_be_authenticated(self):
|
||||
self.publish()
|
||||
envelope = publisher.strict_json(self.output.read_bytes())
|
||||
envelope["version"] = "99.0.0"
|
||||
self.output.write_bytes(publisher.canonical(envelope))
|
||||
with self.assertRaisesRegex(publisher.InvalidRelease, "differ from signed metadata"):
|
||||
self.publish()
|
||||
|
||||
def test_missing_signature_or_symlink_is_rejected(self):
|
||||
original = self.artifact.read_bytes()
|
||||
target = self.root / "outside.AppImage"
|
||||
target.write_bytes(original)
|
||||
self.artifact.unlink()
|
||||
self.artifact.symlink_to(target)
|
||||
with self.assertRaisesRegex(publisher.InvalidRelease, "regular file"):
|
||||
self.publish()
|
||||
self.artifact.unlink()
|
||||
self.artifact.write_bytes(original)
|
||||
self.payload["platforms"]["linux-x86_64"].pop("signature")
|
||||
with self.assertRaisesRegex(publisher.InvalidRelease, "exactly url and signature"):
|
||||
self.publish()
|
||||
|
||||
def test_dry_run_verifies_without_creating_feed(self):
|
||||
self.publish(dry_run=True)
|
||||
self.assertFalse(self.output.exists())
|
||||
|
||||
def make_deb(self, package="sha-craft-launcher", version=None, architecture="amd64"):
|
||||
if not Path(publisher.DPKG_DEB).is_file():
|
||||
self.skipTest("dpkg-deb required for real deb validation")
|
||||
version = version or self.payload["version"]
|
||||
tree = self.root / "deb-tree"
|
||||
control = tree / "DEBIAN"
|
||||
control.mkdir(parents=True, exist_ok=True)
|
||||
(control / "control").write_text(
|
||||
f"Package: {package}\nVersion: {version}\nArchitecture: {architecture}\n"
|
||||
"Maintainer: Test <test@example.invalid>\nDescription: isolated updater fixture\n",
|
||||
encoding="ascii",
|
||||
)
|
||||
# Inspection must not execute a package script, even for an authenticated package.
|
||||
script = control / "preinst"
|
||||
script.write_text(f"#!/bin/sh\ntouch '{self.root / 'script-executed'}'\n", encoding="ascii")
|
||||
script.chmod(0o755)
|
||||
deb = self.artifact.with_name("fixture.deb")
|
||||
subprocess.run(
|
||||
[publisher.DPKG_DEB, "--build", "--root-owner-group", str(tree), str(deb)],
|
||||
env=publisher.PACKAGE_TOOL_ENV, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
timeout=10, check=True,
|
||||
)
|
||||
self.payload["platforms"]["linux-x86_64-appimage"] = copy.deepcopy(
|
||||
self.payload["platforms"]["linux-x86_64"]
|
||||
)
|
||||
self.payload["platforms"]["linux-x86_64-deb"] = {
|
||||
"url": publisher.ORIGIN + self.payload["version"] + "/fixture.deb", "signature": self.sign(deb),
|
||||
}
|
||||
return deb
|
||||
|
||||
def test_format_aware_release_preserves_legacy_appimage_and_verifies_real_deb(self):
|
||||
self.make_deb()
|
||||
notes = self.root / "notes.txt"
|
||||
notes.write_text(self.payload["notes"], encoding="utf-8")
|
||||
args = argparse.Namespace(
|
||||
version="0.1.3", artifact=[
|
||||
"linux-x86_64=fixture.AppImage", "linux-x86_64-appimage=fixture.AppImage",
|
||||
"linux-x86_64-deb=fixture.deb",
|
||||
], downloads_root=self.downloads, notes_file=notes, payload=self.payload_path,
|
||||
pub_date=self.payload["pub_date"], minisign=MINISIGN,
|
||||
)
|
||||
self.assertEqual(publisher.prepare(args, self.public_key), self.payload)
|
||||
self.publish()
|
||||
feed = publisher.verified_previous(self.output.read_bytes(), self.public_key, MINISIGN)
|
||||
self.assertEqual(feed["platforms"]["linux-x86_64"], feed["platforms"]["linux-x86_64-appimage"])
|
||||
self.assertEqual(set(feed["platforms"]), {"linux-x86_64", "linux-x86_64-appimage", "linux-x86_64-deb"})
|
||||
self.assertFalse((self.root / "script-executed").exists())
|
||||
|
||||
def test_authenticated_legacy_feed_advances_to_format_aware_release(self):
|
||||
self.publish()
|
||||
old_release = self.artifact.parent
|
||||
old_bytes = self.artifact.read_bytes()
|
||||
new_release = self.downloads / "0.1.4"
|
||||
shutil.copytree(old_release, new_release)
|
||||
self.artifact = new_release / self.artifact.name
|
||||
self.payload["version"] = "0.1.4"
|
||||
self.payload["platforms"]["linux-x86_64"]["url"] = publisher.ORIGIN + "0.1.4/fixture.AppImage"
|
||||
self.make_deb()
|
||||
self.publish()
|
||||
feed = publisher.verified_previous(self.output.read_bytes(), self.public_key, MINISIGN)
|
||||
self.assertEqual(feed["version"], "0.1.4")
|
||||
self.assertEqual(len(feed["platforms"]), 3)
|
||||
self.assertEqual((old_release / self.artifact.name).read_bytes(), old_bytes)
|
||||
|
||||
def test_linux_format_release_cannot_drop_or_repoint_legacy_entry(self):
|
||||
self.make_deb()
|
||||
for key in ("linux-x86_64", "linux-x86_64-appimage"):
|
||||
payload = copy.deepcopy(self.payload)
|
||||
del payload["platforms"][key]
|
||||
with self.subTest(key=key), self.assertRaisesRegex(publisher.InvalidRelease, "identical legacy"):
|
||||
self.publish(payload)
|
||||
payload = copy.deepcopy(self.payload)
|
||||
payload["platforms"]["linux-x86_64-appimage"]["url"] = publisher.ORIGIN + "0.1.3/other.AppImage"
|
||||
with self.assertRaisesRegex(publisher.InvalidRelease, "identical legacy"):
|
||||
self.publish(payload)
|
||||
self.assertFalse(self.output.exists())
|
||||
|
||||
def test_deb_identity_must_match_application_signed_version_and_architecture(self):
|
||||
for changes in ({"package": "another-launcher"}, {"version": "9.0.0"}, {"architecture": "arm64"}):
|
||||
with self.subTest(changes=changes):
|
||||
self.make_deb(**changes)
|
||||
with self.assertRaisesRegex(publisher.InvalidRelease, "deb identity"):
|
||||
self.publish()
|
||||
self.assertFalse(self.output.exists())
|
||||
|
||||
def test_signed_invalid_deb_is_rejected_without_running_package_scripts(self):
|
||||
deb = self.make_deb()
|
||||
deb.write_bytes(b"not a Debian archive")
|
||||
self.payload["platforms"]["linux-x86_64-deb"]["signature"] = self.sign(deb)
|
||||
with self.assertRaisesRegex(publisher.InvalidRelease, "package inspection failed"):
|
||||
self.publish()
|
||||
self.assertFalse((self.root / "script-executed").exists())
|
||||
self.assertFalse(self.output.exists())
|
||||
|
||||
def test_signed_wrong_appimage_format_is_rejected(self):
|
||||
for changed_slice, replacement in ((slice(8, 11), b"AI\x01"), (slice(18, 20), b"\xb7\x00")):
|
||||
malformed = bytearray(appimage_fixture())
|
||||
malformed[changed_slice] = replacement
|
||||
self.artifact.write_bytes(malformed)
|
||||
self.payload["platforms"]["linux-x86_64"]["signature"] = self.sign(self.artifact)
|
||||
with self.subTest(replacement=replacement), self.assertRaisesRegex(publisher.InvalidRelease, "type-2 x86_64"):
|
||||
self.publish()
|
||||
self.assertFalse(self.output.exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Generated
+101
-30
@@ -2093,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"
|
||||
@@ -2258,6 +2267,16 @@ dependencies = [
|
||||
"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"
|
||||
@@ -3361,13 +3380,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "shacraft-launcher"
|
||||
version = "0.1.6"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"ed25519-dalek",
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
"libc",
|
||||
"md-5",
|
||||
"minisign-verify",
|
||||
"reqwest 0.12.28",
|
||||
@@ -3377,14 +3394,14 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"sysinfo",
|
||||
"tar",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-updater",
|
||||
"tempfile",
|
||||
"time",
|
||||
"url",
|
||||
"zeroize",
|
||||
"zip 2.4.2",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3615,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"
|
||||
@@ -3662,7 +3693,7 @@ dependencies = [
|
||||
"tao-macros",
|
||||
"unicode-segmentation",
|
||||
"url",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
"windows-version",
|
||||
"x11-dl",
|
||||
@@ -3744,7 +3775,7 @@ dependencies = [
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"window-vibrancy",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3828,6 +3859,8 @@ dependencies = [
|
||||
[[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",
|
||||
@@ -3853,7 +3886,6 @@ dependencies = [
|
||||
"tokio",
|
||||
"url",
|
||||
"windows-sys 0.60.2",
|
||||
"zip 4.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3878,7 +3910,7 @@ dependencies = [
|
||||
"url",
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3903,7 +3935,7 @@ dependencies = [
|
||||
"url",
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
"wry",
|
||||
]
|
||||
|
||||
@@ -4672,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",
|
||||
@@ -4696,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",
|
||||
]
|
||||
|
||||
@@ -4752,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]]
|
||||
@@ -4768,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"
|
||||
@@ -4802,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]]
|
||||
@@ -4849,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"
|
||||
@@ -4987,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"
|
||||
@@ -5218,7 +5301,7 @@ dependencies = [
|
||||
"webkit2gtk",
|
||||
"webkit2gtk-sys",
|
||||
"webview2-com",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
"windows-version",
|
||||
"x11-dl",
|
||||
@@ -5355,18 +5438,6 @@ dependencies = [
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"indexmap 2.14.2",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.7"
|
||||
|
||||
+8
-12
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "shacraft-launcher"
|
||||
version = "0.1.6"
|
||||
version = "0.2.0"
|
||||
description = "ShaCraft Minecraft launcher"
|
||||
authors = ["ShaCraft"]
|
||||
license = "MIT"
|
||||
@@ -20,21 +20,17 @@ sha1 = "0.10"
|
||||
sha2 = "0.10"
|
||||
md-5 = "0.10"
|
||||
base64 = "0.22"
|
||||
ed25519-dalek = { version = "2", features = ["pkcs8"] }
|
||||
getrandom = "0.3"
|
||||
zeroize = "1"
|
||||
libc = "0.2"
|
||||
tempfile = "3"
|
||||
ed25519-dalek = "2"
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-updater = { version = "=2.11.0", path = "../vendor/tauri-plugin-updater", default-features = false, features = ["rustls-tls", "zip"] }
|
||||
reqwest-updater = { package = "reqwest", version = "0.13", default-features = false }
|
||||
minisign-verify = "0.2"
|
||||
semver = "1"
|
||||
url = "2"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls", "json"] }
|
||||
flate2 = "1"
|
||||
tar = "0.4"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
sysinfo = { version = "0.39.6", default-features = false, features = ["system"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tauri = { version = "2", features = ["test"] }
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
//! Ephemeral proof of possession for one Aeronautics connection.
|
||||
//!
|
||||
//! Neither this private key nor its ticket crosses IPC, enters launch arguments,
|
||||
//! or is persisted. Only the new Java child's environment receives them. A new
|
||||
//! launch obtains a new key and ticket; account session credentials stay native.
|
||||
|
||||
use crate::session::PlayerIdentity;
|
||||
use base64::{
|
||||
engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
|
||||
Engine,
|
||||
};
|
||||
use ed25519_dalek::{
|
||||
pkcs8::{EncodePrivateKey, KeypairBytes},
|
||||
SigningKey,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::process::Command;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
pub(crate) const TICKET_ENV: &str = "SHACRAFT_ADMISSION_TICKET";
|
||||
pub(crate) const PRIVATE_KEY_ENV: &str = "SHACRAFT_ADMISSION_PRIVATE_KEY";
|
||||
#[cfg(test)]
|
||||
const SERVER_ID: &str = "aoc";
|
||||
|
||||
pub(crate) fn server_for_profile(profile: &str) -> Result<&'static str, &'static str> {
|
||||
match profile {
|
||||
"aeronautics" => Ok("aoc"),
|
||||
"minigames" => Ok("minigames"),
|
||||
_ => Err("Unknown ShaCraft profile"),
|
||||
}
|
||||
}
|
||||
const MAX_LIFETIME_SECONDS: u64 = 600;
|
||||
|
||||
// Deliberately no Debug, Clone or Serialize for secret-bearing values.
|
||||
pub(crate) struct AdmissionKey {
|
||||
server_id: &'static str,
|
||||
public_key: String,
|
||||
private_key: Zeroizing<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct TicketRequest<'a> {
|
||||
server_id: &'static str,
|
||||
public_key: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct TicketResponse {
|
||||
ticket_id: String,
|
||||
mc_username: String,
|
||||
server_id: String,
|
||||
expires_in_seconds: u64,
|
||||
}
|
||||
|
||||
pub(crate) struct Admission {
|
||||
server_id: &'static str,
|
||||
ticket_id: Zeroizing<String>,
|
||||
private_key: Zeroizing<String>,
|
||||
identity: PlayerIdentity,
|
||||
}
|
||||
|
||||
impl AdmissionKey {
|
||||
pub(crate) fn generate(server_id: &'static str) -> Result<Self, &'static str> {
|
||||
if !matches!(server_id, "aoc" | "minigames") { return Err("Unknown ShaCraft server"); }
|
||||
let mut seed = Zeroizing::new([0_u8; 32]);
|
||||
getrandom::fill(seed.as_mut())
|
||||
.map_err(|_| "Не удалось создать защищённый ключ входа. Повторите запуск лаунчера.")?;
|
||||
let signing_key = SigningKey::from_bytes(&seed);
|
||||
let public_key = STANDARD.encode(signing_key.verifying_key().to_bytes());
|
||||
// RFC 8410 PKCS#8 v1 (PrivateKeyInfo) without the optional public key.
|
||||
// This is accepted by Java 21's Ed25519 KeyFactory/PKCS8EncodedKeySpec.
|
||||
let key_bytes = KeypairBytes {
|
||||
secret_key: signing_key.to_bytes(),
|
||||
public_key: None,
|
||||
};
|
||||
let encoded = key_bytes
|
||||
.to_pkcs8_der()
|
||||
.map_err(|_| "Не удалось подготовить защищённый ключ входа.")?;
|
||||
Ok(Self {
|
||||
server_id,
|
||||
public_key,
|
||||
private_key: Zeroizing::new(STANDARD.encode(encoded.as_bytes())),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn request(&self) -> TicketRequest<'_> {
|
||||
TicketRequest {
|
||||
server_id: self.server_id,
|
||||
public_key: &self.public_key,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn bind(self, response: TicketResponse) -> Result<Admission, &'static str> {
|
||||
let ticket_id = Zeroizing::new(response.ticket_id);
|
||||
let valid_ticket = ticket_id.len() == 43
|
||||
&& URL_SAFE_NO_PAD
|
||||
.decode(ticket_id.as_bytes())
|
||||
.is_ok_and(|bytes| {
|
||||
bytes.len() == 32 && URL_SAFE_NO_PAD.encode(bytes) == *ticket_id
|
||||
});
|
||||
let valid_nickname = (3..=16).contains(&response.mc_username.len())
|
||||
&& response
|
||||
.mc_username
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_');
|
||||
if !valid_ticket
|
||||
|| !valid_nickname
|
||||
|| response.server_id != self.server_id
|
||||
|| !(1..=MAX_LIFETIME_SECONDS).contains(&response.expires_in_seconds)
|
||||
{
|
||||
return Err("Сервер вернул некорректное разрешение на вход. Повторите попытку позже.");
|
||||
}
|
||||
Ok(Admission {
|
||||
server_id: self.server_id,
|
||||
ticket_id,
|
||||
private_key: self.private_key,
|
||||
identity: PlayerIdentity::Offline {
|
||||
name: response.mc_username,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Admission {
|
||||
pub(crate) fn server_id(&self) -> &str { self.server_id }
|
||||
pub(crate) fn identity(&self) -> &PlayerIdentity {
|
||||
&self.identity
|
||||
}
|
||||
|
||||
pub(crate) fn configure_child(&self, command: &mut Command) {
|
||||
command.env(TICKET_ENV, self.ticket_id.as_str());
|
||||
command.env(PRIVATE_KEY_ENV, self.private_key.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::{pkcs8::DecodePrivateKey, Signer, Verifier};
|
||||
|
||||
fn response() -> TicketResponse {
|
||||
TicketResponse {
|
||||
ticket_id: URL_SAFE_NO_PAD.encode([37_u8; 32]),
|
||||
mc_username: "Canonical_Name".into(),
|
||||
server_id: SERVER_ID.into(),
|
||||
expires_in_seconds: 600,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_tickets_are_server_bound_with_shared_canonical_identity() {
|
||||
assert_eq!(server_for_profile("aeronautics").unwrap(), "aoc");
|
||||
assert_eq!(server_for_profile("minigames").unwrap(), "minigames");
|
||||
assert!(server_for_profile("../../other").is_err());
|
||||
assert!(AdmissionKey::generate("other").is_err());
|
||||
assert!(AdmissionKey::generate("minigames").unwrap().bind(response()).is_err());
|
||||
let key=AdmissionKey::generate("minigames").unwrap();
|
||||
assert_eq!(serde_json::to_value(key.request()).unwrap()["server_id"], "minigames");
|
||||
let mut payload=response(); payload.server_id="minigames".into();
|
||||
let admitted=key.bind(payload).unwrap();
|
||||
assert_eq!(admitted.server_id(), "minigames");
|
||||
assert_eq!(admitted.identity().name(), "Canonical_Name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generates_distinct_keys_and_only_sends_the_public_key() {
|
||||
let key = AdmissionKey::generate("aoc").unwrap();
|
||||
let other = AdmissionKey::generate("aoc").unwrap();
|
||||
assert_ne!(key.public_key, other.public_key);
|
||||
let payload = serde_json::to_value(key.request()).unwrap();
|
||||
assert_eq!(payload.as_object().unwrap().len(), 2);
|
||||
assert_eq!(payload["server_id"], SERVER_ID);
|
||||
assert_eq!(payload["public_key"], key.public_key);
|
||||
assert!(!payload.to_string().contains(key.private_key.as_str()));
|
||||
let der = Zeroizing::new(STANDARD.decode(key.private_key.as_bytes()).unwrap());
|
||||
let restored = SigningKey::from_pkcs8_der(&der).unwrap();
|
||||
assert_eq!(
|
||||
STANDARD.encode(restored.verifying_key().to_bytes()),
|
||||
key.public_key
|
||||
);
|
||||
let message = b"shacraft-admission-v1:challenge-fixture";
|
||||
restored
|
||||
.verifying_key()
|
||||
.verify(message, &restored.sign(message))
|
||||
.unwrap();
|
||||
// Java's standard Ed25519 encoding is the 48-byte private-key-only form.
|
||||
assert_eq!(der.len(), 48);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_untrusted_identity_ticket_server_and_expiry() {
|
||||
let mutations: Vec<Box<dyn Fn(&mut TicketResponse)>> = vec![
|
||||
Box::new(|r| r.ticket_id = "../unsafe".into()),
|
||||
Box::new(|r| r.ticket_id = "A".repeat(42) + "!"),
|
||||
Box::new(|r| r.ticket_id = "A".repeat(42) + "B"),
|
||||
Box::new(|r| r.mc_username = "../../outside".into()),
|
||||
Box::new(|r| r.mc_username = "ab".into()),
|
||||
Box::new(|r| r.mc_username = "a".repeat(17)),
|
||||
Box::new(|r| r.server_id = "other".into()),
|
||||
Box::new(|r| r.expires_in_seconds = 0),
|
||||
Box::new(|r| r.expires_in_seconds = 601),
|
||||
];
|
||||
for mutate in mutations {
|
||||
let mut payload = response();
|
||||
mutate(&mut payload);
|
||||
assert!(AdmissionKey::generate("aoc").unwrap().bind(payload).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secrets_only_enter_the_child_environment_and_identity_comes_from_ticket() {
|
||||
let original_ticket = std::env::var_os(TICKET_ENV);
|
||||
let original_key = std::env::var_os(PRIVATE_KEY_ENV);
|
||||
let admission = AdmissionKey::generate("aoc").unwrap().bind(response()).unwrap();
|
||||
let mut command = Command::new("java");
|
||||
command
|
||||
.arg("-Xmx6144M")
|
||||
.arg("net.minecraft.client.main.Main");
|
||||
admission.configure_child(&mut command);
|
||||
assert_eq!(admission.identity().name(), "Canonical_Name");
|
||||
let env: std::collections::HashMap<_, _> = command.get_envs().collect();
|
||||
assert_eq!(
|
||||
env.get(std::ffi::OsStr::new(TICKET_ENV)).unwrap().unwrap(),
|
||||
admission.ticket_id.as_str()
|
||||
);
|
||||
assert_eq!(
|
||||
env.get(std::ffi::OsStr::new(PRIVATE_KEY_ENV))
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
admission.private_key.as_str()
|
||||
);
|
||||
assert_eq!(env.len(), 2);
|
||||
for argument in command.get_args() {
|
||||
assert!(!argument
|
||||
.to_string_lossy()
|
||||
.contains(admission.ticket_id.as_str()));
|
||||
assert!(!argument
|
||||
.to_string_lossy()
|
||||
.contains(admission.private_key.as_str()));
|
||||
}
|
||||
assert_eq!(std::env::var_os(TICKET_ENV), original_ticket);
|
||||
assert_eq!(std::env::var_os(PRIVATE_KEY_ENV), original_key);
|
||||
}
|
||||
}
|
||||
@@ -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
-182
@@ -1,7 +1,7 @@
|
||||
use super::data_dir;
|
||||
use super::{data_dir, profiles::ProfileMetadata, shacraft::resolve_identity};
|
||||
use crate::{
|
||||
fabric, java, launch, manifest, mojang, neoforge, operations::LauncherOperations, remote, runtime,
|
||||
settings, shacraft_account,
|
||||
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,135 +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())
|
||||
} else if manifest.minecraft.loader.kind == "fabric" {
|
||||
let child = fabric::fetch_profile(&manifest.minecraft.version, &manifest.minecraft.loader.version)?;
|
||||
mojang::merge_versions(&vanilla, Some(&child)).map_err(|error| error.to_string())
|
||||
.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)]
|
||||
@@ -153,90 +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 game_permit = state.game.acquire("Игра")?;
|
||||
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())?;
|
||||
#[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"));
|
||||
|
||||
// Generate fresh proof only after installation. Keep the account gate
|
||||
// through spawn so local logout/account switching cannot race issuance.
|
||||
let _account_permit = account_operation.acquire("ShaCraft account operation")?;
|
||||
let admission =
|
||||
shacraft_account::issue_admission(&data_dir, crate::admission::server_for_profile(&profile_id)?).map_err(|error| error.to_string())?;
|
||||
let request = launch::LaunchRequest {
|
||||
java_executable: Path::new(&java_install.executable),
|
||||
game_dir: &game_dir,
|
||||
profile_dir: &profile_dir,
|
||||
merged: &merged,
|
||||
admission: &admission,
|
||||
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 game_permit = game_permit;
|
||||
let exit_code = child.wait().ok().and_then(|status| status.code());
|
||||
drop(game_permit);
|
||||
let _ = watch_app.emit(
|
||||
"game-exited",
|
||||
GameExited {
|
||||
profile_id: watch_profile_id,
|
||||
exit_code,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("Launch task failed: {error}"))?
|
||||
let (_, java, inspection) = prepare(&directory, &snapshot, &callback).unwrap();
|
||||
assert!(inspection.up_to_date);
|
||||
assert_eq!(java.major, snapshot.manifest.minecraft.java_major);
|
||||
let game = directory.join("game");
|
||||
let version = &snapshot.manifest.minecraft.loader.version;
|
||||
let json = neoforge::installed_version_json_path(&game, version);
|
||||
let jar = game.join(format!(
|
||||
"libraries/net/neoforged/neoforge/{version}/neoforge-{version}-client.jar"
|
||||
));
|
||||
let original_json = std::fs::read(&json).unwrap();
|
||||
std::fs::write(&json, b"nonempty corrupted version JSON").unwrap();
|
||||
std::fs::write(&jar, b"nonempty corrupted patched JAR").unwrap();
|
||||
let (_, _, repaired) = prepare(&directory, &snapshot, &callback).unwrap();
|
||||
assert!(repaired.up_to_date);
|
||||
assert_eq!(std::fs::read(&json).unwrap(), original_json);
|
||||
assert!(std::fs::metadata(&jar).unwrap().len() > 1024);
|
||||
// A third preparation verifies the receipt and all downloads again.
|
||||
assert!(
|
||||
prepare(&directory, &snapshot, &callback)
|
||||
.unwrap()
|
||||
.2
|
||||
.up_to_date
|
||||
);
|
||||
eprintln!(
|
||||
"Cold install, corrupt JSON/JAR repair and healthy recheck passed: {}",
|
||||
directory.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,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())?
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
//! ShaCraft sessions and verified account links are the launch identity source.
|
||||
use super::data_dir;
|
||||
use crate::{operations::LauncherOperations, shacraft_account};
|
||||
use crate::{
|
||||
operations::{LauncherOperations, Operation},
|
||||
session, shacraft_account,
|
||||
};
|
||||
use std::path::Path;
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
@@ -10,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())
|
||||
})
|
||||
@@ -72,18 +77,6 @@ pub(crate) async fn shacraft_start_link(
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn shacraft_claim_nickname(
|
||||
app: AppHandle,
|
||||
state: State<'_, LauncherOperations>,
|
||||
nickname: String,
|
||||
) -> Result<shacraft_account::Account, String> {
|
||||
account_task(&app, &state, move |directory| {
|
||||
shacraft_account::claim_nickname(directory, &nickname)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn shacraft_link_status(
|
||||
app: AppHandle,
|
||||
@@ -95,3 +88,35 @@ pub(crate) async fn shacraft_link_status(
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Always revalidate the server session and its aoc link. Legacy local settings
|
||||
/// and Microsoft tokens do not select the identity in the ShaCraft-only flow.
|
||||
pub(super) fn resolve_identity(
|
||||
directory: &Path,
|
||||
operation: &Operation,
|
||||
) -> Result<session::PlayerIdentity, String> {
|
||||
let _permit = operation.acquire("ShaCraft account operation")?;
|
||||
let name =
|
||||
shacraft_account::aeronautics_nickname(directory).map_err(|error| error.to_string())?;
|
||||
Ok(session::PlayerIdentity::Offline { name })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn missing_shacraft_session_cannot_fall_back_to_legacy_nickname() {
|
||||
let directory = std::env::temp_dir().join(format!(
|
||||
"shacraft-identity-test-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
crate::settings::save(&directory, crate::settings::LauncherSettings::default()).unwrap();
|
||||
assert!(resolve_identity(&directory, &Operation::default()).is_err());
|
||||
std::fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
+132
-177
@@ -1,203 +1,158 @@
|
||||
//! No updater command accepts a URL, key, target, version, executable or arguments.
|
||||
use super::data_dir;
|
||||
use crate::{
|
||||
operations::LauncherOperations,
|
||||
updater::{self, LauncherUpdater, Stage, UpdateProgress, UpdateStatus},
|
||||
update_guard,
|
||||
updater::{self, UpdateStatus, UpdaterState},
|
||||
};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn get_launcher_update_status(
|
||||
app: AppHandle,
|
||||
updater: State<'_, LauncherUpdater>,
|
||||
) -> Result<UpdateStatus, String> {
|
||||
updater.status(&app)
|
||||
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) async fn check_launcher_update(
|
||||
pub(crate) fn updater_status(
|
||||
app: AppHandle,
|
||||
updater: State<'_, LauncherUpdater>,
|
||||
state: State<'_, UpdaterState>,
|
||||
) -> Result<UpdateStatus, String> {
|
||||
let status = updater.status(&app)?;
|
||||
if !status.supported || status.stage == Stage::Ready {
|
||||
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 permit = updater
|
||||
.operation
|
||||
.acquire("Проверка или установка обновления")
|
||||
.map_err(|_| "Проверка или установка обновления уже выполняется.".to_string())?;
|
||||
let updater = updater.inner().clone();
|
||||
updater.set_stage(Stage::Checking)?;
|
||||
// Detached task owns the permit: cancelling an IPC caller cannot release
|
||||
// the operation while its native HTTP request is still in flight.
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let 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;
|
||||
let result = async {
|
||||
let key = updater::public_key(&app)?;
|
||||
updater::check_candidate(
|
||||
updater::trusted_builder(&app)?,
|
||||
&key,
|
||||
&app.package_info().version.to_string(),
|
||||
updater::installation_kind(&app),
|
||||
)
|
||||
.await
|
||||
}
|
||||
.await;
|
||||
{
|
||||
let mut state = updater
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| "Состояние обновления недоступно.".to_string())?;
|
||||
match result {
|
||||
Ok(candidate) => {
|
||||
state.stage = if candidate.is_some() {
|
||||
Stage::Available
|
||||
} else {
|
||||
Stage::Idle
|
||||
};
|
||||
state.candidate = candidate;
|
||||
}
|
||||
Err(error) => {
|
||||
state.stage = if state.candidate.is_some() {
|
||||
Stage::Available
|
||||
} else {
|
||||
Stage::Idle
|
||||
};
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
updater.status(&app)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "Проверка обновления завершилась с ошибкой.".to_string())?
|
||||
// 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 install_launcher_update(
|
||||
pub(crate) async fn updater_download_install(
|
||||
app: AppHandle,
|
||||
updater: State<'_, LauncherUpdater>,
|
||||
operations: State<'_, LauncherOperations>,
|
||||
) -> Result<(), String> {
|
||||
if let Some(reason) = updater::unsupported_reason(&app) {
|
||||
return Err(reason);
|
||||
state: State<'_, UpdaterState>,
|
||||
) -> Result<UpdateStatus, String> {
|
||||
let permit = state.acquire()?;
|
||||
if let Some(status) = pending(&app, &state)? {
|
||||
return Ok(status);
|
||||
}
|
||||
let permit = updater
|
||||
.operation
|
||||
.acquire("Проверка или установка обновления")
|
||||
.map_err(|_| "Проверка или установка обновления уже выполняется.".to_string())?;
|
||||
let candidate = {
|
||||
let state = updater
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| "Состояние обновления недоступно.".to_string())?;
|
||||
if state.stage == Stage::Ready {
|
||||
return Err("Обновление уже установлено. Перезапустите лаунчер.".into());
|
||||
}
|
||||
state
|
||||
.candidate
|
||||
.clone()
|
||||
.ok_or("Сначала проверьте наличие обновлений.")?
|
||||
};
|
||||
let destination = updater::installation_path(&app)?;
|
||||
let mutation_permits = operations.acquire_update()
|
||||
.map_err(|_| "Закройте Minecraft и дождитесь завершения установки или входа в аккаунт перед обновлением.".to_string())?;
|
||||
let updater = updater.inner().clone();
|
||||
updater.set_stage(Stage::Downloading)?;
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let 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;
|
||||
let result = async {
|
||||
let key = updater::public_key(&app)?;
|
||||
let _ = app.emit(
|
||||
"launcher-update-progress",
|
||||
UpdateProgress {
|
||||
stage: Stage::Downloading,
|
||||
downloaded_bytes: 0,
|
||||
total_bytes: None,
|
||||
},
|
||||
);
|
||||
let bytes =
|
||||
updater::download_verified(&candidate, &key, |downloaded_bytes, total_bytes| {
|
||||
let _ = app.emit(
|
||||
"launcher-update-progress",
|
||||
UpdateProgress {
|
||||
stage: Stage::Downloading,
|
||||
downloaded_bytes,
|
||||
total_bytes,
|
||||
},
|
||||
);
|
||||
})
|
||||
.await?;
|
||||
let size = bytes.len() as u64;
|
||||
updater.set_stage(Stage::Installing)?;
|
||||
let _ = app.emit(
|
||||
"launcher-update-progress",
|
||||
UpdateProgress {
|
||||
stage: Stage::Installing,
|
||||
downloaded_bytes: size,
|
||||
total_bytes: Some(size),
|
||||
},
|
||||
);
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
updater::install_verified(&candidate, &bytes, &key, destination.as_deref())
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "Установка обновления завершилась с ошибкой.".to_string())??;
|
||||
Ok::<_, String>(size)
|
||||
}
|
||||
.await;
|
||||
match result {
|
||||
Ok(size) => {
|
||||
let mut state = updater
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| "Состояние обновления недоступно.".to_string())?;
|
||||
state.stage = Stage::Ready;
|
||||
state.restart_permits = Some(mutation_permits);
|
||||
let _ = app.emit(
|
||||
"launcher-update-progress",
|
||||
UpdateProgress {
|
||||
stage: Stage::Ready,
|
||||
downloaded_bytes: size,
|
||||
total_bytes: Some(size),
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => {
|
||||
updater.set_stage(Stage::Available)?;
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "Установка обновления завершилась с ошибкой.".to_string())?
|
||||
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 restart_launcher_after_update(
|
||||
pub(crate) fn updater_restart(
|
||||
app: AppHandle,
|
||||
updater: State<'_, LauncherUpdater>,
|
||||
state: State<'_, UpdaterState>,
|
||||
) -> Result<(), String> {
|
||||
let _permit = updater
|
||||
.operation
|
||||
.acquire("Установка обновления")
|
||||
.map_err(|_| "Установка обновления ещё выполняется.".to_string())?;
|
||||
{
|
||||
let state = updater
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| "Состояние обновления недоступно.".to_string())?;
|
||||
if state.stage != Stage::Ready || state.restart_permits.is_none() {
|
||||
return Err("Сначала установите обновление лаунчера.".into());
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
if updater::installation_kind(&app) == updater::InstallationKind::Deb
|
||||
|| crate::deb_updater::is_deleted_installed_binary()
|
||||
{
|
||||
crate::deb_updater::restart()?;
|
||||
app.exit(0);
|
||||
return Ok(());
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,555 +0,0 @@
|
||||
//! The only elevated updater operation. pkexec starts this installed, root-owned
|
||||
//! binary in an early non-GUI mode. Input is untrusted until BOTH signatures
|
||||
//! are verified again here. No user-supplied path, command or password is used.
|
||||
use crate::updater::{self, InstallationKind, MAX_ARTIFACT_BYTES, MAX_METADATA_BYTES};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs,
|
||||
io::{self, Read, Write},
|
||||
os::unix::{
|
||||
fs::{MetadataExt, PermissionsExt},
|
||||
process::CommandExt,
|
||||
},
|
||||
path::Path,
|
||||
process::{Command, ExitStatus, Stdio},
|
||||
sync::mpsc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tauri_plugin_updater::Update;
|
||||
use url::Url;
|
||||
|
||||
const BINARY: &str = "/usr/bin/shacraft-launcher";
|
||||
const HELPER_FLAG: &str = "--shacraft-install-deb";
|
||||
const PACKAGE: &str = "sha-craft-launcher";
|
||||
const PKEXEC: &str = "/usr/bin/pkexec";
|
||||
const DPKG: &str = "/usr/bin/dpkg";
|
||||
const QUERY: &str = "/usr/bin/dpkg-query";
|
||||
const DEB: &str = "/usr/bin/dpkg-deb";
|
||||
const OUTPUT_LIMIT: usize = 16 * 1024;
|
||||
const INPUT_MAGIC: &[u8; 8] = b"SCDUPD01";
|
||||
const REJECTED: i32 = 20;
|
||||
const LOCKED: i32 = 21;
|
||||
const INSTALL_FAILED: i32 = 22;
|
||||
const INVALID_HOST: i32 = 23;
|
||||
|
||||
fn architecture() -> &'static str {
|
||||
match std::env::consts::ARCH {
|
||||
"x86_64" => "amd64",
|
||||
"aarch64" => "arm64",
|
||||
_ => "unsupported",
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate the full path without following symlinks. The executable and every
|
||||
/// parent must be root-owned and not writable by group/other users.
|
||||
fn trusted_root_path(path: &Path, executable: bool) -> bool {
|
||||
if !path.is_absolute()
|
||||
|| path
|
||||
.components()
|
||||
.any(|c| matches!(c, std::path::Component::ParentDir))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mut leaf = true;
|
||||
for item in path.ancestors() {
|
||||
let Ok(meta) = fs::symlink_metadata(item) else {
|
||||
return false;
|
||||
};
|
||||
if meta.file_type().is_symlink() || meta.uid() != 0 || meta.mode() & 0o022 != 0 {
|
||||
return false;
|
||||
}
|
||||
if leaf && executable {
|
||||
if !meta.is_file() || meta.mode() & 0o111 == 0 {
|
||||
return false;
|
||||
}
|
||||
} else if !meta.is_dir() {
|
||||
return false;
|
||||
}
|
||||
leaf = false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn safe_sticky_temporary_parent(path: &Path) -> bool {
|
||||
fs::symlink_metadata(path).is_ok_and(|meta| {
|
||||
meta.is_dir()
|
||||
&& !meta.file_type().is_symlink()
|
||||
&& meta.uid() == 0
|
||||
&& (meta.mode() & 0o022 == 0 || meta.mode() & 0o1000 != 0)
|
||||
})
|
||||
}
|
||||
|
||||
fn fixed_command(path: &str) -> Command {
|
||||
let mut command = Command::new(path);
|
||||
command
|
||||
.env_clear()
|
||||
.env("PATH", "/usr/sbin:/usr/bin:/sbin:/bin")
|
||||
.env("LC_ALL", "C");
|
||||
command
|
||||
}
|
||||
|
||||
fn drain_capped(mut source: impl Read) -> io::Result<Vec<u8>> {
|
||||
let mut result = Vec::new();
|
||||
let mut buffer = [0_u8; 4096];
|
||||
loop {
|
||||
let read = source.read(&mut buffer)?;
|
||||
if read == 0 {
|
||||
return Ok(result);
|
||||
}
|
||||
let remaining = OUTPUT_LIMIT.saturating_sub(result.len());
|
||||
result.extend_from_slice(&buffer[..read.min(remaining)]);
|
||||
}
|
||||
}
|
||||
|
||||
struct Captured {
|
||||
status: ExitStatus,
|
||||
stdout: Vec<u8>,
|
||||
stderr: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Drain both pipes concurrently; output can never make the root helper buffer
|
||||
/// unbounded data or deadlock dpkg while it is changing the package database.
|
||||
fn capture(command: &mut Command) -> io::Result<Captured> {
|
||||
capture_with_deadline(command, Duration::from_secs(15))
|
||||
}
|
||||
|
||||
fn capture_with_deadline(command: &mut Command, deadline: Duration) -> io::Result<Captured> {
|
||||
// Read-only inspection has a deadline. Never kill dpkg during mutation:
|
||||
// interrupting it could leave a partially configured installed package.
|
||||
let inspection = command.get_program() != DPKG;
|
||||
if inspection {
|
||||
command.process_group(0);
|
||||
}
|
||||
let mut child = command
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?;
|
||||
let stdout = child.stdout.take().expect("piped stdout");
|
||||
let stderr = child.stderr.take().expect("piped stderr");
|
||||
let (out_send, out_receive) = mpsc::channel();
|
||||
let (err_send, err_receive) = mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let _ = out_send.send(drain_capped(stdout));
|
||||
});
|
||||
std::thread::spawn(move || {
|
||||
let _ = err_send.send(drain_capped(stderr));
|
||||
});
|
||||
let start = Instant::now();
|
||||
let mut stdout = None;
|
||||
let mut stderr = None;
|
||||
loop {
|
||||
if stdout.is_none() {
|
||||
stdout = out_receive.try_recv().ok();
|
||||
}
|
||||
if stderr.is_none() {
|
||||
stderr = err_receive.try_recv().ok();
|
||||
}
|
||||
// Do not reap the parent before its pipes close. Its unreaped PID
|
||||
// reserves the process-group id until a possible timeout kill below.
|
||||
if stdout.is_some() && stderr.is_some() {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
return Ok(Captured {
|
||||
status,
|
||||
stdout: stdout.unwrap()?,
|
||||
stderr: stderr.unwrap()?,
|
||||
});
|
||||
}
|
||||
}
|
||||
if inspection && start.elapsed() > deadline {
|
||||
// Also terminate dpkg-deb's decompressor descendants so they cannot
|
||||
// retain the pipes after the inspection parent has been killed.
|
||||
unsafe {
|
||||
libc::kill(-(child.id() as i32), libc::SIGKILL);
|
||||
}
|
||||
let _ = child.wait();
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"package inspection timed out",
|
||||
));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
fn installed_version() -> Result<String, ()> {
|
||||
let result = capture(fixed_command(QUERY).args([
|
||||
"--show",
|
||||
"--showformat=${db:Status-Status}\n${Version}\n${Architecture}\n",
|
||||
PACKAGE,
|
||||
]))
|
||||
.map_err(|_| ())?;
|
||||
if !result.status.success() {
|
||||
return Err(());
|
||||
}
|
||||
let fields = std::str::from_utf8(&result.stdout)
|
||||
.map_err(|_| ())?
|
||||
.lines()
|
||||
.collect::<Vec<_>>();
|
||||
if fields.len() != 3 || fields[0] != "installed" || fields[2] != architecture() {
|
||||
return Err(());
|
||||
}
|
||||
let version = semver::Version::parse(fields[1]).map_err(|_| ())?;
|
||||
if version.to_string() != fields[1] || !version.pre.is_empty() || !version.build.is_empty() {
|
||||
return Err(());
|
||||
}
|
||||
// dpkg's database must also assign the precise executable to our package.
|
||||
let owner = capture(fixed_command(QUERY).args(["--search", BINARY])).map_err(|_| ())?;
|
||||
if !owner.status.success() || owner.stdout != format!("{PACKAGE}: {BINARY}\n").as_bytes() {
|
||||
return Err(());
|
||||
}
|
||||
Ok(fields[1].to_owned())
|
||||
}
|
||||
|
||||
pub(crate) fn installed_binary_supported() -> bool {
|
||||
std::env::current_exe().is_ok_and(|path| path == Path::new(BINARY))
|
||||
&& trusted_root_path(Path::new(BINARY), true)
|
||||
&& [QUERY, DEB, DPKG]
|
||||
.iter()
|
||||
.all(|path| trusted_root_path(Path::new(path), true))
|
||||
&& installed_version().is_ok()
|
||||
}
|
||||
|
||||
pub(crate) fn unsupported_reason() -> Option<String> {
|
||||
if trusted_root_path(Path::new(PKEXEC), true) {
|
||||
None
|
||||
} else {
|
||||
Some("Для обновления deb нужен системный компонент pkexec (PolicyKit). Установите его или скачайте новый deb с shacraft.ru/help#launcher.".into())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_deleted_installed_binary() -> bool {
|
||||
std::env::current_exe()
|
||||
.is_ok_and(|path| path == Path::new("/usr/bin/shacraft-launcher (deleted)"))
|
||||
}
|
||||
|
||||
pub(crate) fn restart() -> Result<(), String> {
|
||||
if !trusted_root_path(Path::new(BINARY), true) {
|
||||
return Err("Установленный лаунчер недоступен. Запустите его из меню приложений.".into());
|
||||
}
|
||||
Command::new(BINARY).spawn().map_err(|_| {
|
||||
"Не удалось перезапустить лаунчер. Запустите его из меню приложений.".to_string()
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_input(mut output: impl Write, metadata: &[u8], bytes: &[u8]) -> io::Result<()> {
|
||||
if metadata.len() > MAX_METADATA_BYTES || bytes.len() > MAX_ARTIFACT_BYTES {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"update exceeds input limit",
|
||||
));
|
||||
}
|
||||
output.write_all(INPUT_MAGIC)?;
|
||||
output.write_all(&(metadata.len() as u64).to_be_bytes())?;
|
||||
output.write_all(metadata)?;
|
||||
output.write_all(&(bytes.len() as u64).to_be_bytes())?;
|
||||
output.write_all(bytes)
|
||||
}
|
||||
|
||||
fn read_part(input: &mut impl Read, maximum: usize) -> io::Result<Vec<u8>> {
|
||||
let mut length = [0_u8; 8];
|
||||
input.read_exact(&mut length)?;
|
||||
let length = u64::from_be_bytes(length);
|
||||
if length == 0 || length > maximum as u64 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"invalid update input length",
|
||||
));
|
||||
}
|
||||
let mut bytes = vec![0; length as usize];
|
||||
input.read_exact(&mut bytes)?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn read_input(mut input: impl Read) -> io::Result<(Value, Vec<u8>)> {
|
||||
let mut magic = [0_u8; 8];
|
||||
input.read_exact(&mut magic)?;
|
||||
if &magic != INPUT_MAGIC {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"invalid protocol",
|
||||
));
|
||||
}
|
||||
let metadata = read_part(&mut input, MAX_METADATA_BYTES)?;
|
||||
let bytes = read_part(&mut input, MAX_ARTIFACT_BYTES)?;
|
||||
let mut trailing = [0_u8];
|
||||
if input.read(&mut trailing)? != 0 {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "trailing input"));
|
||||
}
|
||||
let raw = serde_json::from_slice(&metadata)
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid metadata"))?;
|
||||
Ok((raw, bytes))
|
||||
}
|
||||
|
||||
fn exit_message(code: Option<i32>) -> String {
|
||||
match code {
|
||||
Some(0) => "",
|
||||
Some(126) => "Установка отменена в системном окне. Текущая версия лаунчера сохранена.",
|
||||
Some(127) => "Система не разрешила установку. Подтвердите права администратора в системном окне; при его отсутствии проверьте PolicyKit.",
|
||||
Some(REJECTED) => "Системная проверка подписи или версии deb не пройдена. Установка отменена.",
|
||||
Some(LOCKED) => "Пакетный менеджер занят другой установкой. Дождитесь её завершения и нажмите «Обновить» ещё раз.",
|
||||
Some(INVALID_HOST) => "Системная установка ShaCraft не подтверждена. Установите новый deb вручную с shacraft.ru/help#launcher.",
|
||||
_ => "Пакетный менеджер не завершил установку. Проверьте состояние пакетов в системе и повторите попытку; при необходимости установите deb вручную.",
|
||||
}.to_owned()
|
||||
}
|
||||
|
||||
pub(crate) fn install(update: &Update, bytes: &[u8]) -> Result<(), String> {
|
||||
if !installed_binary_supported() {
|
||||
return Err(exit_message(Some(INVALID_HOST)));
|
||||
}
|
||||
if let Some(reason) = unsupported_reason() {
|
||||
return Err(reason);
|
||||
}
|
||||
let metadata =
|
||||
serde_json::to_vec(&update.raw_json).map_err(|_| exit_message(Some(REJECTED)))?;
|
||||
let mut child = Command::new(PKEXEC)
|
||||
.args(["--disable-internal-agent", BINARY, HELPER_FLAG])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|_| exit_message(Some(127)))?;
|
||||
// Always wait even on EPIPE: declining the system dialog closes stdin, and
|
||||
// its exit status is the useful cancellation result, not "broken pipe".
|
||||
let write_result = write_input(
|
||||
child.stdin.take().expect("piped helper input"),
|
||||
&metadata,
|
||||
bytes,
|
||||
);
|
||||
let status = child.wait().map_err(|_| exit_message(None))?;
|
||||
if status.success() && write_result.is_ok() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(exit_message(status.code().filter(|code| *code != 0)))
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_deb_release(raw: &Value, bytes: &[u8], key: &str, installed: &str) -> Result<String, ()> {
|
||||
let metadata = updater::verified_metadata(raw, key).map_err(|_| ())?;
|
||||
if !updater::newer_version(&metadata, installed).map_err(|_| ())? {
|
||||
return Err(());
|
||||
}
|
||||
let version = metadata["version"].as_str().ok_or(())?;
|
||||
let target = format!("linux-{}-deb", std::env::consts::ARCH);
|
||||
let artifact = metadata["platforms"].get(&target).ok_or(())?;
|
||||
let url = Url::parse(artifact["url"].as_str().ok_or(())?).map_err(|_| ())?;
|
||||
updater::validate_download_url(&url, version, InstallationKind::Deb).map_err(|_| ())?;
|
||||
updater::verify_signature(bytes, artifact["signature"].as_str().ok_or(())?, key)
|
||||
.map_err(|_| ())?;
|
||||
Ok(version.to_owned())
|
||||
}
|
||||
|
||||
fn valid_package_fields(output: &[u8], version: &str) -> bool {
|
||||
std::str::from_utf8(output)
|
||||
.is_ok_and(|text| text == format!("{PACKAGE}\n{version}\n{}\n", architecture()))
|
||||
}
|
||||
|
||||
fn lock_error(stderr: &[u8]) -> bool {
|
||||
let text = String::from_utf8_lossy(stderr).to_ascii_lowercase();
|
||||
(text.contains("lock")
|
||||
&& (text.contains("locked")
|
||||
|| text.contains("another process")
|
||||
|| text.contains("resource temporarily unavailable")
|
||||
|| text.contains("unable to acquire")))
|
||||
|| text.contains("dpkg frontend lock was locked")
|
||||
}
|
||||
|
||||
fn embedded_key() -> Result<String, ()> {
|
||||
let config: Value = serde_json::from_str(include_str!("../tauri.conf.json")).map_err(|_| ())?;
|
||||
config["plugins"]["updater"]["pubkey"]
|
||||
.as_str()
|
||||
.map(str::to_owned)
|
||||
.ok_or(())
|
||||
}
|
||||
|
||||
fn run_helper() -> Result<(), i32> {
|
||||
// pkexec cleans the environment before executing this root-owned program.
|
||||
// Never initialize Tauri/GTK or network/account code in privileged mode.
|
||||
if unsafe { libc::geteuid() } != 0 || !installed_binary_supported() {
|
||||
return Err(INVALID_HOST);
|
||||
}
|
||||
let installed = installed_version().map_err(|_| INVALID_HOST)?;
|
||||
let (raw, bytes) = read_input(io::stdin().lock()).map_err(|_| REJECTED)?;
|
||||
let version = verify_deb_release(
|
||||
&raw,
|
||||
&bytes,
|
||||
&embedded_key().map_err(|_| REJECTED)?,
|
||||
&installed,
|
||||
)
|
||||
.map_err(|_| REJECTED)?;
|
||||
// No untrusted filesystem object crosses the privilege boundary. This
|
||||
// directory is created by root, mode 0700, after all signature checks.
|
||||
if !trusted_root_path(Path::new("/var"), false)
|
||||
|| !safe_sticky_temporary_parent(Path::new("/var/tmp"))
|
||||
{
|
||||
return Err(INVALID_HOST);
|
||||
}
|
||||
let temp = tempfile::Builder::new()
|
||||
.prefix("shacraft-update-")
|
||||
.tempdir_in("/var/tmp")
|
||||
.map_err(|_| INSTALL_FAILED)?;
|
||||
fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o700))
|
||||
.map_err(|_| INSTALL_FAILED)?;
|
||||
let package = temp.path().join("release.deb");
|
||||
let mut output = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&package)
|
||||
.map_err(|_| INSTALL_FAILED)?;
|
||||
output
|
||||
.set_permissions(fs::Permissions::from_mode(0o600))
|
||||
.map_err(|_| INSTALL_FAILED)?;
|
||||
output
|
||||
.write_all(&bytes)
|
||||
.and_then(|_| output.sync_all())
|
||||
.map_err(|_| INSTALL_FAILED)?;
|
||||
drop(output);
|
||||
let fields = capture(
|
||||
fixed_command(DEB)
|
||||
.arg("--show")
|
||||
.arg("--showformat=${Package}\n${Version}\n${Architecture}\n")
|
||||
.arg(&package),
|
||||
)
|
||||
.map_err(|_| REJECTED)?;
|
||||
if !fields.status.success() || !valid_package_fields(&fields.stdout, &version) {
|
||||
return Err(REJECTED);
|
||||
}
|
||||
// Check again immediately before mutation: another updater might have
|
||||
// installed the release while the authentication dialog was open.
|
||||
if !updater::newer_version(
|
||||
&serde_json::json!({"version": version}),
|
||||
&installed_version().map_err(|_| INVALID_HOST)?,
|
||||
)
|
||||
.map_err(|_| REJECTED)?
|
||||
{
|
||||
return Err(REJECTED);
|
||||
}
|
||||
let result = capture(
|
||||
fixed_command(DPKG)
|
||||
.args(["--refuse-downgrade", "--install"])
|
||||
.arg(&package),
|
||||
)
|
||||
.map_err(|_| INSTALL_FAILED)?;
|
||||
if !result.status.success() {
|
||||
return Err(if lock_error(&result.stderr) {
|
||||
LOCKED
|
||||
} else {
|
||||
INSTALL_FAILED
|
||||
});
|
||||
}
|
||||
if installed_version().map_err(|_| INSTALL_FAILED)? != version {
|
||||
return Err(INSTALL_FAILED);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The special flag is never registered as IPC and does not accept filenames.
|
||||
/// Even manually invoking it cannot bypass signatures, package identity or
|
||||
/// privilege checks. Errors intentionally print no package/metadata contents.
|
||||
pub(crate) fn run_helper_if_requested() -> Option<i32> {
|
||||
let arguments = std::env::args_os().skip(1).collect::<Vec<_>>();
|
||||
if !arguments.iter().any(|argument| argument == HELPER_FLAG) {
|
||||
return None;
|
||||
}
|
||||
if arguments.len() != 1 || arguments[0] != HELPER_FLAG {
|
||||
return Some(REJECTED);
|
||||
}
|
||||
Some(match run_helper() {
|
||||
Ok(()) => 0,
|
||||
Err(code) => code,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn framed_input_rejects_oversized_truncated_and_trailing_data() {
|
||||
let mut bytes = Vec::new();
|
||||
write_input(&mut bytes, b"{}", b"package").unwrap();
|
||||
let (metadata, package) = read_input(&bytes[..]).unwrap();
|
||||
assert_eq!(metadata, serde_json::json!({}));
|
||||
assert_eq!(package, b"package");
|
||||
assert!(read_input(&bytes[..bytes.len() - 1]).is_err());
|
||||
bytes.push(0);
|
||||
assert!(read_input(&bytes[..]).is_err());
|
||||
let mut oversized = INPUT_MAGIC.to_vec();
|
||||
oversized.extend_from_slice(&u64::MAX.to_be_bytes());
|
||||
assert!(read_input(&oversized[..]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_identity_version_architecture_are_exact() {
|
||||
let good = format!("{PACKAGE}\n0.1.4\n{}\n", architecture());
|
||||
assert!(valid_package_fields(good.as_bytes(), "0.1.4"));
|
||||
for wrong in [
|
||||
good.replace(PACKAGE, "another-package"),
|
||||
good.replace("0.1.4", "0.1.5"),
|
||||
good.replace(architecture(), "all"),
|
||||
format!("{good}extra\n"),
|
||||
] {
|
||||
assert!(!valid_package_fields(wrong.as_bytes(), "0.1.4"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_authorization_and_package_lock_remain_distinct() {
|
||||
assert!(exit_message(Some(126)).contains("отменена"));
|
||||
assert!(exit_message(Some(127)).contains("не разрешила"));
|
||||
assert!(exit_message(Some(LOCKED)).contains("занят"));
|
||||
assert!(lock_error(
|
||||
b"dpkg: error: dpkg frontend lock was locked by another process"
|
||||
));
|
||||
assert!(!lock_error(
|
||||
b"dpkg: dependency problems prevent configuration"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn privileged_path_rejects_user_owned_files_symlinks_and_relative_paths() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let file = directory.path().join("launcher");
|
||||
fs::write(&file, b"file").unwrap();
|
||||
fs::set_permissions(&file, fs::Permissions::from_mode(0o777)).unwrap();
|
||||
assert!(!trusted_root_path(&file, true));
|
||||
let link = directory.path().join("link");
|
||||
std::os::unix::fs::symlink("/usr/bin/dpkg", &link).unwrap();
|
||||
assert!(!trusted_root_path(&link, true));
|
||||
assert!(!trusted_root_path(Path::new("usr/bin/dpkg"), true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_verification_does_not_accept_legacy_appimage_as_deb() {
|
||||
let fixture: Value =
|
||||
serde_json::from_str(include_str!("../tests/fixtures/updater-signed.json")).unwrap();
|
||||
assert!(verify_deb_release(
|
||||
&fixture["metadata"],
|
||||
fixture["artifactText"].as_str().unwrap().as_bytes(),
|
||||
fixture["publicKey"].as_str().unwrap(),
|
||||
"0.0.0"
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspection_timeout_kills_descendants_holding_output_pipes() {
|
||||
let start = Instant::now();
|
||||
let result = capture_with_deadline(
|
||||
Command::new("/bin/sh").args(["-c", "sleep 30 & exit 0"]),
|
||||
Duration::from_millis(100),
|
||||
);
|
||||
assert!(matches!(result, Err(error) if error.kind() == io::ErrorKind::TimedOut));
|
||||
assert!(start.elapsed() < Duration::from_secs(3));
|
||||
let output = capture(fixed_command(DEB).arg("--version")).unwrap();
|
||||
assert!(output.status.success());
|
||||
assert!(String::from_utf8_lossy(&output.stdout).contains("Debian"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_output_is_bounded_and_fully_drained() {
|
||||
let input = vec![b'x'; OUTPUT_LIMIT * 4];
|
||||
assert_eq!(drain_capped(input.as_slice()).unwrap().len(), OUTPUT_LIMIT);
|
||||
}
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
//! Fabric metadata and Maven are fixed, independent game trust domains.
|
||||
//! The signed ShaCraft manifest selects versions, never arbitrary loader URLs.
|
||||
use crate::mojang::{Artifact, LibraryDownloads, VersionJson};
|
||||
use reqwest::blocking::Client;
|
||||
use serde::Deserialize;
|
||||
use std::{io::Read, time::Duration};
|
||||
|
||||
pub(crate) const MAVEN_HOST: &str = "maven.fabricmc.net";
|
||||
const HOSTS: [&str; 2] = ["meta.fabricmc.net", MAVEN_HOST];
|
||||
const MAX_PROFILE: usize = 512 * 1024;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FabricLibrary {
|
||||
name: String,
|
||||
url: String,
|
||||
sha1: Option<String>,
|
||||
size: Option<u64>,
|
||||
}
|
||||
|
||||
fn coordinate_path(name: &str) -> Result<String, String> {
|
||||
let pieces: Vec<_> = name.split(':').collect();
|
||||
if pieces.len() != 3
|
||||
|| pieces.iter().any(|p| {
|
||||
!crate::manifest::is_portable_component(p)
|
||||
|| *p == "."
|
||||
|| *p == ".."
|
||||
|| !p
|
||||
.bytes()
|
||||
.all(|c| c.is_ascii_alphanumeric() || b"._+-".contains(&c))
|
||||
})
|
||||
|| pieces[0].split('.').any(|p| !crate::manifest::is_portable_component(p))
|
||||
{
|
||||
return Err("Invalid Fabric library coordinate".into());
|
||||
}
|
||||
Ok(format!(
|
||||
"{}/{}/{}/{}-{}.jar",
|
||||
pieces[0].replace('.', "/"),
|
||||
pieces[1],
|
||||
pieces[2],
|
||||
pieces[1],
|
||||
pieces[2]
|
||||
))
|
||||
}
|
||||
|
||||
fn get(client: &Client, url: &str, limit: usize) -> Result<Vec<u8>, String> {
|
||||
let response = client
|
||||
.get(url)
|
||||
.send()
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut bytes = Vec::new();
|
||||
response
|
||||
.take((limit + 1) as u64)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if bytes.len() > limit {
|
||||
return Err("Fabric metadata exceeds its size limit".into());
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
pub(crate) fn fetch_profile(minecraft: &str, loader: &str) -> Result<VersionJson, String> {
|
||||
let client =
|
||||
crate::trusted_http::client(&HOSTS, Duration::from_secs(30)).map_err(|e| e.to_string())?;
|
||||
// Defence in depth: these values normally already passed manifest validation.
|
||||
if [minecraft, loader].iter().any(|v| {
|
||||
!crate::manifest::is_portable_component(v)
|
||||
|| v.contains('/')
|
||||
|| !v
|
||||
.bytes()
|
||||
.all(|c| c.is_ascii_alphanumeric() || b"._+-".contains(&c))
|
||||
}) {
|
||||
return Err("Invalid Fabric version".into());
|
||||
}
|
||||
let bytes = get(
|
||||
&client,
|
||||
&format!("https://meta.fabricmc.net/v2/versions/loader/{minecraft}/{loader}/profile/json"),
|
||||
MAX_PROFILE,
|
||||
)?;
|
||||
normalize(&client, &bytes, minecraft, loader)
|
||||
}
|
||||
|
||||
fn normalize(
|
||||
client: &Client,
|
||||
bytes: &[u8],
|
||||
minecraft: &str,
|
||||
loader: &str,
|
||||
) -> Result<VersionJson, String> {
|
||||
// Flattening libraries would consume the same key twice; parse the two views explicitly.
|
||||
let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| e.to_string())?;
|
||||
let parent = value.get("inheritsFrom").and_then(|v| v.as_str());
|
||||
let mut version: VersionJson =
|
||||
serde_json::from_value(value.clone()).map_err(|e| e.to_string())?;
|
||||
if parent != Some(minecraft)
|
||||
|| version.id != format!("fabric-loader-{loader}-{minecraft}")
|
||||
|| version.main_class != "net.fabricmc.loader.impl.launch.knot.KnotClient"
|
||||
{
|
||||
return Err("Fabric profile identity mismatch".into());
|
||||
}
|
||||
let artifacts: Vec<FabricLibrary> =
|
||||
serde_json::from_value(value["libraries"].clone()).map_err(|e| e.to_string())?;
|
||||
if artifacts.is_empty() || artifacts.len() > 32 {
|
||||
return Err("Invalid Fabric library count".into());
|
||||
}
|
||||
for (library, artifact) in version.libraries.iter_mut().zip(artifacts) {
|
||||
if artifact.url != "https://maven.fabricmc.net/" {
|
||||
return Err("Untrusted Fabric Maven URL".into());
|
||||
}
|
||||
let path = coordinate_path(&artifact.name)?;
|
||||
let url = format!("https://{MAVEN_HOST}/{path}");
|
||||
let sha1 = match artifact.sha1 {
|
||||
Some(hash) => hash,
|
||||
None => String::from_utf8(get(client, &format!("{url}.sha1"), 128)?)
|
||||
.map_err(|e| e.to_string())?
|
||||
.trim()
|
||||
.to_owned(),
|
||||
};
|
||||
let size = match artifact.size {
|
||||
Some(size) => size,
|
||||
None => client
|
||||
.head(&url)
|
||||
.send()
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_LENGTH)
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.ok_or("Fabric library has no size")?,
|
||||
};
|
||||
if sha1.len() != 40
|
||||
|| !sha1.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
|| size == 0
|
||||
|| size > 64 * 1024 * 1024
|
||||
{
|
||||
return Err("Invalid Fabric library hash or size".into());
|
||||
}
|
||||
library.downloads = Some(LibraryDownloads {
|
||||
artifact: Some(Artifact {
|
||||
path,
|
||||
url,
|
||||
sha1,
|
||||
size,
|
||||
}),
|
||||
});
|
||||
}
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn maven_coordinates_cannot_escape_library_directory() {
|
||||
assert_eq!(
|
||||
coordinate_path("net.fabricmc:fabric-loader:0.19.5").unwrap(),
|
||||
"net/fabricmc/fabric-loader/0.19.5/fabric-loader-0.19.5.jar"
|
||||
);
|
||||
for bad in [
|
||||
"x:y:../evil",
|
||||
"a..b:c:1",
|
||||
"x:/tmp:1",
|
||||
"x:y:1:extra",
|
||||
"x:y:\\evil",
|
||||
"x:y:..",
|
||||
"CON:y:1",
|
||||
"a:y.:1",
|
||||
"a:AUX:1",
|
||||
] {
|
||||
assert!(coordinate_path(bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn loader_identity_and_maven_origin_are_checked_before_downloads() {
|
||||
let client = Client::new();
|
||||
let base = serde_json::json!({"id":"fabric-loader-0.19.5-26.2", "inheritsFrom":"26.2", "mainClass":"net.fabricmc.loader.impl.launch.knot.KnotClient", "libraries":[{"name":"net.fabricmc:fabric-loader:0.19.5", "url":"https://maven.fabricmc.net/", "sha1":"a".repeat(40), "size":42}]});
|
||||
assert!(normalize(
|
||||
&client,
|
||||
&serde_json::to_vec(&base).unwrap(),
|
||||
"26.2",
|
||||
"0.19.5"
|
||||
)
|
||||
.is_ok());
|
||||
for (field, value) in [
|
||||
("inheritsFrom", "1.21.1"),
|
||||
("mainClass", "attacker.Main"),
|
||||
("id", "wrong"),
|
||||
] {
|
||||
let mut bad = base.clone();
|
||||
bad[field] = value.into();
|
||||
assert!(normalize(
|
||||
&client,
|
||||
&serde_json::to_vec(&bad).unwrap(),
|
||||
"26.2",
|
||||
"0.19.5"
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
let mut bad = base;
|
||||
bad["libraries"][0]["url"] = "https://attacker.invalid/".into();
|
||||
assert!(normalize(
|
||||
&client,
|
||||
&serde_json::to_vec(&bad).unwrap(),
|
||||
"26.2",
|
||||
"0.19.5"
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "downloads official Fabric profile metadata"]
|
||||
fn live_resolves_fabric_26_2() {
|
||||
let profile = fetch_profile("26.2", "0.19.5").unwrap();
|
||||
assert!(profile
|
||||
.libraries
|
||||
.iter()
|
||||
.all(|library| library.downloads.is_some()));
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
+23
-90
@@ -1,10 +1,11 @@
|
||||
//! Builds and spawns the real `java` invocation for a merged launch
|
||||
//! profile. The `${auth_*}` placeholders use only the identity bound to the
|
||||
//! server-issued admission ticket. Its one-use proof stays out of arguments
|
||||
//! and files; only the Java child's environment receives it.
|
||||
//! profile. The `${auth_*}` placeholders are filled from a `PlayerIdentity`,
|
||||
//! which is either a real Microsoft-authenticated session (`msa::LoginResult`)
|
||||
//! or an explicit offline account (`PlayerIdentity::Offline`). Offline mode is
|
||||
//! never silently substituted for a Microsoft session.
|
||||
|
||||
use crate::admission::Admission;
|
||||
use crate::mojang::{self, MergedVersion};
|
||||
use crate::session::PlayerIdentity;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
@@ -43,9 +44,11 @@ pub struct LaunchRequest<'a> {
|
||||
/// the shared `game_dir`.
|
||||
pub profile_dir: &'a Path,
|
||||
pub merged: &'a MergedVersion,
|
||||
pub admission: &'a Admission,
|
||||
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 {
|
||||
@@ -182,10 +185,6 @@ fn write_jvm_argfile(path: &Path, arguments: &[String]) -> io::Result<()> {
|
||||
/// that (see `lib.rs`'s launch command, which watches it on a background
|
||||
/// thread and emits an event).
|
||||
pub fn launch(request: &LaunchRequest) -> Result<Child, LaunchError> {
|
||||
Ok(build_command(request)?.spawn()?)
|
||||
}
|
||||
|
||||
fn build_command(request: &LaunchRequest) -> Result<Command, LaunchError> {
|
||||
fs::create_dir_all(request.profile_dir)?;
|
||||
let natives_dir = mojang::natives_directory(request.game_dir, &request.merged.id);
|
||||
fs::create_dir_all(&natives_dir)?;
|
||||
@@ -196,8 +195,7 @@ fn build_command(request: &LaunchRequest) -> Result<Command, LaunchError> {
|
||||
let classpath = build_classpath(request.game_dir, request.merged, &client_jar);
|
||||
|
||||
let mut vars: HashMap<&str, String> = HashMap::new();
|
||||
let identity = request.admission.identity();
|
||||
vars.insert("auth_player_name", identity.name().to_string());
|
||||
vars.insert("auth_player_name", request.identity.name().to_string());
|
||||
// NeoForge's inherited JVM profile uses `${version_name}.jar` in
|
||||
// `-DignoreList`. The actual client jar belongs to the vanilla parent
|
||||
// (`1.21.1.jar`), not to the child profile (`neoforge-...`), so this
|
||||
@@ -207,11 +205,14 @@ fn build_command(request: &LaunchRequest) -> Result<Command, LaunchError> {
|
||||
vars.insert("game_directory", request.profile_dir.display().to_string());
|
||||
vars.insert("assets_root", assets_root.display().to_string());
|
||||
vars.insert("assets_index_name", request.merged.asset_index.id.clone());
|
||||
vars.insert("auth_uuid", identity.uuid());
|
||||
vars.insert("auth_access_token", identity.access_token().to_string());
|
||||
vars.insert("auth_uuid", request.identity.uuid());
|
||||
vars.insert(
|
||||
"auth_access_token",
|
||||
request.identity.access_token().to_string(),
|
||||
);
|
||||
vars.insert("clientid", launcher_client_id(request.game_dir)?);
|
||||
vars.insert("auth_xuid", identity.xuid().to_string());
|
||||
vars.insert("user_type", identity.user_type().to_string());
|
||||
vars.insert("auth_xuid", request.identity.xuid().to_string());
|
||||
vars.insert("user_type", request.identity.user_type().to_string());
|
||||
vars.insert("version_type", "ShaCraft Launcher".to_string());
|
||||
vars.insert("natives_directory", natives_dir.display().to_string());
|
||||
vars.insert("launcher_name", "ShaCraft Launcher".to_string());
|
||||
@@ -228,7 +229,6 @@ fn build_command(request: &LaunchRequest) -> Result<Command, LaunchError> {
|
||||
let game_args = mojang::resolve_arguments(&request.merged.game_arguments, &no_features);
|
||||
|
||||
let mut command = Command::new(request.java_executable);
|
||||
request.admission.configure_child(&mut command);
|
||||
let memory_argument = format!("-Xmx{}M", request.memory_mb);
|
||||
if cfg!(windows) {
|
||||
let argfile = request.profile_dir.join(".shacraft-jvm.args");
|
||||
@@ -245,92 +245,25 @@ fn build_command(request: &LaunchRequest) -> Result<Command, LaunchError> {
|
||||
for argument in game_args {
|
||||
command.arg(substitute(&argument, &vars));
|
||||
}
|
||||
// This endpoint is native-owned; a manifest cannot redirect game admission.
|
||||
if request.admission.server_id() == "minigames" {
|
||||
command.args(["--quickPlayMultiplayer", "135.106.154.86:25568"]);
|
||||
}
|
||||
command.current_dir(request.profile_dir);
|
||||
// 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()?));
|
||||
command.stderr(Stdio::from(log_file));
|
||||
|
||||
Ok(command)
|
||||
Ok(command.spawn()?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn launch_arguments_and_written_files_never_contain_admission_secrets() {
|
||||
use crate::admission::{AdmissionKey, PRIVATE_KEY_ENV, TICKET_ENV};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
|
||||
let directory =
|
||||
std::env::temp_dir().join(format!("shacraft-launch-proof-{}", random_uuid_v4()));
|
||||
let game_dir = directory.join("game");
|
||||
let profile_dir = directory.join("profile");
|
||||
let log_path = directory.join("game.log");
|
||||
for server_id in ["aoc", "minigames"] {
|
||||
let vanilla: mojang::VersionJson = serde_json::from_value(serde_json::json!({
|
||||
"id": "1.21.1",
|
||||
"mainClass": "net.minecraft.client.main.Main",
|
||||
"arguments": {
|
||||
"game": ["--username", "${auth_player_name}", "--uuid", "${auth_uuid}", "--accessToken", "${auth_access_token}"],
|
||||
"jvm": ["-cp", "${classpath}", "-Dlauncher=${launcher_name}"]
|
||||
},
|
||||
"assetIndex": {"id": "17", "sha1": "0".repeat(40), "size": 1, "url": "https://piston-meta.mojang.com/assets"},
|
||||
"downloads": {"client": {"sha1": "0".repeat(40), "size": 1, "url": "https://piston-data.mojang.com/client.jar"}}
|
||||
})).unwrap();
|
||||
let merged = mojang::merge_versions(&vanilla, None).unwrap();
|
||||
let response = serde_json::from_value(serde_json::json!({
|
||||
"ticket_id": URL_SAFE_NO_PAD.encode([73_u8; 32]), "mc_username": "Ticket_Name",
|
||||
"server_id": server_id, "expires_in_seconds": 600
|
||||
}))
|
||||
.unwrap();
|
||||
let admission = AdmissionKey::generate(server_id).unwrap().bind(response).unwrap();
|
||||
let request = LaunchRequest {
|
||||
java_executable: Path::new("java"),
|
||||
game_dir: &game_dir,
|
||||
profile_dir: &profile_dir,
|
||||
merged: &merged,
|
||||
admission: &admission,
|
||||
memory_mb: 6144,
|
||||
log_path: &log_path,
|
||||
};
|
||||
let command = build_command(&request).unwrap();
|
||||
let env: HashMap<_, _> = command.get_envs().collect();
|
||||
let proof = [TICKET_ENV, PRIVATE_KEY_ENV]
|
||||
.map(|name| env[std::ffi::OsStr::new(name)].unwrap().to_str().unwrap());
|
||||
let arguments: Vec<_> = command
|
||||
.get_args()
|
||||
.map(|arg| arg.to_string_lossy())
|
||||
.collect();
|
||||
assert!(arguments
|
||||
.windows(2)
|
||||
.any(|args| args == ["--username", "Ticket_Name"]));
|
||||
assert!(arguments
|
||||
.windows(2)
|
||||
.any(|args| args == ["--accessToken", "0"]));
|
||||
assert_eq!(arguments.windows(2).any(|pair| pair == ["--quickPlayMultiplayer", "135.106.154.86:25568"]), server_id == "minigames");
|
||||
for secret in proof {
|
||||
assert!(arguments.iter().all(|argument| !argument.contains(secret)));
|
||||
for path in [
|
||||
log_path.clone(),
|
||||
game_dir.join(".shacraft-client-id"),
|
||||
profile_dir.join(".shacraft-jvm.args"),
|
||||
] {
|
||||
if path.exists() {
|
||||
assert!(!fs::read_to_string(path).unwrap().contains(secret));
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(command);
|
||||
}
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generates_rfc4122_version_4_uuids() {
|
||||
let id = random_uuid_v4();
|
||||
|
||||
+39
-27
@@ -1,8 +1,9 @@
|
||||
mod admission;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod deb_updater;
|
||||
mod download;
|
||||
mod fabric;
|
||||
mod update_guard;
|
||||
mod updater;
|
||||
use tauri::Manager;
|
||||
mod installation_lock;
|
||||
mod inventory;
|
||||
mod java;
|
||||
mod launch;
|
||||
mod manifest;
|
||||
@@ -17,37 +18,49 @@ mod settings;
|
||||
mod shacraft_account;
|
||||
mod storage;
|
||||
mod trusted_http;
|
||||
mod updater;
|
||||
|
||||
mod commands;
|
||||
mod operations;
|
||||
|
||||
pub fn run() {
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(code) = deb_updater::run_helper_if_requested() {
|
||||
std::process::exit(code);
|
||||
}
|
||||
use tauri::Manager;
|
||||
tauri::Builder::default()
|
||||
.manage(operations::LauncherOperations::default())
|
||||
.manage(updater::LauncherUpdater::default())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.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 {
|
||||
// Do not interrupt the short package replacement step. A
|
||||
// download can safely be abandoned before any file changes.
|
||||
if let Some(updater) = window.try_state::<updater::LauncherUpdater>() {
|
||||
if updater
|
||||
.state
|
||||
.lock()
|
||||
.is_ok_and(|state| state.stage == updater::Stage::Installing)
|
||||
{
|
||||
api.prevent_close();
|
||||
}
|
||||
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,
|
||||
@@ -55,23 +68,22 @@ 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,
|
||||
commands::shacraft::get_shacraft_account,
|
||||
commands::shacraft::shacraft_logout,
|
||||
commands::shacraft::shacraft_start_link,
|
||||
commands::shacraft::shacraft_claim_nickname,
|
||||
commands::shacraft::shacraft_link_status,
|
||||
commands::account::start_microsoft_login,
|
||||
commands::account::get_account,
|
||||
commands::account::logout,
|
||||
commands::game::ensure_game_installed,
|
||||
commands::game::launch_game,
|
||||
commands::updater::get_launcher_update_status,
|
||||
commands::updater::check_launcher_update,
|
||||
commands::updater::install_launcher_update,
|
||||
commands::updater::restart_launcher_after_update
|
||||
commands::game::launch_onboarding
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running ShaCraft Launcher");
|
||||
|
||||
@@ -91,7 +91,7 @@ fn validate(manifest: &Manifest) -> Result<(), ManifestError> {
|
||||
));
|
||||
}
|
||||
if !is_version(&manifest.minecraft.version)
|
||||
|| !matches!(manifest.minecraft.loader.kind.as_str(), "neoforge" | "fabric" | "vanilla")
|
||||
|| manifest.minecraft.loader.kind.trim().is_empty()
|
||||
|| !is_version(&manifest.minecraft.loader.version)
|
||||
{
|
||||
return Err(ManifestError::Invalid(
|
||||
|
||||
@@ -58,7 +58,6 @@ pub fn library_http_client() -> Result<Client, reqwest::Error> {
|
||||
MOJANG_HOSTS[2],
|
||||
MOJANG_HOSTS[3],
|
||||
crate::neoforge::NEOFORGE_HOST,
|
||||
crate::fabric::MAVEN_HOST,
|
||||
],
|
||||
std::time::Duration::from_secs(10 * 60),
|
||||
)
|
||||
@@ -68,7 +67,7 @@ pub fn library_http_client() -> Result<Client, reqwest::Error> {
|
||||
/// own fixed Maven. The profile itself comes from the SHA-256-verified
|
||||
/// NeoForge installer, never from the ShaCraft manifest.
|
||||
fn is_allowed_library_host(url: &str) -> bool {
|
||||
is_allowed_host(url) || crate::neoforge::is_allowed_host(url) || crate::trusted_http::allows(url, &[crate::fabric::MAVEN_HOST])
|
||||
is_allowed_host(url) || crate::neoforge::is_allowed_host(url)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
+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());
|
||||
}
|
||||
}
|
||||
+60
-69
@@ -3,46 +3,17 @@
|
||||
//! 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(Clone, Default)]
|
||||
#[derive(Default)]
|
||||
pub(crate) struct LauncherOperations {
|
||||
pub lifecycle: Lifecycle,
|
||||
recovery: Mutex<Option<String>>,
|
||||
pub installation: Operation,
|
||||
pub account: Operation,
|
||||
pub shacraft_account: Operation,
|
||||
// Held from launch scheduling until Child::wait completes, not just spawn.
|
||||
pub game: Operation,
|
||||
}
|
||||
|
||||
impl LauncherOperations {
|
||||
/// Acquire every mutation gate without waiting. Partial acquisition is
|
||||
/// rolled back by RAII, so a failed update cannot strand an account gate.
|
||||
pub fn acquire_update(&self) -> Result<UpdatePermits, String> {
|
||||
let installation = self.installation.acquire("Установка игры")?;
|
||||
let account = self.account.acquire("Вход в аккаунт")?;
|
||||
let shacraft_account = self
|
||||
.shacraft_account
|
||||
.acquire("Операция с аккаунтом ShaCraft")?;
|
||||
let game = self
|
||||
.game
|
||||
.acquire("Игра")
|
||||
.map_err(|_| "Закройте Minecraft перед обновлением лаунчера.".to_string())?;
|
||||
Ok(UpdatePermits {
|
||||
_installation: installation,
|
||||
_account: account,
|
||||
_shacraft_account: shacraft_account,
|
||||
_game: game,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct UpdatePermits {
|
||||
_installation: Permit,
|
||||
_account: Permit,
|
||||
_shacraft_account: Permit,
|
||||
_game: Permit,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
@@ -67,7 +38,7 @@ impl Drop for Permit {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{LauncherOperations, Operation};
|
||||
use super::Operation;
|
||||
|
||||
#[test]
|
||||
fn rejects_overlap_and_releases_on_worker_error() {
|
||||
@@ -80,43 +51,63 @@ mod tests {
|
||||
assert!(worker().is_err());
|
||||
assert!(operation.acquire("Installation").is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_game_blocks_update_and_partial_locks_are_released() {
|
||||
let operations = LauncherOperations::default();
|
||||
let game = operations.game.acquire("game").unwrap();
|
||||
assert!(operations.acquire_update().is_err());
|
||||
assert!(operations.installation.acquire("install").is_ok());
|
||||
assert!(operations.account.acquire("account").is_ok());
|
||||
assert!(operations
|
||||
.shacraft_account
|
||||
.acquire("ShaCraft account")
|
||||
.is_ok());
|
||||
drop(game);
|
||||
assert!(operations.acquire_update().is_ok());
|
||||
// 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()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_excludes_game_and_accounts_until_permit_drop() {
|
||||
let operations = LauncherOperations::default();
|
||||
let permit = operations.acquire_update().unwrap();
|
||||
assert!(operations.installation.acquire("install").is_err());
|
||||
assert!(operations.account.acquire("account").is_err());
|
||||
assert!(operations
|
||||
.shacraft_account
|
||||
.acquire("ShaCraft account")
|
||||
.is_err());
|
||||
assert!(operations.game.acquire("game").is_err());
|
||||
assert!(operations.acquire_update().is_err());
|
||||
drop(permit);
|
||||
assert!(operations.acquire_update().is_ok());
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_operation_blocks_update_without_stranding_installation() {
|
||||
let operations = LauncherOperations::default();
|
||||
let _account = operations.shacraft_account.acquire("account").unwrap();
|
||||
assert!(operations.acquire_update().is_err());
|
||||
assert!(operations.installation.acquire("install").is_ok());
|
||||
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(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+731
-111
@@ -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,45 +499,17 @@ 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},
|
||||
};
|
||||
|
||||
#[test]
|
||||
#[ignore = "downloads the public signed admission mod into a temporary directory"]
|
||||
fn live_admission_mod_is_restored_when_missing_or_corrupt() {
|
||||
let mut manifest = crate::remote::fetch_manifest("aeronautics").unwrap();
|
||||
manifest.files.retain(|file| {
|
||||
file.path.starts_with("mods/shacraft-admission") && file.path.ends_with(".jar")
|
||||
});
|
||||
assert_eq!(
|
||||
manifest.files.len(),
|
||||
1,
|
||||
"exactly one admission mod is required"
|
||||
);
|
||||
assert!(matches!(manifest.files[0].policy, FilePolicy::Managed));
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"shacraft-live-admission-{}-{}",
|
||||
process::id(),
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
assert_eq!(inspect(&root, &manifest).unwrap().missing_files, 1);
|
||||
super::sync(&root, &manifest).unwrap();
|
||||
assert!(inspect(&root, &manifest).unwrap().up_to_date);
|
||||
let target = root.join(&manifest.files[0].path);
|
||||
fs::write(&target, b"corrupt fixture").unwrap();
|
||||
assert!(!inspect(&root, &manifest).unwrap().up_to_date);
|
||||
super::sync(&root, &manifest).unwrap();
|
||||
assert!(inspect(&root, &manifest).unwrap().up_to_date);
|
||||
fs::remove_file(&target).unwrap();
|
||||
super::sync(&root, &manifest).unwrap();
|
||||
assert!(inspect(&root, &manifest).unwrap().up_to_date);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
fn 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 {
|
||||
@@ -245,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()
|
||||
@@ -273,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()
|
||||
@@ -291,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()
|
||||
@@ -314,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()
|
||||
@@ -332,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
-6
@@ -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},
|
||||
@@ -13,8 +14,6 @@ use std::{
|
||||
|
||||
const AERONAUTICS_MANIFEST: &str =
|
||||
"https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest";
|
||||
const MINIGAMES_MANIFEST: &str = "https://shacraft.ru/api/launcher/v2/profiles/minigames/signed-manifest";
|
||||
const MINIGAMES_ONLINE: &str = "https://shacraft.ru/api/online/minigames";
|
||||
const AERONAUTICS_ONLINE: &str = "https://shacraft.ru/api/online/aoc";
|
||||
const MANIFEST_PUBLIC_KEY: &str = "2S3FRdZj4Xw5nJpZ3IhqVITBg3nTH9AtGSo1Ew9+qVQ=";
|
||||
const MANIFEST_KEY_ID: &str = "2026-09-06";
|
||||
@@ -70,10 +69,18 @@ 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,
|
||||
"minigames" => MINIGAMES_MANIFEST,
|
||||
_ => return Err(RemoteError::UnknownProfile),
|
||||
};
|
||||
let client = Client::builder()
|
||||
@@ -93,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> {
|
||||
@@ -128,7 +135,6 @@ fn fetch_manifest_bytes(client: &Client, url: &str) -> Result<Vec<u8>, RemoteErr
|
||||
pub fn fetch_server_status(profile_id: &str) -> Result<ServerStatus, RemoteError> {
|
||||
let url = match profile_id {
|
||||
"aeronautics" => AERONAUTICS_ONLINE,
|
||||
"minigames" => MINIGAMES_ONLINE,
|
||||
_ => return Err(RemoteError::UnknownProfile),
|
||||
};
|
||||
let client = Client::builder()
|
||||
@@ -158,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);
|
||||
}
|
||||
@@ -182,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)]
|
||||
|
||||
+144
-123
@@ -6,12 +6,7 @@
|
||||
use reqwest::blocking::{Client, Response};
|
||||
use reqwest::redirect::Policy;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
fmt, fs,
|
||||
io::{self, Read},
|
||||
path::Path,
|
||||
time::Duration,
|
||||
};
|
||||
use std::{fmt, fs, io, path::Path, time::Duration};
|
||||
|
||||
const API_ORIGIN: &str = "https://shacraft.ru";
|
||||
const SESSION_FILE: &str = "shacraft-session";
|
||||
@@ -50,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)]
|
||||
@@ -67,6 +74,7 @@ pub enum AccountError {
|
||||
Api(String),
|
||||
Io(io::Error),
|
||||
InvalidSession,
|
||||
NoLinkedNickname,
|
||||
}
|
||||
|
||||
impl fmt::Display for AccountError {
|
||||
@@ -76,6 +84,9 @@ impl fmt::Display for AccountError {
|
||||
Self::Api(message) => formatter.write_str(message),
|
||||
Self::Io(error) => write!(formatter, "Не удалось сохранить сессию: {error}"),
|
||||
Self::InvalidSession => formatter.write_str("Сессия ShaCraft истекла — войдите снова"),
|
||||
Self::NoLinkedNickname => {
|
||||
formatter.write_str("Сначала привяжите игровой ник к серверу Aeronautics")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,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> {
|
||||
@@ -222,101 +318,56 @@ pub fn link_status(data_dir: &Path, challenge_id: i64) -> Result<LinkStatus, Acc
|
||||
response.json::<LinkStatus>().map_err(AccountError::Network)
|
||||
}
|
||||
|
||||
const ADMISSION_ENDPOINT: &str = "/api/launcher/v2/admission/tickets";
|
||||
|
||||
/// Error responses at this boundary never echo arbitrary response bodies: a
|
||||
/// misconfigured proxy/service must not copy credentials into UI diagnostics.
|
||||
fn admission_error(status: reqwest::StatusCode) -> AccountError {
|
||||
use reqwest::StatusCode;
|
||||
match status {
|
||||
StatusCode::UNAUTHORIZED => AccountError::InvalidSession,
|
||||
StatusCode::FORBIDDEN => AccountError::Api(
|
||||
"Нет разрешения на вход в Aeronautics. Проверьте привязку ника и доступ к серверу в аккаунте ShaCraft.".into()),
|
||||
StatusCode::CONFLICT => AccountError::Api(
|
||||
"Этот ник уже занят или зарезервирован. Если это ваш игровой ник, обратитесь в поддержку ShaCraft.".into()),
|
||||
StatusCode::NOT_FOUND | StatusCode::SERVICE_UNAVAILABLE => AccountError::Api(
|
||||
"Вход через ShaCraft Launcher пока не настроен на сервере. Повторите попытку позже.".into()),
|
||||
StatusCode::TOO_MANY_REQUESTS => AccountError::Api(
|
||||
"Слишком много запросов входа. Подождите немного и повторите попытку.".into()),
|
||||
_ => AccountError::Api(format!("Не удалось получить разрешение ShaCraft: HTTP {status}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn checked_admission_response(
|
||||
data_dir: &Path,
|
||||
response: Response,
|
||||
) -> Result<Response, AccountError> {
|
||||
if response.status().is_success() {
|
||||
return Ok(response);
|
||||
}
|
||||
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
let _ = fs::remove_file(session_path(data_dir));
|
||||
}
|
||||
Err(admission_error(response.status()))
|
||||
}
|
||||
|
||||
/// The server reserves a free nickname atomically for this account. Existing
|
||||
/// player names remain reserved for administrator-assisted migration.
|
||||
pub fn claim_nickname(data_dir: &Path, nickname: &str) -> Result<Account, AccountError> {
|
||||
let token = load_session(data_dir)?;
|
||||
let response = client()?
|
||||
.post(format!("{API_ORIGIN}/api/launcher/v2/admission/nickname"))
|
||||
.bearer_auth(token)
|
||||
.json(&serde_json::json!({"server_id": "aoc", "mc_username": nickname}))
|
||||
.send()
|
||||
.map_err(AccountError::Network)?;
|
||||
checked_admission_response(data_dir, response)?
|
||||
.json()
|
||||
.map_err(AccountError::Network)
|
||||
}
|
||||
|
||||
/// Called only after installation, immediately before Java spawn. Nothing in
|
||||
/// this response is exposed to the webview or persisted with account settings.
|
||||
pub(crate) fn issue_admission(
|
||||
data_dir: &Path,
|
||||
server_id: &'static str,
|
||||
) -> Result<crate::admission::Admission, AccountError> {
|
||||
let token = load_session(data_dir)?;
|
||||
let key = crate::admission::AdmissionKey::generate(server_id)
|
||||
.map_err(|message| AccountError::Api(message.into()))?;
|
||||
let response = client()?
|
||||
.post(format!("{API_ORIGIN}{ADMISSION_ENDPOINT}"))
|
||||
.bearer_auth(token)
|
||||
.json(&key.request())
|
||||
.send()
|
||||
.map_err(AccountError::Network)?;
|
||||
let response = checked_admission_response(data_dir, response)?;
|
||||
// The expected object is under 256 bytes; bound the remote allocation and
|
||||
// use a fixed parse error without response values or secret-bearing bodies.
|
||||
let mut body = zeroize::Zeroizing::new(Vec::new());
|
||||
response.take(4097).read_to_end(&mut body).map_err(|_| {
|
||||
AccountError::Api(
|
||||
"Не удалось прочитать разрешение на вход. Повторите попытку позже.".into(),
|
||||
)
|
||||
})?;
|
||||
let invalid = || {
|
||||
AccountError::Api(
|
||||
"Сервер вернул некорректное разрешение на вход. Повторите попытку позже.".into(),
|
||||
)
|
||||
};
|
||||
if body.len() > 4096 {
|
||||
return Err(invalid());
|
||||
}
|
||||
let payload = serde_json::from_slice(&body).map_err(|_| invalid())?;
|
||||
key.bind(payload)
|
||||
.map_err(|message| AccountError::Api(message.into()))
|
||||
pub fn aeronautics_nickname(data_dir: &Path) -> Result<String, AccountError> {
|
||||
get_account(data_dir)?
|
||||
.links
|
||||
.into_iter()
|
||||
.find(|link| link.server_id == "aoc")
|
||||
.map(|link| link.mc_username)
|
||||
.ok_or(AccountError::NoLinkedNickname)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
admission_error, issue_admission, load_session, save_session, session_path, AccountError,
|
||||
};
|
||||
use super::{load_session, save_session, session_path};
|
||||
use std::{
|
||||
fs, process,
|
||||
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-{}-{}",
|
||||
@@ -354,34 +405,4 @@ mod tests {
|
||||
);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admission_without_session_never_falls_back_to_legacy_nickname() {
|
||||
let directory = temporary_directory();
|
||||
crate::settings::save(&directory, crate::settings::LauncherSettings::default()).unwrap();
|
||||
assert!(matches!(
|
||||
issue_admission(&directory, "aoc"),
|
||||
Err(AccountError::InvalidSession)
|
||||
));
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admission_unavailable_and_revoked_session_have_actionable_errors() {
|
||||
for status in [
|
||||
reqwest::StatusCode::NOT_FOUND,
|
||||
reqwest::StatusCode::SERVICE_UNAVAILABLE,
|
||||
] {
|
||||
assert!(admission_error(status)
|
||||
.to_string()
|
||||
.contains("пока не настроен"));
|
||||
}
|
||||
assert!(matches!(
|
||||
admission_error(reqwest::StatusCode::UNAUTHORIZED),
|
||||
AccountError::InvalidSession
|
||||
));
|
||||
assert!(admission_error(reqwest::StatusCode::CONFLICT)
|
||||
.to_string()
|
||||
.contains("зарезервирован"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
+588
-777
File diff suppressed because it is too large
Load Diff
@@ -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.6",
|
||||
"version": "0.2.0",
|
||||
"identifier": "ru.shacraft.launcher",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -28,7 +28,6 @@
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"createUpdaterArtifacts": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
@@ -37,22 +36,18 @@
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"linux": {
|
||||
"deb": {
|
||||
"depends": [
|
||||
"libwebkit2gtk-4.1-0",
|
||||
"libgtk-3-0",
|
||||
"pkexec"
|
||||
]
|
||||
"windows": {
|
||||
"wix": {
|
||||
"upgradeCode": "2058b1df-56a1-51ef-bd48-d296479cd59a"
|
||||
},
|
||||
"nsis": {
|
||||
"installMode": "currentUser"
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDk0N0MzODEwMjg1OUVCNDEKUldSQjYxa29FRGg4bEdKSkFWUzZUNDZhRFN4cGIwL0FvVnl0blhrOWtSMWhSOWxHMkU1aGs5L2oK",
|
||||
"endpoints": [
|
||||
"https://shacraft.ru/launcher/updates/stable.json"
|
||||
],
|
||||
"pubkey": "",
|
||||
"windows": {
|
||||
"installMode": "passive"
|
||||
}
|
||||
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"publicKey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXkgM0FGNTgxMjY4QTBERUU5NgpSV1NXN2cyS0pvSDFPczhocEIzTmp1bjM1TnRRWWE1QnIyck00bDRmZW1sQlphQXY2MTZvTWcwZgo=",
|
||||
"metadata": {
|
||||
"notes": "Проверка обновления",
|
||||
"platforms": {
|
||||
"linux-x86_64": {
|
||||
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIG1pbmlzaWduIHNlY3JldCBrZXkKUlVTVzdnMktKb0gxT2dZZmwrT0V0b0pYbDdYU3dSang1TXJNZXdtTDZvUVNvU1FMU2ZsajJkL2d4bXlOVDdQNmt4eEExeUtGcG1zckNWcEhPVXd4TnBJOU9ublZWbHk0NFFjPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTk1ODU1CWZpbGU6Zml4dHVyZS5BcHBJbWFnZQloYXNoZWQKdWxLSklQR3pabTcxdkNaUmM3d3FHbTRRSDI5dzY4UEU0QXY3NE9MazVWdGRPallpOTQ0a2RSK1AyRDBaeEZ5eGdERFgvbmVtZEluUXc4UFdUOWV6Qnc9PQo=",
|
||||
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.3/fixture.AppImage"
|
||||
}
|
||||
},
|
||||
"pub_date": "2026-09-10T00:00:00Z",
|
||||
"version": "0.1.3",
|
||||
"signedPayload": "eyJub3RlcyI6ItCf0YDQvtCy0LXRgNC60LAg0L7QsdC90L7QstC70LXQvdC40Y8iLCJwbGF0Zm9ybXMiOnsibGludXgteDg2XzY0Ijp7InNpZ25hdHVyZSI6ImRXNTBjblZ6ZEdWa0lHTnZiVzFsYm5RNklITnBaMjVoZEhWeVpTQm1jbTl0SUcxcGJtbHphV2R1SUhObFkzSmxkQ0JyWlhrS1VsVlRWemRuTWt0S2IwZ3hUMmRaWm13clQwVjBiMHBZYkRkWVUzZFNhbmcxVFhKTlpYZHRURFp2VVZOdlUxRk1VMlpzYWpKa0wyZDRiWGxPVkRkUU5tdDRlRUV4ZVV0R2NHMXpja05XY0VoUFZYZDRUbkJKT1U5dWJsWldiSGswTkZGalBRcDBjblZ6ZEdWa0lHTnZiVzFsYm5RNklIUnBiV1Z6ZEdGdGNEb3hOemc0T1RrMU9EVTFDV1pwYkdVNlptbDRkSFZ5WlM1QmNIQkpiV0ZuWlFsb1lYTm9aV1FLZFd4TFNrbFFSM3BhYlRjeGRrTmFVbU0zZDNGSGJUUlJTREk1ZHpZNFVFVTBRWFkzTkU5TWF6VldkR1JQYWxscE9UUTBhMlJTSzFBeVJEQmFlRVo1ZUdkRVJGZ3ZibVZ0WkVsdVVYYzRVRmRVT1dWNlFuYzlQUW89IiwidXJsIjoiaHR0cHM6Ly9zaGFjcmFmdC5ydS9kb3dubG9hZHMvc2hhY3JhZnQtbGF1bmNoZXIvMC4xLjMvZml4dHVyZS5BcHBJbWFnZSJ9fSwicHViX2RhdGUiOiIyMDI2LTA5LTEwVDAwOjAwOjAwWiIsInZlcnNpb24iOiIwLjEuMyJ9",
|
||||
"metadataSignature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIG1pbmlzaWduIHNlY3JldCBrZXkKUlVTVzdnMktKb0gxT3YrcXkzN0haeGZoeHBML0pMdXJYSXRjSE1vQ2VPZkg3bFpHZjRHbWVzOG1wdlJLcWRxUlJIaW11NElydkcxMk5jWStEMGtZSEI5UXAwak1TLzdjRXdnPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTk1ODU1CWZpbGU6cGF5bG9hZC5qc29uCWhhc2hlZApEMGVQWFJCQW9RVTFaRnNDbG8xMnZOekpHcy9Pb0xEN0hHaDFiMkxJQ294WDBkeEszY0s2aGl3QWMzWEtFdmtIakRxOEx4VlF5UHJoZnpYSDJ1ZEdBQT09Cg=="
|
||||
},
|
||||
"artifactText": "isolated ShaCraft updater fixture; not an executable"
|
||||
}
|
||||
+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
|
||||
+35
-23
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { FeedbackDialog, type Feedback } from './components/FeedbackDialog'
|
||||
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'
|
||||
@@ -9,9 +9,9 @@ import { Titlebar } from './components/Titlebar'
|
||||
import { servers } from './data/servers'
|
||||
import { useAccount } from './hooks/useAccount'
|
||||
import { useLauncher } from './hooks/useLauncher'
|
||||
import { useLauncherUpdate } from './hooks/useLauncherUpdate'
|
||||
import { useServerStatus } from './hooks/useServerStatus'
|
||||
import { useSettings } from './hooks/useSettings'
|
||||
import { useUpdater } from './hooks/useUpdater'
|
||||
import { isNative } from './services/native'
|
||||
import { launchAccess } from './state/account'
|
||||
import { installStageLabels } from './state/game'
|
||||
@@ -19,8 +19,8 @@ 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 [errorFeedback, setErrorFeedback] = useState<Feedback | null>(null)
|
||||
const preferences = useSettings()
|
||||
const session = useAccount()
|
||||
const launcher = useLauncher()
|
||||
@@ -28,25 +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 updater = useLauncherUpdate(busy || session.busy || preferences.saving || session.recoveryCodes.length > 0)
|
||||
const updateLocked = updater.locksOperations
|
||||
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 || updateLocked || checking
|
||||
const error = launcher.game.error ?? preferences.error ?? windowError ?? launcher.environmentError ?? session.error ?? profile?.error ?? null
|
||||
useEffect(() => {
|
||||
if (error) setErrorFeedback({ kind: 'error', title: 'Ошибка лаунчера', message: error })
|
||||
}, [error])
|
||||
|
||||
let label = 'Играть'
|
||||
if (!desktop) label = 'В приложении'
|
||||
else if (updateLocked) label = updater.state.phase === 'ready' || updater.state.phase === 'restarting' ? 'Перезапустите лаунчер' : 'Обновляем лаунчер…'
|
||||
else if (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]}…` : 'Подготовка…'
|
||||
@@ -59,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)
|
||||
@@ -68,28 +80,28 @@ 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 || updateLocked || session.busy} onSelect={setSelected} onSettings={() => setSettingsOpen(true)} />
|
||||
<ServerStage server={selected} status={serverStatus}>
|
||||
<PlayDock server={selected} operation={operation} profile={profile}
|
||||
<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>
|
||||
{desktop && (updater.state.status?.version || updateLocked) && !settingsOpen && !session.recoveryCodes.length &&
|
||||
<button className="update-banner" onClick={() => setSettingsOpen(true)}>
|
||||
<span className="update-banner-dot" />
|
||||
{updater.state.phase === 'ready' || updater.state.phase === 'restarting' ? 'Обновление установлено · перезапустить'
|
||||
: updateLocked ? 'Обновляем ShaCraft Launcher…' : `ShaCraft Launcher ${updater.state.status?.version} · обновить`}
|
||||
</button>}
|
||||
<SettingsDrawer open={settingsOpen && !session.recoveryCodes.length} locked={busy || updateLocked} preferences={preferences}
|
||||
session={session} updater={updater} host={launcher.host} java={launcher.java} onClose={closeSettings} />
|
||||
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} />
|
||||
<FeedbackDialog feedback={session.recoveryCodes.length ? null : session.feedback ?? errorFeedback}
|
||||
onDismiss={() => { session.dismissFeedback(); setErrorFeedback(null) }} />
|
||||
</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)
|
||||
@@ -41,19 +41,25 @@ export function AccountSettings({ session, locked }: { session: ReturnType<typeo
|
||||
</form>
|
||||
)}
|
||||
{account && !linkedNickname && (
|
||||
<form noValidate onSubmit={(event) => { event.preventDefault(); if (!disabled) void session.startLink(nickname) }}>
|
||||
<form onSubmit={(event) => { event.preventDefault(); if (!disabled) void session.startLink(nickname) }}>
|
||||
<label className="text-setting">
|
||||
<span><strong>Игровой ник</strong><small>Общий ник ShaCraft</small></span>
|
||||
<span><strong>Игровой ник</strong><small>Aeronautics</small></span>
|
||||
<input value={nickname} minLength={3} maxLength={16} pattern="[A-Za-z0-9_]{3,16}" required
|
||||
disabled={disabled || session.linking} onChange={(event) => setNickname(event.target.value)} placeholder="Player" />
|
||||
<small>Свободный ник закрепляется за аккаунтом. Для старого игрового ника обратитесь к администратору.</small>
|
||||
<small>Подтвердите владение ником на сервере Aeronautics.</small>
|
||||
</label>
|
||||
<button className="setting-row" type="submit" disabled={disabled || session.linking}>
|
||||
<span>{session.linking ? 'Ожидаем подтверждения…' : busy ? 'Создаём проверку…' : 'Привязать ник'}</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>}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
export interface Feedback {
|
||||
kind: 'info' | 'success' | 'error'
|
||||
title: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export function FeedbackDialog({ feedback, onDismiss }: { feedback: Feedback | null; onDismiss: () => void }) {
|
||||
const dialog = useRef<HTMLDialogElement>(null)
|
||||
const visible = feedback !== null
|
||||
useEffect(() => {
|
||||
const element = dialog.current
|
||||
if (!visible || !element) return
|
||||
const previous = document.activeElement
|
||||
element.showModal()
|
||||
return () => {
|
||||
element.close()
|
||||
if (previous instanceof HTMLElement && previous.isConnected) previous.focus()
|
||||
}
|
||||
}, [visible])
|
||||
if (!feedback) return null
|
||||
return (
|
||||
<dialog ref={dialog} className={`feedback-dialog ${feedback.kind}`} aria-labelledby="feedback-title"
|
||||
aria-describedby="feedback-message" onCancel={(event) => { event.preventDefault(); onDismiss() }}
|
||||
onKeyDown={(event) => event.stopPropagation()}>
|
||||
<div aria-live={feedback.kind === 'error' ? 'assertive' : 'polite'} aria-atomic="true">
|
||||
<h2 id="feedback-title">{feedback.title}</h2>
|
||||
<p id="feedback-message">{feedback.message}</p>
|
||||
</div>
|
||||
<button type="button" autoFocus onClick={onDismiss}>Понятно</button>
|
||||
</dialog>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { ArrowDownToLine, RefreshCw } from 'lucide-react'
|
||||
import type { useLauncherUpdate } from '../hooks/useLauncherUpdate'
|
||||
import { isNative } from '../services/native'
|
||||
import { updatePercent } from '../state/updater'
|
||||
|
||||
export function LauncherUpdateSettings({ updater }: { updater: ReturnType<typeof useLauncherUpdate> }) {
|
||||
const { state, blocked, eventsReady, eventError } = updater
|
||||
const { phase, status, error } = state
|
||||
const percent = updatePercent(state.progress)
|
||||
const working = phase === 'downloading' || phase === 'installing'
|
||||
const ready = phase === 'ready' || phase === 'restarting'
|
||||
const checking = phase === 'loading' || phase === 'checking'
|
||||
const deb = status?.installationKind === 'deb'
|
||||
|
||||
return <section className="launcher-update" aria-labelledby="launcher-update-title">
|
||||
<div className="launcher-update-heading">
|
||||
<h3 id="launcher-update-title">ShaCraft Launcher</h3>
|
||||
{status?.currentVersion && <span>{status.currentVersion}</span>}
|
||||
</div>
|
||||
<div className="launcher-update-status" aria-live="polite">
|
||||
{!isNative() ? <p>Обновления доступны в приложении лаунчера.</p>
|
||||
: ready ? <p className="update-success">Обновление установлено. Перезапустите лаунчер.</p>
|
||||
: working ? <p>{phase === 'installing' ? deb
|
||||
? 'Подтвердите установку в системном окне и дождитесь завершения.'
|
||||
: 'Проверяем подпись и устанавливаем…'
|
||||
: `Скачиваем обновление${percent === null ? '…' : ` · ${percent}%`}`}</p>
|
||||
: checking ? <p>Проверяем обновления…</p>
|
||||
: status?.supported === false ? <p>{status.reason || 'Для этой установки обновление доступно вручную на shacraft.ru/help#launcher.'}</p>
|
||||
: status?.version ? <p className="update-success">Доступна версия {status.version}</p>
|
||||
: state.checked && !error ? <p>У вас последняя версия.</p>
|
||||
: <p>Проверка новой версии лаунчера.</p>}
|
||||
{working && <progress aria-label={phase === 'installing' ? 'Установка обновления лаунчера' : 'Загрузка обновления лаунчера'} max={100} value={phase === 'installing' ? undefined : percent ?? undefined} />}
|
||||
</div>
|
||||
{status?.notes && status.version && !working && !ready && <details className="update-notes">
|
||||
<summary>Что нового</summary><p>{status.notes.slice(0, 1600)}</p>
|
||||
</details>}
|
||||
{error && <p className="status-error update-error" role="status">{error}</p>}
|
||||
{eventError && <p className="status-error update-error" role="status">{eventError} Перезапустите лаунчер, чтобы включить установку обновлений.</p>}
|
||||
{deb && status?.supported && status.version && !working && !ready &&
|
||||
<p className="update-hint">Для обновления deb потребуется подтверждение администратора в системном окне.</p>}
|
||||
{isNative() && <div className="update-actions">
|
||||
{ready ? <button className="update-primary" disabled={blocked || phase === 'restarting'} onClick={() => void updater.restart()}>
|
||||
<RefreshCw />{phase === 'restarting' ? 'Перезапускаем…' : 'Перезапустить лаунчер'}
|
||||
</button> : <>
|
||||
{status?.supported && status.version && <button className="update-primary"
|
||||
disabled={blocked || working || checking || !eventsReady} onClick={() => void updater.install()}>
|
||||
<ArrowDownToLine />{working ? 'Обновляем…' : 'Обновить'}
|
||||
</button>}
|
||||
{status?.supported !== false && <button className="update-check" disabled={checking || working} onClick={() => void updater.check()}>
|
||||
{checking ? 'Проверяем…' : error ? 'Повторить проверку' : 'Проверить обновления'}
|
||||
</button>}
|
||||
</>}
|
||||
</div>}
|
||||
{blocked && status?.supported && status.version && !working && <p className="update-hint">Завершите игру и текущие операции, чтобы обновить лаунчер.</p>}
|
||||
</section>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export function Library({ selected, profiles, account, locked, native, onSelect,
|
||||
<button className="rail-button active" aria-label="Настройки" onClick={onSettings}><Settings /></button>
|
||||
</nav>
|
||||
<aside className="library-panel">
|
||||
<div className="library-heading"><p>Сборки</p><span>{servers.length} сборки</span></div>
|
||||
<div className="library-heading"><p>Сборки</p><span>{servers.length} доступна</span></div>
|
||||
<div className="server-list">
|
||||
{servers.map((server) => {
|
||||
const profile = profiles[server.profileId]
|
||||
@@ -33,7 +33,7 @@ export function Library({ selected, profiles, account, locked, native, onSelect,
|
||||
return (
|
||||
<button key={server.id} className={`server-row ${selected.id === server.id ? 'selected' : ''}`}
|
||||
aria-pressed={selected.id === server.id} disabled={locked} onClick={() => onSelect(server)}>
|
||||
<span className={`server-glyph ${server.id}`} aria-hidden="true">{server.name.slice(0, 1)}</span>
|
||||
<span className={`server-glyph ${server.id}`} aria-hidden="true">A</span>
|
||||
<span className="server-copy"><strong>{server.name}</strong><small>{status}</small></span>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
|
||||
@@ -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>Версия {server.id === 'minigames' ? 25 : 21}</dd></div>
|
||||
<div><dt>Java</dt><dd>{javaMajor ? `Версия ${javaMajor}` : 'По подписанной сборке'}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
{children}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { FolderOpen, Wrench, X } from 'lucide-react'
|
||||
import { AccountSettings } from './AccountSettings'
|
||||
import { LauncherUpdateSettings } from './LauncherUpdateSettings'
|
||||
import { LauncherUpdate } from './LauncherUpdate'
|
||||
import type { useUpdater } from '../hooks/useUpdater'
|
||||
import type { useAccount } from '../hooks/useAccount'
|
||||
import type { useSettings } from '../hooks/useSettings'
|
||||
import type { useLauncherUpdate } from '../hooks/useLauncherUpdate'
|
||||
import type { JavaInstallation, NativeHost } from '../types/launcher'
|
||||
|
||||
interface SettingsDrawerProps {
|
||||
@@ -14,11 +14,14 @@ interface SettingsDrawerProps {
|
||||
java: JavaInstallation | null | undefined
|
||||
preferences: ReturnType<typeof useSettings>
|
||||
session: ReturnType<typeof useAccount>
|
||||
updater: ReturnType<typeof useLauncherUpdate>
|
||||
onClose: () => void
|
||||
requiredJava?: number
|
||||
onOnboard?: (nickname: string) => Promise<void>
|
||||
updater: ReturnType<typeof useUpdater>
|
||||
native: boolean
|
||||
}
|
||||
|
||||
export function SettingsDrawer({ open, locked, host, java, preferences, session, updater, 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(() => {
|
||||
@@ -28,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), summary')
|
||||
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,29 +52,31 @@ export function SettingsDrawer({ open, locked, host, java, preferences, session,
|
||||
<aside className={`settings-drawer ${open ? 'open' : ''}`} inert={!open} aria-hidden={!open}
|
||||
role="dialog" aria-modal={open ? true : undefined} aria-labelledby="settings-title">
|
||||
<div className="drawer-title">
|
||||
<div><p>Настройки</p><h2 id="settings-title">Лаунчер и игра</h2></div>
|
||||
<div><p>Настройки</p><h2 id="settings-title">Игра</h2></div>
|
||||
<button ref={closeButton} onClick={onClose} aria-label="Закрыть настройки"><X /></button>
|
||||
</div>
|
||||
<LauncherUpdateSettings updater={updater} />
|
||||
<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,17 +3,18 @@ import { test } from 'node:test'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { AccountSettings } from './AccountSettings'
|
||||
import { RecoveryCodesModal } from './RecoveryCodesModal'
|
||||
import { ServerStage } from './ServerStage'
|
||||
import { Library } from './Library'
|
||||
import { 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> {
|
||||
return {
|
||||
account: null, error: null, busy: false, recoveryCodes: [], linkMessage: null,
|
||||
feedback: null, dismissFeedback: () => {},
|
||||
linking: false, linkedNickname: null, authenticate: async () => true,
|
||||
logout: async () => {}, startLink: async () => {}, clearError: () => {},
|
||||
acceptChallenge: () => {},
|
||||
acknowledgeRecoveryCodes: () => {},
|
||||
}
|
||||
}
|
||||
@@ -34,7 +35,7 @@ test('linked identity is displayed read-only while an unlinked account offers ve
|
||||
doesNotMatch(linked, /<input|Привязать ник/)
|
||||
const unlinked = renderToStaticMarkup(<AccountSettings session={{ ...session(), account: { username: 'website_login', links: [] } }} locked={false} />)
|
||||
match(unlinked, /Привязать ник/)
|
||||
match(unlinked, /Общий ник ShaCraft/)
|
||||
match(unlinked, /Aeronautics/)
|
||||
})
|
||||
|
||||
test('recovery codes render only until explicitly acknowledged', () => {
|
||||
@@ -47,14 +48,49 @@ test('recovery codes render only until explicitly acknowledged', () => {
|
||||
equal(renderToStaticMarkup(<RecoveryCodesModal codes={[]} onAcknowledge={() => {}} />), '')
|
||||
})
|
||||
|
||||
test('both profiles remain selectable and display their own runtime requirements', () => {
|
||||
const library = renderToStaticMarkup(<Library selected={servers[1]!} profiles={{}} account={null}
|
||||
locked={false} native={false} onSelect={() => {}} onSettings={() => {}} />)
|
||||
match(library, /Aeronautics/)
|
||||
match(library, /Minigames/)
|
||||
equal((library.match(/class="server-row/g) ?? []).length, 2)
|
||||
const stage = (index: number) => renderToStaticMarkup(<ServerStage server={servers[index]!} status={null}>{null}</ServerStage>)
|
||||
match(stage(0), /Версия 21/)
|
||||
match(stage(1), /Версия 25/)
|
||||
match(stage(1), /Fabric 0.19.5/)
|
||||
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=""/)
|
||||
})
|
||||
|
||||
@@ -1,75 +1,86 @@
|
||||
import { doesNotMatch, match } from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { LauncherUpdateSettings } from './LauncherUpdateSettings'
|
||||
import type { useLauncherUpdate } from '../hooks/useLauncherUpdate'
|
||||
import { initialUpdaterState, updaterReducer, updateBlocksOperations, type UpdaterState } from '../state/updater'
|
||||
import { LauncherUpdate } from './LauncherUpdate'
|
||||
import { initialUpdaterState } from '../state/updater'
|
||||
import type { UpdaterState } from '../state/updater'
|
||||
import type { UpdaterStatus } from '../types/updater'
|
||||
|
||||
function render(state: UpdaterState): string {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window')
|
||||
const previousTauri = Object.getOwnPropertyDescriptor(globalThis, 'isTauri')
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: { isTauri: true } })
|
||||
Object.defineProperty(globalThis, 'isTauri', { configurable: true, value: true })
|
||||
try {
|
||||
const updater: ReturnType<typeof useLauncherUpdate> = {
|
||||
state, blocked: false, eventsReady: true, eventError: null,
|
||||
locksOperations: updateBlocksOperations(state), check: async () => {},
|
||||
install: async () => {}, restart: async () => {},
|
||||
}
|
||||
return renderToStaticMarkup(<LauncherUpdateSettings updater={updater} />)
|
||||
} finally {
|
||||
if (previous) Object.defineProperty(globalThis, 'window', previous)
|
||||
else Reflect.deleteProperty(globalThis, 'window')
|
||||
if (previousTauri) Object.defineProperty(globalThis, 'isTauri', previousTauri)
|
||||
else Reflect.deleteProperty(globalThis, 'isTauri')
|
||||
}
|
||||
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={() => {}} />)
|
||||
}
|
||||
|
||||
const available = updaterReducer(initialUpdaterState, { type: 'loaded', checked: true,
|
||||
status: { currentVersion: '0.1.4', supported: true, installationKind: 'deb', version: '0.1.5' } })
|
||||
|
||||
test('deb announces system authorization before installation without collecting credentials', () => {
|
||||
const html = render(available)
|
||||
match(html, /Для обновления deb потребуется подтверждение администратора в системном окне/)
|
||||
match(html, /class="update-primary"><[^>]+.*Обновить<\/button>/)
|
||||
doesNotMatch(html, /<input|type="password"|Перезапустить лаунчер/)
|
||||
test('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('deb installation requests system confirmation and prevents duplicate install or check', () => {
|
||||
const downloading = updaterReducer(available, { type: 'install' })
|
||||
const installing = updaterReducer(downloading, { type: 'progress',
|
||||
progress: { stage: 'installing', downloadedBytes: 10, totalBytes: 10 } })
|
||||
const html = render(installing)
|
||||
match(html, /Подтвердите установку в системном окне и дождитесь завершения/)
|
||||
match(html, /aria-label="Установка обновления лаунчера"/)
|
||||
match(html, /class="update-primary" disabled=""/)
|
||||
match(html, /class="update-check" disabled=""/)
|
||||
doesNotMatch(html, /Для обновления deb потребуется|<input|type="password"/)
|
||||
test('an unconfigured build still reports its installed native version', () => {
|
||||
const html = render(status('unconfigured'))
|
||||
match(html, /Версия 0\.2\.0/)
|
||||
match(html, /Автообновление пока не настроено/)
|
||||
doesNotMatch(html, /Установить и перезапустить/)
|
||||
})
|
||||
|
||||
test('cancelled deb authorization remains visible and permits explicit retry without claiming success', () => {
|
||||
const downloading = updaterReducer(available, { type: 'install' })
|
||||
const installing = updaterReducer(downloading, { type: 'progress',
|
||||
progress: { stage: 'installing', downloadedBytes: 10 } })
|
||||
const cancelled = updaterReducer(installing, { type: 'failed', error: 'Установка отменена в системном окне.' })
|
||||
const html = render(cancelled)
|
||||
match(html, /role="status">Установка отменена в системном окне/)
|
||||
match(html, /class="update-primary">/)
|
||||
match(html, /Повторить проверку/)
|
||||
doesNotMatch(html, /disabled=""|Обновление установлено|Перезапустить лаунчер/)
|
||||
const retry = render(updaterReducer(cancelled, { type: 'install' }))
|
||||
match(retry, /Скачиваем обновление/)
|
||||
doesNotMatch(retry, /Установка отменена/)
|
||||
test('test updater builds have a visible native-provided notice', () => {
|
||||
match(render(status('available', { testBuild: true })), /Тестовая сборка · тестовый канал обновлений/)
|
||||
doesNotMatch(render(status('available')), /Тестовая сборка/)
|
||||
})
|
||||
|
||||
test('AppImage and older native status retain their installation copy without administrator hints', () => {
|
||||
for (const installationKind of ['appimage', undefined] as const) {
|
||||
const status = { ...available.status!, installationKind }
|
||||
const downloading = updaterReducer({ ...available, status }, { type: 'install' })
|
||||
const installing = updaterReducer(downloading, { type: 'progress',
|
||||
progress: { stage: 'installing', downloadedBytes: 10 } })
|
||||
const html = render(installing)
|
||||
match(html, /Проверяем подпись и устанавливаем/)
|
||||
doesNotMatch(html, /администратора|системном окне/)
|
||||
}
|
||||
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
-7
@@ -8,13 +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',
|
||||
},
|
||||
{
|
||||
id: 'minigames', kicker: 'Лобби и арены', name: 'Minigames',
|
||||
subtitle: 'Небесные острова. Сражения на аренах SMASH.',
|
||||
version: '26.2 · Fabric 0.19.5', loader: 'Fabric 0.19.5', profileId: 'minigames',
|
||||
},
|
||||
]
|
||||
|
||||
+65
-37
@@ -1,10 +1,15 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { Feedback } from '../components/FeedbackDialog'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
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
|
||||
expiresAt: number
|
||||
isCurrent: () => boolean
|
||||
}
|
||||
|
||||
export function useAccount() {
|
||||
// undefined = restoring saved account; null = signed out.
|
||||
@@ -12,12 +17,8 @@ export function useAccount() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([])
|
||||
const [challenge, setChallenge] = useState<PendingLink | null>(null)
|
||||
const [linkMessage, setLinkMessage] = useState<string | null>(null)
|
||||
const [feedback, setFeedback] = useState<Feedback | null>(null)
|
||||
const reportLink = useCallback((message: string, kind: Feedback['kind'] = 'info') => {
|
||||
setLinkMessage(message)
|
||||
setFeedback({ kind, title: kind === 'error' ? 'Не удалось привязать ник' : 'Привязка игрового ника', message })
|
||||
}, [])
|
||||
const pending = useRef(false)
|
||||
const requests = useRef(createRequestScope())
|
||||
|
||||
@@ -34,6 +35,44 @@ export function useAccount() {
|
||||
return () => { active = false; requests.current.invalidate() }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!challenge) return
|
||||
let active = true
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const current = () => active && challenge.isCurrent()
|
||||
const poll = async () => {
|
||||
if (!current()) return
|
||||
if (Date.now() >= challenge.expiresAt) {
|
||||
setChallenge(null)
|
||||
setLinkMessage('Срок проверки истёк. Начните привязку ещё раз.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await native.linkStatus(challenge.challengeId)
|
||||
if (!current()) return
|
||||
if (result.status === 'verified') {
|
||||
const refreshed = await native.getAccount()
|
||||
if (!current()) return
|
||||
setAccount(refreshed)
|
||||
setChallenge(null)
|
||||
setLinkMessage(linkedNickname(refreshed) ? 'Ник подтверждён.' : 'Не удалось подтвердить привязку. Войдите снова.')
|
||||
} else if (result.status === 'expired' || result.status === 'conflict') {
|
||||
setChallenge(null)
|
||||
setLinkMessage(result.detail || 'Проверка завершилась. Попробуйте ещё раз.')
|
||||
} else {
|
||||
// One request at a time; dispose and logout cancel future polling.
|
||||
timer = setTimeout(() => { void poll() }, 3_000)
|
||||
}
|
||||
} catch (reason) {
|
||||
if (!current()) return
|
||||
setChallenge(null)
|
||||
setLinkMessage(errorMessage(reason, 'Не удалось проверить ник'))
|
||||
}
|
||||
}
|
||||
timer = setTimeout(() => { void poll() }, 3_000)
|
||||
return () => { active = false; clearTimeout(timer) }
|
||||
}, [challenge])
|
||||
|
||||
const authenticate = async (username: string, password: string, register: boolean) => {
|
||||
if (pending.current || account === undefined) return false
|
||||
if (!validCredentials(username, password)) {
|
||||
@@ -53,6 +92,7 @@ export function useAccount() {
|
||||
const result = await native.authenticate(username, password, register)
|
||||
if (!currentRequest()) return false
|
||||
setAccount(result.account)
|
||||
setChallenge(null)
|
||||
setLinkMessage(null)
|
||||
setRecoveryCodes(result.recoveryCodes)
|
||||
return true
|
||||
@@ -69,6 +109,7 @@ export function useAccount() {
|
||||
if (!isNative() || pending.current) return
|
||||
pending.current = true
|
||||
requests.current.invalidate()
|
||||
setChallenge(null)
|
||||
setLinkMessage(null)
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
@@ -84,43 +125,31 @@ 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 (pending.current) return
|
||||
if (!isNative()) {
|
||||
reportLink('Привязка доступна в приложении лаунчера.', 'error')
|
||||
return
|
||||
}
|
||||
if (!account) {
|
||||
reportLink('Войдите в аккаунт ShaCraft, затем повторите привязку.', 'error')
|
||||
return
|
||||
}
|
||||
nickname = nickname.trim()
|
||||
if (!isNative() || pending.current || challenge || !account) return
|
||||
if (!isValidNickname(nickname)) {
|
||||
reportLink('Ник: 3–16 латинских букв, цифр или _', 'error')
|
||||
setLinkMessage('Ник: 3–16 латинских букв, цифр или _')
|
||||
return
|
||||
}
|
||||
pending.current = true
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
reportLink('Проверяем аккаунт и закрепляем ник…')
|
||||
setLinkMessage('Создаём проверку…')
|
||||
requests.current.invalidate()
|
||||
const currentRequest = requests.current.capture()
|
||||
try {
|
||||
const refreshed = await native.getAccount()
|
||||
const started = await native.startLink(nickname)
|
||||
if (!currentRequest()) return
|
||||
setAccount(refreshed)
|
||||
if (!refreshed) {
|
||||
reportLink('Сессия завершена или аккаунт удалён. Войдите в ShaCraft снова; если аккаунт удалён, создайте новый.', 'error')
|
||||
return
|
||||
}
|
||||
const linkedAccount = await native.claimNickname(nickname)
|
||||
if (!currentRequest()) return
|
||||
setAccount(linkedAccount)
|
||||
const confirmed = linkedNickname(linkedAccount)
|
||||
reportLink(confirmed ? `Ник ${confirmed} закреплён за аккаунтом. Теперь можно запускать игру.`
|
||||
: 'Не удалось получить закреплённый ник. Войдите снова.', confirmed ? 'success' : 'error')
|
||||
acceptChallenge(started)
|
||||
} catch (reason) {
|
||||
if (currentRequest()) reportLink(errorMessage(reason, 'Не удалось начать привязку'), 'error')
|
||||
setLinkMessage(errorMessage(reason, 'Не удалось начать привязку'))
|
||||
} finally {
|
||||
pending.current = false
|
||||
setBusy(false)
|
||||
@@ -128,9 +157,8 @@ export function useAccount() {
|
||||
}
|
||||
|
||||
return {
|
||||
account, error, busy, recoveryCodes, linkMessage, linking: false,
|
||||
feedback, dismissFeedback: () => setFeedback(null),
|
||||
linkedNickname: linkedNickname(account), authenticate, logout, startLink,
|
||||
account, error, busy, recoveryCodes, linkMessage, linking: challenge !== null,
|
||||
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,83 +0,0 @@
|
||||
import { useEffect, useReducer, useRef, useState } from 'react'
|
||||
import { errorMessage, singleFlight } from '../services/async'
|
||||
import { isNative, native, watchLauncherUpdate } from '../services/native'
|
||||
import { initialUpdaterState, updateBlocksOperations, updaterReducer } from '../state/updater'
|
||||
|
||||
// StrictMode re-runs effects; share the pending native check without installing
|
||||
// anything. Manual checks always make a fresh request.
|
||||
const startupCheck = singleFlight(async () => {
|
||||
const status = await native.updateStatus()
|
||||
if (!status.supported || (status.stage && ['downloading', 'installing', 'ready'].includes(status.stage))) return { status, checked: false, error: null }
|
||||
try {
|
||||
return { status: await native.checkUpdate(), checked: true, error: null }
|
||||
} catch (error) {
|
||||
return { status, checked: false, error: errorMessage(error, 'Не удалось проверить обновления. Повторите попытку.') }
|
||||
}
|
||||
})
|
||||
|
||||
export function useLauncherUpdate(blocked: boolean) {
|
||||
const [state, dispatch] = useReducer(updaterReducer, initialUpdaterState)
|
||||
const [eventsReady, setEventsReady] = useState(false)
|
||||
const [eventError, setEventError] = useState<string | null>(null)
|
||||
const pending = useRef(false)
|
||||
const mounted = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNative()) return
|
||||
let active = true
|
||||
mounted.current = true
|
||||
const subscription = watchLauncherUpdate((progress) => {
|
||||
if (active) dispatch({ type: 'progress', progress })
|
||||
})
|
||||
void subscription.ready.then(() => {
|
||||
if (active) { setEventsReady(true); setEventError(null) }
|
||||
}).catch((reason: unknown) => {
|
||||
if (active) setEventError(errorMessage(reason, 'Не удалось подключить события обновления. Перезапустите лаунчер.'))
|
||||
})
|
||||
pending.current = true
|
||||
void startupCheck().then((result) => {
|
||||
if (active) dispatch({ type: 'loaded', ...result })
|
||||
}).catch((reason: unknown) => {
|
||||
if (active) dispatch({ type: 'failed', error: errorMessage(reason, 'Не удалось проверить обновления. Повторите попытку.') })
|
||||
}).finally(() => { if (active) pending.current = false })
|
||||
return () => { active = false; mounted.current = false; subscription.dispose() }
|
||||
}, [])
|
||||
|
||||
const check = async () => {
|
||||
if (!isNative() || pending.current || updateBlocksOperations(state)) return
|
||||
pending.current = true
|
||||
dispatch({ type: 'check' })
|
||||
try {
|
||||
const status = await native.checkUpdate()
|
||||
if (mounted.current) dispatch({ type: 'loaded', status, checked: true })
|
||||
} catch (reason) {
|
||||
if (mounted.current) dispatch({ type: 'failed', error: errorMessage(reason, 'Не удалось проверить обновления. Повторите попытку.') })
|
||||
} finally { pending.current = false }
|
||||
}
|
||||
|
||||
const install = async () => {
|
||||
if (!isNative() || pending.current || blocked || !eventsReady || state.phase !== 'idle' ||
|
||||
!state.status?.supported || !state.status.version) return
|
||||
pending.current = true
|
||||
dispatch({ type: 'install' })
|
||||
try {
|
||||
await native.installUpdate()
|
||||
if (mounted.current) dispatch({ type: 'ready' })
|
||||
} catch (reason) {
|
||||
if (mounted.current) dispatch({ type: 'failed', error: errorMessage(reason, 'Не удалось установить обновление. Повторите попытку.') })
|
||||
} finally { pending.current = false }
|
||||
}
|
||||
|
||||
const restart = async () => {
|
||||
if (!isNative() || pending.current || blocked || state.phase !== 'ready') return
|
||||
pending.current = true
|
||||
dispatch({ type: 'restart' })
|
||||
try {
|
||||
await native.restartAfterUpdate()
|
||||
} catch (reason) {
|
||||
if (mounted.current) dispatch({ type: 'failed', error: errorMessage(reason, 'Не удалось перезапустить лаунчер. Закройте его и откройте снова.') })
|
||||
} finally { pending.current = false }
|
||||
}
|
||||
|
||||
return { state, eventsReady, eventError, blocked, locksOperations: updateBlocksOperations(state), check, install, restart }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user