Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
728fed7de3 | ||
|
|
b674347e86 | ||
|
|
a7eee390ab | ||
|
|
5b741771b4 | ||
|
|
799fa692ef | ||
|
|
69946e62f1 | ||
|
|
fd8a6a87a1 | ||
|
|
b43fc610c4 | ||
|
|
effe683a40 | ||
|
|
260e4577d0 | ||
|
|
bb4b1f8051 | ||
|
|
bf43254c57 | ||
|
|
3f3be65a3f | ||
|
|
cde13c4489 | ||
|
|
2d60f5eb3c | ||
|
|
ff666ba55f | ||
|
|
5fa58b0013 | ||
|
|
513be6274f | ||
|
|
6311a7c502 |
@@ -3,7 +3,10 @@ name: Cross-platform build
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, codex/server-migration-20260917]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -16,14 +19,14 @@ jobs:
|
||||
os: ubuntu-22.04
|
||||
args: --bundles appimage,deb
|
||||
- name: Windows x64
|
||||
os: windows-latest
|
||||
os: windows-2022
|
||||
args: --bundles nsis,msi
|
||||
- name: macOS Apple Silicon
|
||||
os: macos-14
|
||||
args: --target aarch64-apple-darwin --bundles dmg
|
||||
os: macos-15
|
||||
args: --target aarch64-apple-darwin --bundles app,dmg
|
||||
- name: macOS Intel
|
||||
os: macos-15-intel
|
||||
args: --target x86_64-apple-darwin --bundles dmg
|
||||
args: --target x86_64-apple-darwin --bundles app,dmg
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
@@ -40,7 +43,28 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||
- run: npm ci
|
||||
- run: npm run tauri:build -- ${{ matrix.args }}
|
||||
- run: npm test
|
||||
- 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'
|
||||
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
|
||||
@@ -65,4 +89,6 @@ jobs:
|
||||
with:
|
||||
name: shacraft-launcher-${{ matrix.name == 'macOS Apple Silicon' && 'macos-arm64' || 'macos-x64' }}
|
||||
if-no-files-found: error
|
||||
path: src-tauri/target/*/release/bundle/dmg/*.dmg
|
||||
path: |
|
||||
src-tauri/target/*/release/bundle/dmg/*.dmg
|
||||
src-tauri/target/*/release/bundle/macos/*.app.tar.gz
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
name: Launcher checks
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: checks-${{ github.ref }}
|
||||
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:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- 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
|
||||
- run: npm ci
|
||||
- run: npm test
|
||||
- run: npm run build
|
||||
- run: cargo test --locked --manifest-path src-tauri/Cargo.toml
|
||||
@@ -10,3 +10,7 @@ src-tauri/gen/
|
||||
*.p12
|
||||
*.pfx
|
||||
*.sig
|
||||
|
||||
admission-client/.gradle/
|
||||
admission-client/build/
|
||||
graphify-out/
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
|
||||
Cross-platform desktop launcher for the ShaCraft Minecraft network. It is a
|
||||
Tauri 2 application: React/Vite is the UI and Rust owns all filesystem,
|
||||
network and process-adjacent work. The current production profile is
|
||||
**Aeronautics** (Minecraft 1.21.1, NeoForge 21.1.248, Java 21).
|
||||
network and process-adjacent work. Profiles are **Aeronautics** (Minecraft 1.21.1, NeoForge 21.1.248, Java 21)
|
||||
and **Minigames** (Minecraft 26.2, Fabric 0.19.5, Java 25). See the Minigames
|
||||
integration record below and `docs/release-0.1.6.md` for publication evidence.
|
||||
|
||||
This repository owns the launcher only. The server-side API and published
|
||||
payload are in `/root/shacraft` on the ShaCraft host; see
|
||||
@@ -21,8 +22,9 @@ payload are in `/root/shacraft` on the ShaCraft host; see
|
||||
|
||||
## Trust model
|
||||
|
||||
- The only supported remote profile manifest endpoint is
|
||||
`https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest`.
|
||||
- The only supported remote profile manifest endpoints are the fixed
|
||||
`https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest` and
|
||||
`https://shacraft.ru/api/launcher/v2/profiles/minigames/signed-manifest`.
|
||||
The read-only Aeronautics player-count endpoint
|
||||
`https://shacraft.ru/api/online/aoc` is also hardcoded in `remote.rs`; it
|
||||
is display-only and is never allowed to influence downloads or launching.
|
||||
@@ -39,21 +41,82 @@ payload are in `/root/shacraft` on the ShaCraft host; see
|
||||
independent, hardcoded-host trust domains (Mojang, NeoForge, Microsoft,
|
||||
Adoptium) that install and run the actual game. Do not let manifest data
|
||||
control a URL in any of those domains.
|
||||
- Account modes: the launcher supports launching as either a genuine
|
||||
Microsoft account that owns Minecraft Java Edition (`src-tauri/src/msa.rs`,
|
||||
device-code OAuth -> Xbox Live -> XSTS -> Minecraft Services) or as a local
|
||||
offline profile (nickname + deterministic offline UUID, see
|
||||
`src-tauri/src/session.rs`). The mode is an explicit player choice
|
||||
(`account_mode` in settings); offline is never silently substituted for a
|
||||
Microsoft session. The mc-aoc/mc-create servers' own `ONLINE_MODE=FALSE` +
|
||||
whitelist + Login System are a separate, independent access-control layer
|
||||
on the server side.
|
||||
- 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.
|
||||
|
||||
## Layout
|
||||
|
||||
- `src/main.tsx` — UI state and Tauri command calls; do not put privileged
|
||||
operations in the web layer.
|
||||
- `src/main.tsx` — React entrypoint; `src/App.tsx` composes the screen.
|
||||
- `src/components/` — presentational UI; `src/hooks/` — lifecycle/settings/account.
|
||||
- `src/services/native.ts` — typed IPC and event subscriptions; keep schemas
|
||||
aligned with Rust. `src/services/async.ts` — serialized writes, single-flight
|
||||
account restore and listener disposal. `src/state/` — tested reducers.
|
||||
- Native filesystem/network/process operations never belong in the web layer.
|
||||
- `src-tauri/src/` — native commands and security-sensitive logic.
|
||||
- `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.
|
||||
- `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,
|
||||
atomic rename, progress callback); `manifest.rs`/`profile.rs` (ShaCraft
|
||||
mods) and `mojang.rs`/`neoforge.rs`/`runtime.rs` (the game itself) all
|
||||
@@ -66,20 +129,34 @@ payload are in `/root/shacraft` on the ShaCraft host; 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.
|
||||
- `launch.rs` — builds and spawns the actual `java` process.
|
||||
- `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.
|
||||
- `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
|
||||
(mods/config only).
|
||||
- `docs/game-trust-boundary.md` — the Mojang/NeoForge/Microsoft/Adoptium
|
||||
trust domains used to install and run the game itself.
|
||||
- `.github/workflows/build.yml` — manual cross-platform build matrix.
|
||||
- `.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`.
|
||||
|
||||
## Verification
|
||||
|
||||
Run from repository root:
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm test
|
||||
npm run build
|
||||
(cd src-tauri && /home/emil/.cargo/bin/cargo test)
|
||||
npm run tauri:dev
|
||||
@@ -88,6 +165,17 @@ npm run tauri:dev
|
||||
`tauri:dev` is for local desktop testing. A successful web build alone does
|
||||
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
|
||||
|
||||
- Keep UI copy in Russian; code, identifiers and errors may remain English.
|
||||
@@ -96,3 +184,93 @@ not prove Tauri commands work.
|
||||
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.
|
||||
|
||||
## Server migration — launcher 0.1.7 (2026-09-17)
|
||||
|
||||
Minigames Quick Play now uses the fixed native endpoint `shacraft.ru:25568`.
|
||||
It no longer pins the retired server IP in the application binary. The Fabric
|
||||
admission client accepts the new actual socket IP `135.106.219.182` and retains
|
||||
`135.106.154.86` for the temporary forwarding path; unrelated hosts and ports
|
||||
remain rejected. The server published updated signed companion manifests.
|
||||
|
||||
The migration's full-platform packages are published from
|
||||
`codex/server-migration-20260917`. Existing 0.1.6 installers still connect to the
|
||||
old IP and need that forwarding path until upgraded. Version 0.1.7 uses a newly generated operator-held updater key and the fixed
|
||||
`https://shacraft.ru/launcher/updates/stable-v2.json` channel. It requires one
|
||||
manual installation. Preserve `stable.json` at the last old-key release; never
|
||||
replace it with new-key metadata. Subsequent v2-channel releases use the new
|
||||
key, kept only at `/home/emil/.local/share/shacraft-updater/production.key`.
|
||||
All eight public HTTPS downloads, their signatures and the signed v2 feed were
|
||||
verified after publication. See `docs/release-0.1.7.md` and its publication
|
||||
receipt for provenance, checks and the limits of platform verification.
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 ShaCraft
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,129 @@
|
||||
# План ShaCraft Launcher
|
||||
|
||||
## Сделано: рефакторинг 2026-09-09
|
||||
|
||||
- [x] React components/hooks/typed IPC/state reducers вместо единого main.tsx.
|
||||
- [x] Строгий TypeScript, тесты async lifecycle и последовательных сохранений.
|
||||
- [x] Rust commands по ответственности; signed-only синхронизация профиля.
|
||||
- [x] Общие atomic files, process-local operation guards, portable path checks.
|
||||
- [x] Проверка подписи, размера envelope и profile ID; bounded provider redirects.
|
||||
- [x] Push/PR проверки и ручная матрица сборки с артефактами.
|
||||
|
||||
## Следующие задачи
|
||||
|
||||
- [x] Сохранены изменения 0.1.1 из GitHub: обязательный ShaCraft-аккаунт,
|
||||
подтверждённый ник, реальный онлайн и исправления Windows-install pipeline.
|
||||
- [ ] Microsoft (отдельное будущее решение): собственный client ID + API
|
||||
approval и живой OAuth-тест. Текущий запуск использует ShaCraft identity.
|
||||
- [ ] Cold install / repair / update / game exit на чистых Windows/Linux/macOS.
|
||||
Unit tests и web preview не заменяют эти прогоны.
|
||||
- [ ] Windows Authenticode / macOS signing-notarization и проверка установщиков.
|
||||
- [ ] Реальная отмена загрузок, журнал с редактированием токенов и retry UX.
|
||||
- [ ] Выбор каталога профиля и безопасный reset только managed-файлов.
|
||||
- [ ] Keychain-хранилище refresh token; cross-process exclusion при необходимости.
|
||||
- [ ] Динамический каталог и новости; реальный Aeronautics онлайн уже
|
||||
загружается через фиксированный display-only API. Не имитировать данные.
|
||||
|
||||
## Вход по одноразовому разрешению: внедрение 2026-09-10
|
||||
|
||||
- [x] Новый native claim закрепляет свободный ник за аккаунтом без входа в игру.
|
||||
Старые имена резервируются backend и переносятся администратором.
|
||||
- [x] После установки Rust создаёт Ed25519 ключ через OS CSPRNG и запрашивает
|
||||
ticket на фиксированном ShaCraft API. Ник для запуска берётся из ticket;
|
||||
локальные настройки не могут его заменить.
|
||||
- [x] Ticket и PKCS#8 private key передаются только окружением дочерней Java;
|
||||
session сайта остаётся в native. В argv, argfile, settings, логах и IPC
|
||||
секретов нет. Account permit держится до spawn.
|
||||
- [x] Ошибки и результаты привязки показаны в диалоге; двойной клик не создаёт
|
||||
параллельных запросов, старого polling нет.
|
||||
- [x] Локально пройдены 64 Rust-теста (5 live-тестов пропущены), 22 UI-теста,
|
||||
TypeScript/Vite и сборка Linux x86-64 `tauri:build -- --no-bundle`.
|
||||
Шесть браузерных сценариев используют только замоканный Tauri IPC.
|
||||
- [x] Собраны неподписанные AppImage и deb версии 0.1.2 на Ubuntu 26.04;
|
||||
проверены извлечение AppImage и metadata deb. Работа на старых Ubuntu и
|
||||
установка пакетов на чистой машине этим не подтверждены.
|
||||
- [x] Выложены backend, подписанный клиентский мод и серверный мод Aeronautics;
|
||||
сервер перезапущен и healthy. Whitelist сохранён; LoginSystem заменён только
|
||||
на `aoc`. Пакеты Linux 0.1.2 опубликованы на сайте, AppImage запущен локально.
|
||||
- [x] Настоящий изолированный NeoForge: новый ticket допускает в мир, отсутствие
|
||||
мода/билета и повтор билета отклоняются. Старый LoginSystem на клиенте не мешает.
|
||||
Подключение дубликата без авторизации не выбивает уже играющего владельца.
|
||||
- [x] На публичном сервере подключение без мода отклонено до входа в мир;
|
||||
HTTPS verifier из контейнера, подпись manifest и SHA-256 мода проверены.
|
||||
Backend Docker: 404 tests + 48 subtests, включая истечение, отзыв session,
|
||||
чужую identity, whitelist, резервирование имён и атомарное погашение.
|
||||
- [x] Полный вход в production Aeronautics через установленный лаунчер
|
||||
подтверждён пользователем 2026-09-10: «Присоединился!». Это пользовательское
|
||||
подтверждение, а не автоматизированный cold-install тест.
|
||||
- [ ] Проверить cold install и этот протокол на Windows/macOS, выпустить
|
||||
подписанные пакеты. Локальная Linux-сборка не подтверждает эти платформы.
|
||||
- [ ] Удобное переподключение: сейчас использованный или истёкший ticket
|
||||
требует нового запуска игры из лаунчера; автоматического обновления нет.
|
||||
|
||||
## Самообновление лаунчера 0.1.3: реализация 2026-09-10
|
||||
|
||||
- [x] Отдельный канал обновления приложения на фиксированном HTTPS endpoint.
|
||||
Выделенный публичный ключ проверяет подпись metadata и пакета; версия,
|
||||
заметки и URL связаны подписью. Redirect, downgrade и произвольные пути
|
||||
из webview запрещены; размеры metadata и загрузки ограничены.
|
||||
- [x] Vendored Tauri updater 2.11.0 принимает уже проверенный JSON через
|
||||
`check_metadata` без второго HTTP-запроса. На Linux AppImage заменяется
|
||||
атомарно через временный файл в том же каталоге с проверкой подписи и fsync.
|
||||
- [x] Проверка при старте без автоматической установки; доступны уведомление
|
||||
о новой версии, ручная проверка, заметки, прогресс, ошибки, повторная попытка
|
||||
и явный перезапуск. Сбой проверки не блокирует установленный лаунчер.
|
||||
- [x] Native guards исключают обновление во время игры и конфликтующих
|
||||
операций. На время установки и до перезапуска заблокированы запуск игры,
|
||||
ремонт сборки и изменения аккаунта. Для deb и development binary показано
|
||||
сообщение об установке вручную; версия 0.1.2 требует первого ручного обновления.
|
||||
- [x] Пройдены 76 Rust-тестов, 28 UI-тестов и 11 тестов publisher;
|
||||
TypeScript/Vite успешно собраны. Девять браузерных сценариев с mock Tauri IPC
|
||||
проверяют обновление, ошибки, повтор, блокировки и восстановление состояния;
|
||||
внешняя сеть и реальные аккаунты в этих сценариях не используются.
|
||||
- [x] Живой native-прогон скачал 0.1.3 с production HTTPS: повреждение
|
||||
отклонено без изменения старого файла, подлинный пакет атомарно заменил
|
||||
временную копию 0.1.2. Исходный AppImage сохранён, хеши проверены.
|
||||
- [x] Опубликованы Linux 0.1.3 и подписанный stable feed; HTTPS 200,
|
||||
`Cache-Control: no-store`, подписи и хеши проверены. Ссылка на сайте обновлена.
|
||||
AppImage установлен в `~/Applications`, добавлен ярлык и проверен запуск.
|
||||
- [ ] Полный GUI-цикл «Обновить → Перезапустить» проверить на следующем
|
||||
релизе; текущий прогон проверяет native-установку и запуск пакета отдельно.
|
||||
- [ ] Проверить установку и самообновление Windows/macOS перед публикацией
|
||||
пакетов этих платформ; Authenticode/notarization остаются отдельными задачами.
|
||||
|
||||
## Обновление DEB 0.1.4: 2026-09-10
|
||||
|
||||
- [x] Отдельный подписанный deb entry, определение установленного формата
|
||||
и сохранение AppImage-совместимости для клиентов 0.1.3.
|
||||
- [x] Одно системное подтверждение через pkexec. Root helper до запуска GUI
|
||||
повторно проверяет подписи и точные Package/Version/Architecture, получает
|
||||
только bounded bytes через stdin и устанавливает пакет из root-only staging.
|
||||
Пароль не попадает в лаунчер; отмена не открывает запасные окна авторизации.
|
||||
- [x] Dpkg отказывается от downgrade и сохраняет системную блокировку пакетов.
|
||||
Ошибки частичной установки не маскируются; автоматических повторов нет.
|
||||
Read-only inspection имеет ограничение вывода и времени для группы процессов;
|
||||
работающий dpkg не прерывается таймаутом посреди изменения пакета.
|
||||
- [x] 32 UI-теста, TypeScript/Vite и 12 браузерных сценариев с mock IPC;
|
||||
18 publisher-тестов с настоящими minisign и deb, включая переход feed 0.1.3.
|
||||
- [x] 84 native-теста прошли. Реальный root-helper в изолированном Ubuntu 26.04
|
||||
прошёл 10 сценариев: установка подписанного 0.1.4, повреждения, неверные
|
||||
права/временный каталог, занятый dpkg, повтор и защита от downgrade.
|
||||
Dpkg подтвердил версию, тестовый маркер профиля сохранён. GUI-подтверждение
|
||||
PolicyKit этим контейнерным прогоном не проверялось; отмена покрыта UI/unit.
|
||||
- [x] Опубликованы подписанные AppImage/deb 0.1.4, проверены байты feed и deb,
|
||||
обе кнопки сайта и системная инструкция. Backend: 404 tests + 48 subtests,
|
||||
Ruff clean. Minecraft не перезапускался, данные аккаунтов не менялись.
|
||||
Deb 0.1.3 и ниже требуют первого ручного обновления до 0.1.4.
|
||||
|
||||
## Связанные серверные риски
|
||||
|
||||
Серверный план находится в `/root/shacraft/PLAN.md`. Для admission обязательны
|
||||
атомарная одноразовая проверка ticket, привязка к текущим session/аккаунту/нику,
|
||||
проверка до входа в мир и сохранение whitelist как серверного ограничения.
|
||||
Старые игровые имена нельзя отдавать первому зарегистрировавшемуся; их
|
||||
резервирование и назначение аккаунтам — отдельный этап миграции. Старый
|
||||
NoGravity challenge-поток должен быть закрыт при включённом admission, чтобы
|
||||
он не обходил резервирование имени. Остальные серверные задачи включают
|
||||
enforcement реферальных правил и очередь повторов whitelist. Не менять
|
||||
протокол незаметно в клиентском рефакторинге и не считать ticket доказательством
|
||||
неизменённости клиентского бинарника.
|
||||
@@ -1,41 +1,69 @@
|
||||
# ShaCraft Launcher
|
||||
|
||||
Кроссплатформенный лаунчер для сети Minecraft-серверов ShaCraft.
|
||||
Кроссплатформенный 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).
|
||||
|
||||
- интерактивный интерфейс профилей и настроек;
|
||||
- Tauri 2 native shell с безопасной командой `native_host`;
|
||||
- сохранение памяти профиля в локальном каталоге данных приложения;
|
||||
- безопасное обнаружение установленной Java (включая `JAVA_HOME`) перед запуском;
|
||||
- интеграция с фиксированным ShaCraft launcher v2 manifest для Aeronautics;
|
||||
- адаптивное окно для Windows, Linux и macOS;
|
||||
- вход через настоящий аккаунт Microsoft (без него игра не устанавливается
|
||||
и не запускается — так владение игрой проверяется по-настоящему);
|
||||
- установка Minecraft и NeoForge версии, которую задаёт manifest, и запуск
|
||||
игры.
|
||||
Вход выполняется через аккаунт ShaCraft — тот же, что на сайте. Игровой ник
|
||||
общий для обоих профилей и берётся только из подтверждённой привязки `aoc`.
|
||||
Тикет входа привязан к выбранному серверу; локальные настройки не выбирают личность. Пароли не сохраняются; сессию можно отозвать.
|
||||
Microsoft OAuth-модуль сохранён отдельно, но не используется текущим
|
||||
сценарием запуска; для его активации потребуются client ID и API approval.
|
||||
|
||||
## Локальная разработка
|
||||
## Разработка
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Для нативного приложения нужен Rust:
|
||||
Это браузерный preview — он не устанавливает и не запускает игру.
|
||||
Для приложения нужен Rust и системные зависимости Tauri:
|
||||
|
||||
```bash
|
||||
npm run tauri:dev
|
||||
```
|
||||
|
||||
На Ubuntu для сборки Tauri также потребуются системные пакеты WebKit/GTK и
|
||||
DBus development headers. Их установка описана в официальной документации
|
||||
Tauri и требует прав администратора.
|
||||
## Проверка
|
||||
|
||||
## Статус
|
||||
```bash
|
||||
npm test
|
||||
npm run build
|
||||
cargo test --locked --manifest-path src-tauri/Cargo.toml
|
||||
```
|
||||
|
||||
Вход через Microsoft, установка и запуск Aeronautics уже работают. Вход
|
||||
через Microsoft пока не активен на боевой сборке: нужна собственная
|
||||
регистрация приложения ShaCraft в Azure AD и её одобрение Microsoft для
|
||||
доступа к Minecraft API — см. комментарий к `MSA_CLIENT_ID` в
|
||||
`src-tauri/src/msa.rs`.
|
||||
Build включает строгий TypeScript. GitHub Actions проверяет UI и Rust на
|
||||
push/PR; workflow на main-push/ручном запуске собирает Windows x64, Linux x64, macOS Intel
|
||||
и Apple Silicon и сохраняет неподписанные артефакты. Релизный оператор
|
||||
подписывает проверенные пакеты и metadata локальным ключом; CI его не получает.
|
||||
Используемые macOS runners соответствуют [списку GitHub](https://docs.github.com/en/actions/reference/runners/github-hosted-runners).
|
||||
|
||||
## Обновление лаунчера
|
||||
|
||||
С версии 0.1.3 настройки содержат проверку обновлений, установку с прогрессом
|
||||
и перезапуск. Лаунчер также проверяет новые версии при старте, но устанавливает
|
||||
их только по кнопке. Подписи пакета и сведений о версии обязательны.
|
||||
Закройте запущенный Minecraft перед установкой обновления.
|
||||
|
||||
В Linux обновляются AppImage и установленный deb (с версии 0.1.4).
|
||||
Для deb система запрашивает права администратора; пароль не передаётся лаунчеру.
|
||||
Deb 0.1.3 и ниже нужно один раз обновить вручную до 0.1.4. Dev-бинарник
|
||||
обновляется вручную. С версии 0.1.2 нужен ручной переход на новый AppImage.
|
||||
Пакеты Windows/macOS 0.1.5 опубликованы. Проверка реальной установки на каждой
|
||||
платформе остаётся отдельной от сборки и автоматических тестов.
|
||||
|
||||
## Навигация
|
||||
|
||||
- [Архитектура](docs/launcher-architecture.md) — компоненты, данные, IPC.
|
||||
- [Trust boundaries](docs/game-trust-boundary.md) — доверенные источники игры.
|
||||
- [Manifest](docs/manifest-v1.md) — подписанный контракт модпака.
|
||||
- [Обновления](docs/launcher-updates.md) — подпись, публикация и восстановление.
|
||||
- [PLAN.md](PLAN.md) — ограничения и следующие шаги.
|
||||
- [AGENTS.md](AGENTS.md) — инструкции для следующего разработчика/агента.
|
||||
|
||||
Не хранить в Git токены, ключи, пользовательские данные или пакеты игры.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# ShaCraft Minigames admission client
|
||||
|
||||
Client-only Fabric companion for Minecraft 26.2, Java 25, Fabric Loader 0.19.5
|
||||
and Fabric API 0.160.0+26.2. This is account admission, not an anti-cheat or a
|
||||
proof that the original launcher binary is running.
|
||||
|
||||
Build with Java 25: `./gradlew test build --no-daemon`.
|
||||
Output: `build/libs/shacraft-admission-client-0.1.0.jar`.
|
||||
Publish that jar and the pinned Fabric API jar in the signed Minigames profile.
|
||||
|
||||
The native launcher supplies `SHACRAFT_ADMISSION_TICKET` and
|
||||
`SHACRAFT_ADMISSION_PRIVATE_KEY` only to the final Java child environment.
|
||||
The client accepts a CONFIGURATION payload on `shacraft_admission:challenge`
|
||||
with three Minecraft UTF strings: server ID (16), nickname (16), nonce (43).
|
||||
It verifies server `minigames`, the exact current game nickname and actual
|
||||
socket `135.106.219.182:25568` (the previous IP remains accepted during migration), 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.
|
||||
@@ -0,0 +1,37 @@
|
||||
plugins {
|
||||
id 'net.fabricmc.fabric-loom' version "${loom_version}"
|
||||
}
|
||||
|
||||
repositories { mavenCentral() }
|
||||
|
||||
loom {
|
||||
splitEnvironmentSourceSets()
|
||||
mods {
|
||||
'shacraft_admission' {
|
||||
sourceSet sourceSets.main
|
||||
sourceSet sourceSets.client
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
minecraft "com.mojang:minecraft:${project.minecraft_version}"
|
||||
implementation "net.fabricmc:fabric-loader:${project.loader_version}"
|
||||
implementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}"
|
||||
testImplementation platform('org.junit:junit-bom:5.12.2')
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter'
|
||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||
}
|
||||
|
||||
processResources {
|
||||
inputs.property 'version', project.version
|
||||
filesMatching('fabric.mod.json') { expand version: project.version }
|
||||
}
|
||||
|
||||
tasks.withType(JavaCompile).configureEach { options.release = 25 }
|
||||
java { toolchain.languageVersion = JavaLanguageVersion.of(25); withSourcesJar() }
|
||||
test { useJUnitPlatform() }
|
||||
|
||||
// Pure HTTP/validation tests use the same client implementation without launching Minecraft.
|
||||
sourceSets.test.compileClasspath += sourceSets.client.output
|
||||
sourceSets.test.runtimeClasspath += sourceSets.client.output
|
||||
@@ -0,0 +1,9 @@
|
||||
org.gradle.jvmargs=-Xmx2G
|
||||
org.gradle.parallel=false
|
||||
org.gradle.configuration-cache=false
|
||||
minecraft_version=26.2
|
||||
loader_version=0.19.5
|
||||
loom_version=1.17.20
|
||||
fabric_api_version=0.160.0+26.2
|
||||
version=0.1.0
|
||||
group=ru.shacraft
|
||||
Binary file not shown.
@@ -0,0 +1,8 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
|
||||
distributionSha256Sum=bafc141b619ad6350fd975fc903156dd5c151998cc8b058e8c1044ab5f7b031f
|
||||
networkTimeout=30000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
Vendored
+82
@@ -0,0 +1,82 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||
setlocal EnableExtensions
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||
@rem which allows us to clear the local environment before executing the java command
|
||||
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
||||
|
||||
:exitWithErrorLevel
|
||||
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||
@@ -0,0 +1,8 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
maven { url = 'https://maven.fabricmc.net/' }
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
rootProject.name = 'shacraft-admission-client'
|
||||
@@ -0,0 +1,57 @@
|
||||
package ru.shacraft.admission;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import net.fabricmc.api.ClientModInitializer;
|
||||
import net.fabricmc.fabric.api.client.networking.v1.ClientConfigurationNetworking;
|
||||
import net.fabricmc.fabric.api.client.networking.v1.ClientConfigurationConnectionEvents;
|
||||
import net.fabricmc.fabric.impl.networking.RegistrationPayload;
|
||||
import net.minecraft.network.protocol.common.ServerboundCustomPayloadPacket;
|
||||
import java.util.List;
|
||||
import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry;
|
||||
import net.minecraft.network.chat.Component;
|
||||
|
||||
/** The account session remains in the native launcher, never in Minecraft. */
|
||||
public final class ClientAdmission implements ClientModInitializer {
|
||||
private static final AtomicBoolean USED = new AtomicBoolean();
|
||||
|
||||
@Override public void onInitializeClient() {
|
||||
PayloadTypeRegistry.clientboundConfiguration().register(AdmissionPayloads.Challenge.TYPE, AdmissionPayloads.Challenge.CODEC);
|
||||
PayloadTypeRegistry.serverboundConfiguration().register(AdmissionPayloads.Proof.TYPE, AdmissionPayloads.Proof.CODEC);
|
||||
ClientConfigurationNetworking.registerGlobalReceiver(AdmissionPayloads.Challenge.TYPE, ClientAdmission::challenge);
|
||||
// Paper waits for vanilla channel advertisement, while Fabric normally waits
|
||||
// for the server's registration first. Bootstrap our one fixed receiver.
|
||||
// INIT runs in the listener constructor; schedule() queues until after vanilla
|
||||
// has switched the outbound protocol from LOGIN to CONFIGURATION.
|
||||
ClientConfigurationConnectionEvents.INIT.register((listener, client) -> client.schedule(() ->
|
||||
listener.send(new ServerboundCustomPayloadPacket(new RegistrationPayload(
|
||||
RegistrationPayload.REGISTER, List.of(AdmissionPayloads.Challenge.TYPE.id()))))));
|
||||
}
|
||||
|
||||
private static void challenge(AdmissionPayloads.Challenge challenge, ClientConfigurationNetworking.Context context) {
|
||||
var connection = context.packetContext().orElseThrow(net.fabricmc.fabric.api.networking.v1.context.PacketContext.CONNECTION);
|
||||
boolean loopback = "1".equals(System.getenv("SHACRAFT_ADMISSION_ALLOW_LOOPBACK"));
|
||||
if (!(connection.getRemoteAddress() instanceof InetSocketAddress remote)
|
||||
|| remote.getAddress() == null
|
||||
|| !AdmissionProof.allowedTarget(remote.getAddress().getHostAddress(), remote.getPort(), loopback)
|
||||
|| !AdmissionProof.SERVER_ID.equals(challenge.serverId())
|
||||
|| !context.client().getUser().getName().equals(challenge.nickname())
|
||||
|| !AdmissionProof.validOpaque(challenge.nonce())) {
|
||||
deny(context); return;
|
||||
}
|
||||
String ticket = System.getenv("SHACRAFT_ADMISSION_TICKET");
|
||||
String privateKey = System.getenv("SHACRAFT_ADMISSION_PRIVATE_KEY");
|
||||
if (!AdmissionProof.validOpaque(ticket) || !USED.compareAndSet(false, true)) {
|
||||
deny(context); return;
|
||||
}
|
||||
try {
|
||||
String signature = AdmissionProof.sign(privateKey, ticket, challenge.serverId(), challenge.nickname(), challenge.nonce());
|
||||
context.responseSender().sendPacket(new AdmissionPayloads.Proof(ticket, signature));
|
||||
} catch (Exception invalidKey) { deny(context); }
|
||||
}
|
||||
|
||||
private static void deny(ClientConfigurationNetworking.Context context) {
|
||||
context.responseSender().disconnect(Component.literal(
|
||||
"Не удалось подтвердить вход ShaCraft. Закройте игру и запустите её заново через ShaCraft Launcher."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package ru.shacraft.admission;
|
||||
|
||||
import net.minecraft.network.FriendlyByteBuf;
|
||||
import net.minecraft.network.codec.StreamCodec;
|
||||
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
public final class AdmissionPayloads {
|
||||
private AdmissionPayloads() {}
|
||||
|
||||
public record Challenge(String serverId, String nickname, String nonce) implements CustomPacketPayload {
|
||||
public static final Type<Challenge> TYPE = new Type<>(Identifier.fromNamespaceAndPath("shacraft_admission", "challenge"));
|
||||
public static final StreamCodec<FriendlyByteBuf, Challenge> CODEC = StreamCodec.of(
|
||||
(buffer, value) -> { buffer.writeUtf(value.serverId, 16); buffer.writeUtf(value.nickname, 16); buffer.writeUtf(value.nonce, 43); },
|
||||
buffer -> new Challenge(buffer.readUtf(16), buffer.readUtf(16), buffer.readUtf(43)));
|
||||
@Override public Type<Challenge> type() { return TYPE; }
|
||||
@Override public String toString() { return "AdmissionChallenge[redacted]"; }
|
||||
}
|
||||
|
||||
public record Proof(String ticket, String signature) implements CustomPacketPayload {
|
||||
public static final Type<Proof> TYPE = new Type<>(Identifier.fromNamespaceAndPath("shacraft_admission", "proof"));
|
||||
public static final StreamCodec<FriendlyByteBuf, Proof> CODEC = StreamCodec.of(
|
||||
(buffer, value) -> { buffer.writeUtf(value.ticket, 43); buffer.writeUtf(value.signature, 88); },
|
||||
buffer -> new Proof(buffer.readUtf(43), buffer.readUtf(88)));
|
||||
@Override public Type<Proof> type() { return TYPE; }
|
||||
@Override public String toString() { return "AdmissionProof[redacted]"; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package ru.shacraft.admission;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.Signature;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.util.Base64;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** The only client credential is an ephemeral private key supplied by the launcher. */
|
||||
public final class AdmissionProof {
|
||||
public static final String SERVER_ID = "minigames";
|
||||
private static final Pattern OPAQUE = Pattern.compile("[A-Za-z0-9_-]{43}");
|
||||
private static final Pattern NICKNAME = Pattern.compile("[A-Za-z0-9_]{3,16}");
|
||||
|
||||
private AdmissionProof() {}
|
||||
|
||||
public static boolean validOpaque(String value) {
|
||||
return value != null && OPAQUE.matcher(value).matches();
|
||||
}
|
||||
|
||||
public static byte[] transcript(String ticket, String serverId, String nickname, String nonce) {
|
||||
if (!validOpaque(ticket) || !validOpaque(nonce) || !SERVER_ID.equals(serverId)
|
||||
|| nickname == null || !NICKNAME.matcher(nickname).matches()) {
|
||||
throw new IllegalArgumentException("Invalid admission challenge");
|
||||
}
|
||||
return ("shacraft-admission-v1\n" + ticket + "\n" + serverId + "\n"
|
||||
+ nickname + "\n" + nonce).getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
public static String sign(String encodedPrivateKey, String ticket, String serverId,
|
||||
String nickname, String nonce) throws Exception {
|
||||
if (encodedPrivateKey == null || encodedPrivateKey.length() > 256) {
|
||||
throw new IllegalArgumentException("Missing admission key");
|
||||
}
|
||||
byte[] encoded = Base64.getDecoder().decode(encodedPrivateKey);
|
||||
try {
|
||||
PrivateKey key = KeyFactory.getInstance("Ed25519")
|
||||
.generatePrivate(new PKCS8EncodedKeySpec(encoded));
|
||||
Signature signer = Signature.getInstance("Ed25519");
|
||||
signer.initSign(key);
|
||||
signer.update(transcript(ticket, serverId, nickname, nonce));
|
||||
return Base64.getEncoder().encodeToString(signer.sign());
|
||||
} finally {
|
||||
java.util.Arrays.fill(encoded, (byte) 0);
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean validSignature(String value) {
|
||||
if (value == null || value.length() != 88) return false;
|
||||
try {
|
||||
byte[] decoded = Base64.getDecoder().decode(value);
|
||||
return decoded.length == 64 && Base64.getEncoder().encodeToString(decoded).equals(value);
|
||||
} catch (IllegalArgumentException invalid) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean allowedTarget(String host, int port, boolean allowLoopback) {
|
||||
if (host == null) return false;
|
||||
if (allowLoopback && (host.equals("127.0.0.1") || host.equals("::1") || host.equals("[::1]") || host.equals("0:0:0:0:0:0:0:1"))) {
|
||||
return port > 0 && port <= 65535;
|
||||
}
|
||||
return port == 25568 && (host.equalsIgnoreCase("shacraft.ru") || host.equals("135.106.154.86") || host.equals("135.106.219.182"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "shacraft_admission",
|
||||
"version": "${version}",
|
||||
"name": "ShaCraft Minigames Admission",
|
||||
"description": "Account-bound admission to ShaCraft Minigames.",
|
||||
"environment": "client",
|
||||
"entrypoints": { "client": ["ru.shacraft.admission.ClientAdmission"] },
|
||||
"depends": { "fabricloader": ">=0.19.5", "minecraft": "26.2", "java": ">=25", "fabric-networking-api-v1": "*" }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
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("shacraft.ru",25568,false));
|
||||
assertTrue(AdmissionProof.allowedTarget("135.106.219.182",25568,false));
|
||||
assertTrue(AdmissionProof.allowedTarget("135.106.154.86",25568,false));
|
||||
assertFalse(AdmissionProof.allowedTarget("135.106.219.183",25568,false));
|
||||
assertFalse(AdmissionProof.allowedTarget("135.106.219.182",25567,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));
|
||||
}
|
||||
}
|
||||
@@ -57,10 +57,13 @@ own on disk, confirmed).
|
||||
Hosts: `login.microsoftonline.com`, `user.auth.xboxlive.com`,
|
||||
`xsts.auth.xboxlive.com`, `api.minecraftservices.com`.
|
||||
|
||||
Real device-code OAuth login -> Xbox Live user token -> XSTS token ->
|
||||
Minecraft Services login -> `GET /minecraft/profile` ownership check (404 =
|
||||
doesn't own the game = nothing installs or launches). This is the actual
|
||||
ownership gate; it is not optional and there is no fallback identity. See
|
||||
This module is retained for a future Microsoft mode; the current launcher
|
||||
uses authenticated ShaCraft account links and deterministic offline identity.
|
||||
Do not treat this unused module as the active launch gate.
|
||||
|
||||
In a Microsoft flow: device-code OAuth -> Xbox Live user token -> XSTS token ->
|
||||
Minecraft Services login -> `GET /minecraft/profile` ownership check. An
|
||||
authentication/ownership failure must never fall back to another identity. See
|
||||
`MSA_CLIENT_ID`'s doc comment in `msa.rs`: unlike the other three domains,
|
||||
this one needs a deployment-specific value — ShaCraft's own Azure AD app
|
||||
registration, approved for Minecraft API access via
|
||||
@@ -70,13 +73,14 @@ refuse to run while it's still the placeholder.
|
||||
## 4. Eclipse Adoptium (`runtime.rs`)
|
||||
|
||||
Host: `api.adoptium.net` (redirects to `github.com`/
|
||||
`objects.githubusercontent.com` for the actual download — expected, still
|
||||
`objects.githubusercontent.com`/`release-assets.githubusercontent.com` for the download — expected, still
|
||||
verified).
|
||||
|
||||
Java 21 JRE, GPLv2+CE. The API returns the release's SHA-256 inline, verified
|
||||
before extraction. Never touches a Java installation the user already has —
|
||||
`java::ensure_java` only provisions here when `java::detect()` finds nothing
|
||||
with at least the manifest's `javaMajor`.
|
||||
with exactly the manifest's `javaMajor`; a newer major is not assumed
|
||||
compatible with the Minecraft/NeoForge version.
|
||||
|
||||
## Why this separation matters
|
||||
|
||||
@@ -86,3 +90,21 @@ URL — cannot redirect a download to an attacker-controlled host in any of
|
||||
these domains. When adding a new game-related download, verify its host is
|
||||
one of the ones above (or add a new hardcoded constant following the same
|
||||
pattern) rather than accepting a URL from anywhere else.
|
||||
|
||||
## 5. Fabric (`fabric.rs`, Minigames)
|
||||
|
||||
Metadata comes only from `https://meta.fabricmc.net/v2/versions/loader/` for
|
||||
manifest-selected, validated version identifiers. Profile ID, parent and
|
||||
KnotClient main class must match. At most 512 KiB metadata and 32 libraries
|
||||
are accepted. Library URLs are constructed only below
|
||||
`https://maven.fabricmc.net/` from portable three-part Maven coordinates;
|
||||
other metadata origins are rejected. Libraries require a 40-hex SHA-1 and a
|
||||
positive size up to 64 MiB. When Fabric metadata omits either for its loader
|
||||
jar, the fixed Maven's `.sha1` sidecar and HEAD provide them. Existing verified
|
||||
downloads perform the hash/size check and atomic rename. The ShaCraft signed
|
||||
manifest cannot select Fabric metadata URLs, repositories or launch targets.
|
||||
|
||||
Minecraft 26.2's official version metadata requires Java 25. `runtime.rs`
|
||||
already provisions a separate Adoptium Java 25 runtime without changing the
|
||||
Aeronautics Java 21 runtime. Client companion mods and Fabric API are separately
|
||||
approved ShaCraft managed files in the signed Minigames profile.
|
||||
|
||||
+324
-17
@@ -2,24 +2,37 @@
|
||||
|
||||
## Current capability
|
||||
|
||||
The launcher persists local settings, synchronises Aeronautics mod/config
|
||||
The launcher persists local settings, synchronises profile mod/config
|
||||
files from the signed ShaCraft v2 manifest, installs the exact Minecraft +
|
||||
NeoForge version the manifest specifies, and launches the game. Players can
|
||||
launch either with a real Microsoft account or with a local offline profile
|
||||
(nickname + deterministic offline UUID) — see `docs/game-trust-boundary.md`
|
||||
and `AGENTS.md`'s trust model section.
|
||||
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.
|
||||
|
||||
The interface also shows a live Aeronautics player count from the fixed,
|
||||
read-only `https://shacraft.ru/api/online/aoc` endpoint. It is display-only:
|
||||
the result never controls files, versions, URLs, or the launch command.
|
||||
|
||||
Not yet implemented: a user-selectable profile directory, a "reset managed
|
||||
files only" recovery action, and signed cross-platform release builds of the
|
||||
launcher itself. Do not represent these as completed in UI or release notes.
|
||||
Version 0.1.3 adds signed application updates, separate from modpack sync.
|
||||
Version 0.1.4 adds installed deb updates with system administrator confirmation.
|
||||
The first AppImage upgrade from 0.1.2 and deb upgrade from 0.1.3 are manual.
|
||||
Windows/macOS 0.1.5 packages are published; actual desktop installation tests remain pending.
|
||||
Not yet implemented: a user-selectable profile directory and a "reset managed
|
||||
files only" recovery action. OS code signing/notarization is separate from the
|
||||
updater signatures and is not certified by this implementation.
|
||||
|
||||
## Data flow
|
||||
|
||||
Two independent pipelines feed one launch:
|
||||
Managed files, game installation and account admission meet at Java spawn:
|
||||
|
||||
```text
|
||||
ShaCraft manifest (mods/config + which MC/loader/Java version to use)
|
||||
@@ -33,8 +46,14 @@ Game itself (never controlled by the manifest above)
|
||||
-> NeoForge's own installer, run headlessly (neoforge.rs)
|
||||
-> generic inheritsFrom merge of the two version JSONs (mojang.rs)
|
||||
-> SHA-1-verified merged libraries + platform natives (mojang.rs)
|
||||
-> real Microsoft/Xbox/Minecraft Services login (msa.rs)
|
||||
-> java process spawned with the merged classpath/args (launch.rs)
|
||||
|
||||
Admission (fixed ShaCraft account API; never controlled by a manifest)
|
||||
native website session -> direct free-nickname claim or admin migration
|
||||
-> after installation: OS CSPRNG -> ephemeral Ed25519 key (admission.rs)
|
||||
-> public key + bearer session -> one-use ticket for canonical aoc nickname
|
||||
-> deterministic offline UUID for that nickname (session.rs)
|
||||
-> Java with merged classpath/args + child-only ticket/private-key environment
|
||||
-> client mod signs server challenge; server validates before world entry
|
||||
```
|
||||
|
||||
Profiles (ShaCraft-managed mods/config, and the player's own worlds/
|
||||
@@ -42,8 +61,10 @@ screenshots/resourcepacks) live below Tauri's `app_data_dir()/profiles/
|
||||
<profile-id>` — this becomes `--gameDir`. The shared vanilla+NeoForge
|
||||
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`, the Microsoft refresh token at
|
||||
`app_data_dir()/account.json` (mode 600). None of these should be assumed to
|
||||
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
|
||||
be the system `.minecraft` directory.
|
||||
|
||||
## Aeronautics contract
|
||||
@@ -57,15 +78,301 @@ be the system `.minecraft` directory.
|
||||
no launcher release.
|
||||
- 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.
|
||||
|
||||
## Planned but not implemented
|
||||
|
||||
1. User-selectable profile directory and structured launcher logs.
|
||||
2. "Reset managed files only" recovery action that doesn't touch player
|
||||
worlds/screenshots/resourcepacks.
|
||||
3. Signed, cross-platform release builds of the launcher itself.
|
||||
4. Real per-stage byte progress for the Java/NeoForge install steps
|
||||
(currently start/done only — the dominant, user-visible wait, asset
|
||||
downloading, already reports real bytes).
|
||||
3. Windows Authenticode/macOS signing-notarization and cross-platform release
|
||||
installation testing.
|
||||
4. Cancellation, structured logs and a full cold-install/recovery beta on
|
||||
every target OS. Install progress reports bytes or installer work counts
|
||||
depending on the stage; these units are not interchangeable.
|
||||
5. Transparent reconnect after the admission ticket has been consumed or
|
||||
expired. The current implementation requires a new launch.
|
||||
|
||||
Do not represent these as completed features in UI or release notes.
|
||||
|
||||
|
||||
## Module boundaries (2026-09-09)
|
||||
|
||||
React entrypoint → App/components → hooks → typed native service. Pure
|
||||
reducers own game and profile states; IPC failures retain their real message.
|
||||
Settings writes are serialized, and account restoration is single-flight
|
||||
even under React StrictMode. A failed repair invalidates profile readiness.
|
||||
Game exit may arrive before launch acknowledgement; the reducer handles both.
|
||||
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.
|
||||
`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
|
||||
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.
|
||||
|
||||
|
||||
## Cross-platform release 0.1.5 (2026-09-10)
|
||||
|
||||
Published Windows x64 EXE/MSI, macOS aarch64 and x86_64 DMG/app.tar.gz,
|
||||
and Linux x64 AppImage/DEB on https://shacraft.ru/help#launcher. The signed
|
||||
stable feed includes all platforms plus the legacy/exact Linux aliases.
|
||||
CI source b43fc610c43a9ec9f5f3ffce601de9604670ff8a, successful Actions run
|
||||
https://github.com/emil28092005/shacraft-launcher/actions/runs/34512683651.
|
||||
An initial non-Linux borrow/move compilation error in updater target selection
|
||||
was fixed before the final build. Native tests: Windows70, macOS74 per arch,
|
||||
Linux84; UI tests pass on all four runners. Local checks verify macOS bundle
|
||||
version/CPU type, Linux package identity and every artifact signature. Public
|
||||
HTTPS downloads of all eight artifacts match local SHA-256. Website418 tests
|
||||
plus48 subtests pass; only backend recreated, game containers unchanged.
|
||||
Updater signatures use the existing operator-held key, never uploaded to CI or
|
||||
server. Windows installers have no Authenticode signature; macOS is not Apple
|
||||
notarized. Native CI tests and packaging do not certify full Minecraft installs
|
||||
or desktop updater/restart behavior on Windows/macOS. Older unsupported clients
|
||||
need a manual installation of the current release. Previous releases immutable.
|
||||
|
||||
The live native updater test passed against the published 0.1.5 feed: verified download, corrupted-byte rejection and replacement of only a temporary AppImage copy. The user-installed launcher was not modified.
|
||||
|
||||
|
||||
## Admission client menu 0.1.1 (2026-09-11)
|
||||
|
||||
The signed Aeronautics payload now contains admission mod 0.1.1 at the existing
|
||||
managed path `mods/shacraft-admission-0.1.0.jar` to prevent duplicate mod IDs on
|
||||
upgrade. SHA-256: `faa9ae13cb0f2d93c03dae26ab36ae20d3fb6b66c89c09254b16808d6b183f89`.
|
||||
From the title screen (and vanilla safety acknowledgement), Multiplayer connects
|
||||
to fixed `135.106.154.86:25567`. Cancel/errors return to TitleScreen; transitions
|
||||
from other screens do not auto-connect. Client-only registration, protocol 1 and
|
||||
one-use admission remain unchanged. Running game server was not restarted.
|
||||
Linux Java 21 build and 8 mod tests pass. An opt-in native live test verifies
|
||||
signed-manifest retrieval and download/repair/restoration of the admission jar
|
||||
only in a temporary directory. Mac 0.1.5 connection failure remains unclassified
|
||||
pending exact error/log; this is not a verified macOS fix or desktop UI test.
|
||||
|
||||
## Minigames alongside Aeronautics (2026-09-13)
|
||||
|
||||
The new native allowlist adds profile `minigames` at
|
||||
`https://shacraft.ru/api/launcher/v2/profiles/minigames/signed-manifest` and
|
||||
its display-only count at `https://shacraft.ru/api/online/minigames`.
|
||||
Aeronautics remains a separate profile and keeps its previous managed files.
|
||||
Minigames uses Minecraft 26.2, Fabric Loader 0.19.5, Java 25 and its own
|
||||
`profiles/minigames` game directory. The signed payload supplies Fabric API
|
||||
0.160.0+26.2 and `mods/shacraft-admission-client-0.1.0.jar`; it never supplies
|
||||
Paper, the server plugins, maps, credentials or game-download URLs.
|
||||
|
||||
`fabric.rs` obtains an exact parent/loader profile from fixed Fabric metadata,
|
||||
checks its identity and KnotClient entry point, and converts bounded Maven
|
||||
library entries into the existing verified library contract. Each artifact
|
||||
URL is constructed from a validated coordinate below fixed Fabric Maven;
|
||||
metadata-supplied alternative origins are rejected. SHA-1 and size come from
|
||||
Fabric metadata, or the same Maven's hash sidecar and HEAD for the loader jar.
|
||||
Java provisioning already accepts exactly Java 25. Automatic installation on
|
||||
a platform still requires a real cold-install check on that platform.
|
||||
|
||||
Both profiles claim/display the canonical `aoc` nickname. Only the native
|
||||
profile mapping determines ticket `server_id` (`aoc` or `minigames`), and the
|
||||
response must match that exact server. The backend is responsible for shared
|
||||
access checks at both issuance and redemption. Existing account sessions and
|
||||
settings need no migration. No copied subscription/whitelist grant is trusted.
|
||||
|
||||
Minigames adds native `--quickPlayMultiplayer 135.106.154.86:25568` at launch.
|
||||
The client-only Fabric companion validates the actual socket target and signs
|
||||
the existing configuration challenge with `minigames` in the transcript.
|
||||
Paper performs verification before entry; its early duplicate UUID gate must
|
||||
run before vanilla would evict the existing player. There is no proxy and no
|
||||
client-side shared secret. One ticket is used for one game connection; a fresh
|
||||
launcher start is required after expiry, consumption or a failed proof attempt.
|
||||
|
||||
The integration is staged until server authentication, signed profile payload,
|
||||
and a newer signed launcher release are deployed and checked together. The
|
||||
existing public 0.1.5 binary cannot select the new profile by a website-only
|
||||
catalog change. Preserve both catalog entries when publishing either profile.
|
||||
|
||||
## Server migration — launcher 0.1.7 (2026-09-17)
|
||||
|
||||
Minigames Quick Play now uses the fixed native endpoint `shacraft.ru:25568`.
|
||||
It no longer pins the retired server IP in the application binary. The Fabric
|
||||
admission client accepts the new actual socket IP `135.106.219.182` and retains
|
||||
`135.106.154.86` for the temporary forwarding path; unrelated hosts and ports
|
||||
remain rejected. The server published updated signed companion manifests.
|
||||
|
||||
The migration's full-platform packages are prepared on
|
||||
`codex/server-migration-20260917`. Existing 0.1.6 installers still connect to the
|
||||
old IP and need that forwarding path until upgraded. Version 0.1.7 uses a newly generated operator-held updater key and the fixed
|
||||
`https://shacraft.ru/launcher/updates/stable-v2.json` channel. It requires one
|
||||
manual installation. Preserve `stable.json` at the last old-key release; never
|
||||
replace it with new-key metadata. Subsequent v2-channel releases use the new
|
||||
key, kept only at `/home/emil/.local/share/shacraft-updater/production.key`.
|
||||
Do not claim publication based on CI packages alone.
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
> From 0.1.7, new installations trust the new operator key and use
|
||||
> `https://shacraft.ru/launcher/updates/stable-v2.json`. Migration from 0.1.6
|
||||
> requires manual installation. Keep the old `stable.json` feed unchanged.
|
||||
> For future releases use `stable-v2.json` in publication commands below.
|
||||
|
||||
# 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/).
|
||||
@@ -0,0 +1,86 @@
|
||||
# Глобальный рефакторинг — 2026-09-09
|
||||
|
||||
## Границы работы
|
||||
|
||||
Переработаны backend/шаблоны сайта и React/Rust-слои нового лаунчера.
|
||||
Не менялись миры, модпак, порты, compose-топология, цены или режимы аккаунтов.
|
||||
Не переносились production-БД, секреты и приватный ключ подписи.
|
||||
|
||||
## Backend
|
||||
|
||||
- Монолит main.py разделён на HTTP routers, services, schemas, middleware
|
||||
и единый резолвер ресурсов. Entrypoint app.main:app сохранён.
|
||||
- Cookie/bearer вход используют одну реализацию аккаунтов, паролей и сессий.
|
||||
- Ограничитель попыток входа защищён от гонок и бесконечного роста памяти.
|
||||
Он process-local; multi-worker развертывание требует общего хранилища.
|
||||
- Оплата и подписка фиксируются одной SQLite-транзакцией. Claim/reject,
|
||||
продление, recovery и привязки сериализуют конфликтующие операции.
|
||||
- JSON metadata проверяются централизованно: неверный UTF-8, не-object JSON
|
||||
и небезопасные идентификаторы не приводят к непредусмотренному чтению пути.
|
||||
- Jinja наследование убирает копии фона/шапки/навигации. Размер фона 72×83
|
||||
задаётся единожды. HTML-формы, скрипты и визуальная структура сохранены.
|
||||
- Python зависимости зафиксированы на версиях действовавшего runtime.
|
||||
Добавлены pytest, Ruff, CI и изолированный test-stage Docker.
|
||||
|
||||
## Лаунчер
|
||||
|
||||
- React разделён на компоненты, hooks, типизированный IPC и reducers.
|
||||
TypeScript проверяется сборкой; ошибки IPC не теряются.
|
||||
- Сохранены новые изменения GitHub 0.1.1: обязательный ShaCraft-аккаунт,
|
||||
verified aoc nickname, реальный онлайн, управление окном и Windows-фиксы
|
||||
установки/полных библиотек/точной Java major/длинной JVM-команды.
|
||||
- Сохранения настроек упорядочены; lifecycle install/launch/exit явный;
|
||||
демонстрационные значения прогресса не выдаются за реальную установку.
|
||||
- Rust commands выделены по ответственности. Неиспользуемые команды
|
||||
unsigned sync/inspect удалены; запись профиля требует verified manifest.
|
||||
- Общие атомарные записи и process-local permits защищают файлы от
|
||||
конфликтующих операций. Проверяются переносимые пути, symlink и подпись.
|
||||
- HTTPS exact-host policy распространяется и на redirects отдельных
|
||||
игровых провайдеров. Метаданные архивов Adoptium проверяются до загрузки.
|
||||
- GitHub: тесты/сборка на push/PR; ручная матрица пакетов Linux/Windows/macOS
|
||||
Intel/ARM с сохранением артефактов. Это не подпись релизов и не автообновление.
|
||||
|
||||
## Проверки
|
||||
|
||||
Фактический итог: 88 backend-тестов + 23 subtests (локально и в изолированном
|
||||
Docker), 22 UI-теста, 59 Rust unit tests и отдельная живая read-only проверка
|
||||
production signed-manifest — успешно. npm ci/audit: 0 известных уязвимостей
|
||||
на момент прогона; строгий TypeScript/Vite и Ruff проходят. Это итог после
|
||||
объединения со всеми изменениями GitHub 0.1.1; прежние upstream Rust-тесты
|
||||
сохранены. Browser smoke: вход/регистрация ShaCraft, preview-gating,
|
||||
настройки, закрытие Escape и восстановление фокуса проверены без отправки
|
||||
учётных данных. Четыре тяжёлых/live игровых сценария не запускались.
|
||||
|
||||
Backend выложен, image ID контейнера совпадает с собранным образом;
|
||||
главная/Help/Моды/кабинет/healthz/catalog/signed-manifest отвечают 200,
|
||||
публичная админка по-прежнему закрыта Caddy (403). Внешний вид проверен
|
||||
в браузере. Время запуска mc-aoc не изменилось. Резервный образ сохранён
|
||||
как `shacraft-backend:before-refactor-20260909`, исходники —
|
||||
`/root/shacraft-rollback-NWKzLR`. Схема БД не менялась.
|
||||
|
||||
Backend: неизменность полного OpenAPI-снимка, публичные шаблоны, аккаунты,
|
||||
пароли/recovery, rate limit, LoginSystem gating, admin auth, платежи,
|
||||
rollback/параллельные операции, каталоги/manifest/config, startup/shutdown.
|
||||
Внешние эффекты замещены заглушками, БД только временная.
|
||||
|
||||
Launcher: строгий TypeScript + Vite, тесты UI state/settings/listeners,
|
||||
Rust unit tests по подписи/путям/хранилищу/IPC guards/trusted hosts.
|
||||
Полная холодная установка игры, OAuth и запуск на Windows/macOS требуют
|
||||
отдельного ручного beta-прогона; не выдавать локальные проверки за такой тест.
|
||||
|
||||
## Не замаскированные рефактором ограничения
|
||||
|
||||
1. Привязка ника: NoGravity подтверждает авторизацию игрока на сервере,
|
||||
но не связь с конкретным веб-запросом. Нужен одноразовый challenge в игре.
|
||||
2. Реферальная акция: enforcement REFERRAL_ENABLED и новизны самого
|
||||
плательщика не соответствует всей публичной формулировке. Требуется
|
||||
отдельное согласованное изменение бизнес-правил и регрессионные тесты.
|
||||
3. RCON/уведомления после commit не имеют outbox/retry. Сбой может потребовать
|
||||
ручной выдачи, а не повторного начисления подписки.
|
||||
4. Microsoft OAuth не является текущим способом входа: используется
|
||||
ShaCraft account + verified nickname. Неактивный OAuth-модуль требует
|
||||
собственного client ID и API approval при отдельной будущей активации.
|
||||
5. Нужны подписанные installer/update-релизы, native beta на всех ОС,
|
||||
полноценная отмена загрузок и recovery пользовательских данных.
|
||||
6. Блокировки лаунчера process-local; symlink-проверки не защищают от
|
||||
злонамеренного same-user TOCTOU. Keychain хранения refresh token пока нет.
|
||||
@@ -0,0 +1,20 @@
|
||||
## Cross-platform release 0.1.5 (2026-09-10)
|
||||
|
||||
Published Windows x64 EXE/MSI, macOS aarch64 and x86_64 DMG/app.tar.gz,
|
||||
and Linux x64 AppImage/DEB on https://shacraft.ru/help#launcher. The signed
|
||||
stable feed includes all platforms plus the legacy/exact Linux aliases.
|
||||
CI source b43fc610c43a9ec9f5f3ffce601de9604670ff8a, successful Actions run
|
||||
https://github.com/emil28092005/shacraft-launcher/actions/runs/34512683651.
|
||||
An initial non-Linux borrow/move compilation error in updater target selection
|
||||
was fixed before the final build. Native tests: Windows70, macOS74 per arch,
|
||||
Linux84; UI tests pass on all four runners. Local checks verify macOS bundle
|
||||
version/CPU type, Linux package identity and every artifact signature. Public
|
||||
HTTPS downloads of all eight artifacts match local SHA-256. Website418 tests
|
||||
plus48 subtests pass; only backend recreated, game containers unchanged.
|
||||
Updater signatures use the existing operator-held key, never uploaded to CI or
|
||||
server. Windows installers have no Authenticode signature; macOS is not Apple
|
||||
notarized. Native CI tests and packaging do not certify full Minecraft installs
|
||||
or desktop updater/restart behavior on Windows/macOS. Older unsupported clients
|
||||
need a manual installation of the current release. Previous releases immutable.
|
||||
|
||||
The live native updater test passed against the published 0.1.5 feed: verified download, corrupted-byte rejection and replacement of only a temporary AppImage copy. The user-installed launcher was not modified.
|
||||
@@ -0,0 +1,117 @@
|
||||
# Launcher 0.1.6 release
|
||||
|
||||
Adds a separate Minigames profile (Minecraft 26.2 / Fabric 0.19.5 / Java 25)
|
||||
while keeping Aeronautics and its installed profile intact. Both use the
|
||||
existing canonical aoc nickname and shared access. Tickets remain bound to
|
||||
the selected server. Minigames connects to 135.106.154.86:25568 and proves
|
||||
account admission during configuration before world entry.
|
||||
|
||||
Local checks: 87 native tests, 33 UI tests, TypeScript/Vite, three Java client
|
||||
proof tests, an official Fabric metadata resolution check and the Linux native
|
||||
release build. The [real Fabric/Paper smoke](verification/minigames-fabric-2026-09-13.json)
|
||||
passed with a synthetic account: the unchanged production companion entered
|
||||
the lobby and the backend consumed its Minigames ticket. Published on 2026-09-13:
|
||||
all four cross-platform jobs succeeded in [build run 34778218510](https://github.com/emil28092005/shacraft-launcher/actions/runs/34778218510)
|
||||
at runtime source `799fa692ef5e775f0044fe300f65fd4564771d47`.
|
||||
[Checks run 34778334417](https://github.com/emil28092005/shacraft-launcher/actions/runs/34778334417)
|
||||
passed at `5b741771b4668a834a2c5b757379340f11b8ae3a`; the only difference is the
|
||||
CI job that runs all 18 publisher signature tests on Ubuntu 24.04. The CI Fabric
|
||||
jar exactly matches the one used by the real client smoke.
|
||||
|
||||
All eight packages were signed locally, verified again on the server, and
|
||||
published after a successful dry-run. Full public HTTPS downloads match the
|
||||
recorded SHA-256 values; package signatures and the public feed signature pass.
|
||||
The feed payload equals the locally signed canonical bytes. See the
|
||||
[publication receipt](verification/launcher-release-0.1.6.json). The previous
|
||||
0.1.5 feed was backed up; its packages remain unchanged. The private key stayed
|
||||
on the operator workstation.
|
||||
|
||||
## Build contract
|
||||
|
||||
Git remote: `git@github.com:emil28092005/shacraft-launcher.git`.
|
||||
The production admission line is branch `codex/launcher-updater`; `main` is a
|
||||
divergent unreleased prototype and must remain unchanged for this release.
|
||||
Push the reviewed commit to the production branch, then dispatch
|
||||
`gh workflow run build.yml --ref codex/launcher-updater --repo emil28092005/shacraft-launcher`.
|
||||
The check workflow runs on branch pushes. Verify each run's head SHA equals the
|
||||
reviewed commit before downloading artifacts. A main push also triggers the
|
||||
build matrix, but was not the publication path for this release.
|
||||
CI publishes unsigned artifacts named:
|
||||
|
||||
- `shacraft-launcher-linux-x64`: AppImage and deb.
|
||||
- `shacraft-launcher-windows-x64`: NSIS exe and MSI.
|
||||
- `shacraft-launcher-macos-arm64`: aarch64 app.tar.gz and DMG.
|
||||
- `shacraft-launcher-macos-x64`: x86_64 app.tar.gz and DMG.
|
||||
|
||||
`.github/workflows/check.yml` additionally tests the Java 25 admission client
|
||||
and uploads `shacraft-admission-client`. It has no production credentials.
|
||||
Download the artifacts from the checked run at the exact reviewed commit with
|
||||
`gh run download RUN_ID --repo emil28092005/shacraft-launcher --dir STAGING`.
|
||||
|
||||
## Signing and publication
|
||||
|
||||
Stage renamed ASCII filenames below a local downloads root, for example
|
||||
`/tmp/shacraft-release-0.1.6/downloads/0.1.6/`. Preserve already published
|
||||
0.1.5 bytes. Expected updater filenames:
|
||||
|
||||
- `ShaCraft.Launcher_0.1.6_amd64.AppImage`
|
||||
- `ShaCraft.Launcher_0.1.6_amd64.deb`
|
||||
- `ShaCraft.Launcher_0.1.6_x64-setup.exe`
|
||||
- `ShaCraft.Launcher_0.1.6_aarch64.app.tar.gz`
|
||||
- `ShaCraft.Launcher_0.1.6_x86_64.app.tar.gz`
|
||||
|
||||
MSI and DMG are additional manual downloads; the updater uses EXE and app.tar.gz.
|
||||
Inspect package versions, architecture and contents before signing. Sign each
|
||||
chosen file with the existing local operator key:
|
||||
|
||||
```bash
|
||||
npm run tauri -- signer sign --private-key-path /home/emil/.local/share/shacraft-updater/production.key ARTIFACT
|
||||
```
|
||||
|
||||
The key file is mode 0600 and remains local. Never read its contents into logs,
|
||||
copy it to CI/server or substitute a different signing identity. Existing
|
||||
`production.key.pub` is sufficient for every later verification/publication.
|
||||
|
||||
After creating a UTF-8 release notes file, prepare the payload:
|
||||
|
||||
```bash
|
||||
python3 scripts/publish_launcher_update.py prepare \
|
||||
--version 0.1.6 \
|
||||
--downloads-root /tmp/shacraft-release-0.1.6/downloads \
|
||||
--artifact linux-x86_64=ShaCraft.Launcher_0.1.6_amd64.AppImage \
|
||||
--artifact linux-x86_64-appimage=ShaCraft.Launcher_0.1.6_amd64.AppImage \
|
||||
--artifact linux-x86_64-deb=ShaCraft.Launcher_0.1.6_amd64.deb \
|
||||
--artifact windows-x86_64=ShaCraft.Launcher_0.1.6_x64-setup.exe \
|
||||
--artifact darwin-aarch64=ShaCraft.Launcher_0.1.6_aarch64.app.tar.gz \
|
||||
--artifact darwin-x86_64=ShaCraft.Launcher_0.1.6_x86_64.app.tar.gz \
|
||||
--notes-file /tmp/shacraft-release-0.1.6/notes.txt \
|
||||
--public-key /home/emil/.local/share/shacraft-updater/production.key.pub \
|
||||
--payload /tmp/shacraft-release-0.1.6/release.payload.json
|
||||
npm run tauri -- signer sign \
|
||||
--private-key-path /home/emil/.local/share/shacraft-updater/production.key \
|
||||
/tmp/shacraft-release-0.1.6/release.payload.json
|
||||
```
|
||||
|
||||
Upload only public packages, signatures, payload, public key and publisher.
|
||||
Server downloads root is `/root/shacraft/caddy/www/downloads/shacraft-launcher`;
|
||||
public artifact URLs are `https://shacraft.ru/downloads/shacraft-launcher/0.1.6/`
|
||||
followed by the checked filename. Preserve the old feed before publication.
|
||||
With the exact staged server paths, run the existing publisher first with
|
||||
`--dry-run`, then without it:
|
||||
|
||||
```bash
|
||||
python3 publish_launcher_update.py publish \
|
||||
--downloads-root /root/shacraft/caddy/www/downloads/shacraft-launcher \
|
||||
--public-key PUBLIC_KEY_FILE \
|
||||
--payload SIGNED_PAYLOAD_FILE \
|
||||
--signature PAYLOAD_SIGNATURE_FILE \
|
||||
--output /root/shacraft/data/launcher/updates/stable.json \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
Publication depends on the signed Minigames profile containing the final client
|
||||
and Fabric API jars, the healthy Paper admission gate, and the shared-access
|
||||
backend endpoints being available. Verify public HTTPS package hashes, feed
|
||||
signatures and an actual client admission before updating the website buttons.
|
||||
OS Authenticode/Apple notarization and cold installations on other platforms
|
||||
remain distinct from successful native CI/builds.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Launcher 0.1.7 release — server migration
|
||||
|
||||
Published on 2026-09-17. Minigames Quick Play uses `shacraft.ru:25568`;
|
||||
the Fabric admission companion accepts the new server's actual socket IP
|
||||
`135.106.219.182`, the domain and the temporary old-IP forwarding path.
|
||||
|
||||
The owner approved replacing the unavailable updater signing key and requiring
|
||||
one manual installation. Users of 0.1.6 and earlier must close Minecraft and
|
||||
the launcher, then install 0.1.7 from [the download page](https://shacraft.ru/help#launcher).
|
||||
Their installed game profiles do not need to be reinstalled. The new native
|
||||
updater endpoint is `https://shacraft.ru/launcher/updates/stable-v2.json`.
|
||||
The old `stable.json` remains byte-identical at 0.1.6 under its original key.
|
||||
Never publish new-key signatures to that old channel.
|
||||
|
||||
## Provenance and verification
|
||||
|
||||
Runtime source: `b674347e867af3edb6f00201bd30f1db7af4d9d2` on
|
||||
`codex/server-migration-20260917`.
|
||||
All four jobs succeeded in [build run 35167814993](https://github.com/emil28092005/shacraft-launcher/actions/runs/35167814993).
|
||||
[Checks run 35167815151](https://github.com/emil28092005/shacraft-launcher/actions/runs/35167815151)
|
||||
passed native checks, publisher signature tests and the Java 25 Fabric build.
|
||||
Downloaded artifact ZIP hashes match GitHub's SHA-256 digests and their run
|
||||
metadata points to the exact runtime commit. Local checks passed 33 UI tests,
|
||||
87 Rust tests (8 live/desktop tests ignored), TypeScript and Vite.
|
||||
|
||||
All eight packages were signed on the operator workstation, checked again on
|
||||
the new server, and published after the metadata publisher's dry-run. Complete
|
||||
public HTTPS downloads match the recorded hashes; all eight package signatures
|
||||
and the v2 feed's metadata signature verify. The public feed bytes exactly match
|
||||
the locally verified metadata and carry `Cache-Control: no-store`. The website
|
||||
shows 0.1.7 links and manual installation instructions. See the
|
||||
[publication receipt](verification/launcher-release-0.1.7.json).
|
||||
|
||||
Native executables extracted from Windows NSIS, Linux DEB and both macOS app
|
||||
archives contain the domain endpoint, v2 channel and expected public key, with
|
||||
no old IP literal. The CI-built Fabric companion was published at immutable
|
||||
SHA-256 `6ef059192cef0242839db3d6a234228b771196f23b9b1e307a17a27669632a90`.
|
||||
Its public profile signature uses the original profile key and verifies;
|
||||
Java 25 target checks accept only the intended destinations. Minecraft status
|
||||
queries work through both new and old public IPs (Paper 26.2, protocol 776).
|
||||
This release verification does not establish a fresh Windows/macOS installation
|
||||
or an authenticated game session on the migrated host. Authenticode and Apple
|
||||
notarization remain separate from the updater signature.
|
||||
|
||||
## Operator state
|
||||
|
||||
The new updater private key is only at
|
||||
`/home/emil/.local/share/shacraft-updater/production.key` (0600; parent 0700).
|
||||
Its public companion's SHA-256, after trimming whitespace, is
|
||||
`5ac34dd380307ab25fcfc1479ee6d653b9b43bce45b5a242950d09c01fe1b2f9`.
|
||||
Maintain an encrypted owner-controlled backup. No private key was uploaded to
|
||||
GitHub or the server. Future versions must use this same key and the v2 feed.
|
||||
|
||||
Production is `135.106.219.182`. Release evidence is under
|
||||
`/root/shacraft/.release-staging/launcher-0.1.7`, with the exact runtime source
|
||||
snapshot at `/root/shacraft-launcher-0.1.7`. The unversioned launcher checkout on
|
||||
that host is older. The website image is `shacraft/backend:migration-017-20260917`.
|
||||
|
||||
The old host `135.106.154.86` only forwards ports 80, 443 and 25568. Keep it
|
||||
until authoritative DNS and caches have updated **and** users of 0.1.6 have
|
||||
manually upgraded: those binaries pin the old IP independently of DNS.
|
||||
Do not restart old application containers or Hermes. Detailed migration and
|
||||
rollback context is in `/context/migration-20260917.md` on the new host.
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"recorded_at": "2026-09-13T20:03:46.913196+00:00",
|
||||
"version": "0.1.6",
|
||||
"source_sha": "799fa692ef5e775f0044fe300f65fd4564771d47",
|
||||
"branch": "codex/launcher-updater",
|
||||
"build_run": 34778218510,
|
||||
"checks_run": 34778334417,
|
||||
"stable_feed": "https://shacraft.ru/launcher/updates/stable.json",
|
||||
"feed_sha256": "342876aee8760a984421889cec1453dd642ebf988ab2bad27f23ea4f4f203f8d",
|
||||
"feed_signature_verified": true,
|
||||
"feed_payload_equals_locally_signed_payload": true,
|
||||
"platforms": [
|
||||
"darwin-aarch64",
|
||||
"darwin-x86_64",
|
||||
"linux-x86_64",
|
||||
"linux-x86_64-appimage",
|
||||
"linux-x86_64-deb",
|
||||
"windows-x86_64"
|
||||
],
|
||||
"artifacts": [
|
||||
{
|
||||
"artifact": "ShaCraft.Launcher_0.1.6_amd64.AppImage",
|
||||
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.6/ShaCraft.Launcher_0.1.6_amd64.AppImage",
|
||||
"size": 83274232,
|
||||
"sha256": "fa01905baddebd08b59ab7855009f0b29df02fcd3813941a0190f5135ff58f7c",
|
||||
"public_signature_verified": true
|
||||
},
|
||||
{
|
||||
"artifact": "ShaCraft.Launcher_0.1.6_amd64.deb",
|
||||
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.6/ShaCraft.Launcher_0.1.6_amd64.deb",
|
||||
"size": 5997818,
|
||||
"sha256": "01621fde16f6fc1ca453acffb1d22d664c8d8b60909d22a8aa2af10ac80d8737",
|
||||
"public_signature_verified": true
|
||||
},
|
||||
{
|
||||
"artifact": "ShaCraft.Launcher_0.1.6_x64-setup.exe",
|
||||
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.6/ShaCraft.Launcher_0.1.6_x64-setup.exe",
|
||||
"size": 3793645,
|
||||
"sha256": "0bb2a577d88d4387f23efcd28b19bf83e373b1b10c5cbbb5d6e0f69428d9f9ab",
|
||||
"public_signature_verified": true
|
||||
},
|
||||
{
|
||||
"artifact": "ShaCraft.Launcher_0.1.6_x64_en-US.msi",
|
||||
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.6/ShaCraft.Launcher_0.1.6_x64_en-US.msi",
|
||||
"size": 5554176,
|
||||
"sha256": "8a53a9d8d6dbdd98238b294df604fe5050375e64709dfdff942bf60fcd61519b",
|
||||
"public_signature_verified": true
|
||||
},
|
||||
{
|
||||
"artifact": "ShaCraft.Launcher_0.1.6_aarch64.app.tar.gz",
|
||||
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.6/ShaCraft.Launcher_0.1.6_aarch64.app.tar.gz",
|
||||
"size": 5876796,
|
||||
"sha256": "c113afc0794eaa6788cb994e7d84f821d22573d559f3cddfb0bcd5c2cad557cf",
|
||||
"public_signature_verified": true
|
||||
},
|
||||
{
|
||||
"artifact": "ShaCraft.Launcher_0.1.6_aarch64.dmg",
|
||||
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.6/ShaCraft.Launcher_0.1.6_aarch64.dmg",
|
||||
"size": 6420101,
|
||||
"sha256": "0391a1d4afa6cb21663f1f8de367ecdd68a51656db48e86ad0bec617270c6036",
|
||||
"public_signature_verified": true
|
||||
},
|
||||
{
|
||||
"artifact": "ShaCraft.Launcher_0.1.6_x86_64.app.tar.gz",
|
||||
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.6/ShaCraft.Launcher_0.1.6_x86_64.app.tar.gz",
|
||||
"size": 6032564,
|
||||
"sha256": "ee025cd13af1887334d0af5a7e04259eddd6339fe8509b108e0b3ac0e7268daf",
|
||||
"public_signature_verified": true
|
||||
},
|
||||
{
|
||||
"artifact": "ShaCraft.Launcher_0.1.6_x64.dmg",
|
||||
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.6/ShaCraft.Launcher_0.1.6_x64.dmg",
|
||||
"size": 6591340,
|
||||
"sha256": "cd54c8f975bb7a1ec587bbadde35caf4a3eb9a1f86182150310590e41dec4023",
|
||||
"public_signature_verified": true
|
||||
}
|
||||
],
|
||||
"previous_feed_version": "0.1.5",
|
||||
"previous_feed_sha256": "d81ab2b007f7f9a385f2ec28d17f204e75c0c2e92cf40931064dd66d3bd2898f",
|
||||
"previous_feed_backup": "/root/shacraft/.release-staging/launcher-0.1.6/stable.before-0.1.6.json",
|
||||
"private_key_location": "remains on operator workstation; not uploaded",
|
||||
"remote_publication": "all8 local signatures verified, remote hashes/signatures verified, dry-run passed, atomic publish passed",
|
||||
"limitations": "Cross-platform CI and package inspection passed. Windows/macOS cold installation and OS signing/notarization were not exercised."
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"source_sha": "b674347e867af3edb6f00201bd30f1db7af4d9d2",
|
||||
"version": "0.1.7",
|
||||
"build_run": 35167814993,
|
||||
"new_channel": "https://shacraft.ru/launcher/updates/stable-v2.json",
|
||||
"files": [
|
||||
{
|
||||
"name": "ShaCraft.Launcher_0.1.7_aarch64.app.tar.gz",
|
||||
"bytes": 5877025,
|
||||
"sha256": "c460fd5c766b1e1f64d55e23952bead6c1e6a1ecd395e8147ce625aeca4049f7"
|
||||
},
|
||||
{
|
||||
"name": "ShaCraft.Launcher_0.1.7_aarch64.dmg",
|
||||
"bytes": 6420405,
|
||||
"sha256": "be869b3221363ddb9fcc755bf76a78d3e44264c0bc3a24bcd9d4396ee118a7cb"
|
||||
},
|
||||
{
|
||||
"name": "ShaCraft.Launcher_0.1.7_amd64.AppImage",
|
||||
"bytes": 83270136,
|
||||
"sha256": "a2e2337e1b0ee07f062383fa2e3da8e8fc51c62e2ccacda89a45a1ea0d092351"
|
||||
},
|
||||
{
|
||||
"name": "ShaCraft.Launcher_0.1.7_amd64.deb",
|
||||
"bytes": 5997780,
|
||||
"sha256": "b6b9913063600659dfd917c7939e3c07675ca81e35c34cf6a90edc61ef30328e"
|
||||
},
|
||||
{
|
||||
"name": "ShaCraft.Launcher_0.1.7_x64-setup.exe",
|
||||
"bytes": 3792580,
|
||||
"sha256": "0b1167dae9fce7d85f1ad95b2857cce1df7d17fffcdd6c358901ff998c5ac6b6"
|
||||
},
|
||||
{
|
||||
"name": "ShaCraft.Launcher_0.1.7_x64.dmg",
|
||||
"bytes": 6591601,
|
||||
"sha256": "9b6cc5660817f4dd1684282e01daa1639d98e987325ec4924b42b07ae07e44d5"
|
||||
},
|
||||
{
|
||||
"name": "ShaCraft.Launcher_0.1.7_x64_en-US.msi",
|
||||
"bytes": 5554176,
|
||||
"sha256": "54d0e8425f0087f13c396efa464da5e8e95203e222353d66c11d68f1076abbb4"
|
||||
},
|
||||
{
|
||||
"name": "ShaCraft.Launcher_0.1.7_x86_64.app.tar.gz",
|
||||
"bytes": 6032631,
|
||||
"sha256": "05748cc29462f850a12da3cd84336872af4ee8f5a5de0a3b10d91461c60fbad7"
|
||||
}
|
||||
],
|
||||
"verified_at": "2026-09-17T01:09:31.123799+00:00",
|
||||
"public_https_packages": [
|
||||
{
|
||||
"name": "ShaCraft.Launcher_0.1.7_aarch64.app.tar.gz",
|
||||
"bytes": 5877025,
|
||||
"sha256": "c460fd5c766b1e1f64d55e23952bead6c1e6a1ecd395e8147ce625aeca4049f7"
|
||||
},
|
||||
{
|
||||
"name": "ShaCraft.Launcher_0.1.7_aarch64.dmg",
|
||||
"bytes": 6420405,
|
||||
"sha256": "be869b3221363ddb9fcc755bf76a78d3e44264c0bc3a24bcd9d4396ee118a7cb"
|
||||
},
|
||||
{
|
||||
"name": "ShaCraft.Launcher_0.1.7_amd64.AppImage",
|
||||
"bytes": 83270136,
|
||||
"sha256": "a2e2337e1b0ee07f062383fa2e3da8e8fc51c62e2ccacda89a45a1ea0d092351"
|
||||
},
|
||||
{
|
||||
"name": "ShaCraft.Launcher_0.1.7_amd64.deb",
|
||||
"bytes": 5997780,
|
||||
"sha256": "b6b9913063600659dfd917c7939e3c07675ca81e35c34cf6a90edc61ef30328e"
|
||||
},
|
||||
{
|
||||
"name": "ShaCraft.Launcher_0.1.7_x64-setup.exe",
|
||||
"bytes": 3792580,
|
||||
"sha256": "0b1167dae9fce7d85f1ad95b2857cce1df7d17fffcdd6c358901ff998c5ac6b6"
|
||||
},
|
||||
{
|
||||
"name": "ShaCraft.Launcher_0.1.7_x64.dmg",
|
||||
"bytes": 6591601,
|
||||
"sha256": "9b6cc5660817f4dd1684282e01daa1639d98e987325ec4924b42b07ae07e44d5"
|
||||
},
|
||||
{
|
||||
"name": "ShaCraft.Launcher_0.1.7_x64_en-US.msi",
|
||||
"bytes": 5554176,
|
||||
"sha256": "54d0e8425f0087f13c396efa464da5e8e95203e222353d66c11d68f1076abbb4"
|
||||
},
|
||||
{
|
||||
"name": "ShaCraft.Launcher_0.1.7_x86_64.app.tar.gz",
|
||||
"bytes": 6032631,
|
||||
"sha256": "05748cc29462f850a12da3cd84336872af4ee8f5a5de0a3b10d91461c60fbad7"
|
||||
}
|
||||
],
|
||||
"old_feed_preserved": true,
|
||||
"feed_sha256": "b46d9a6ef974e177fd3b0d945ff22efb0ec0afaaa71bae7f7a23003997682166"
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"recorded_at": "2026-09-13T19:32:59.436884+00:00",
|
||||
"result": "joined_world",
|
||||
"client": "Minecraft 26.2 / Fabric Loader 0.19.5 / Fabric API 0.160.0+26.2",
|
||||
"java": "25.0.2",
|
||||
"client_jar_sha256": "3335626b7c8fdd233e8531ad382594398c7df48842b1b919934d1154dd4ba8f1",
|
||||
"client_jar_size": 10667,
|
||||
"profile": "minigames",
|
||||
"synthetic_player": "AdmissionPilot",
|
||||
"endpoint": "127.0.0.1:25608",
|
||||
"environment": "isolated Xvfb :95; separate temporary game directory; synthetic account/ticket; explicit local socket test flag",
|
||||
"server_evidence": [
|
||||
"[22:32:02 INFO]: AdmissionPilot joined the game",
|
||||
"[22:32:02 INFO]: AdmissionPilot[/127.0.0.1:54602] logged in with entity id 77 at ([minecraft:shacraft_lobby_v2]0.5, 96.0, 43.5)"
|
||||
],
|
||||
"verified": [
|
||||
"production companion jar bytes unchanged",
|
||||
"queued minecraft:register during configuration",
|
||||
"server-bound Ed25519 response accepted",
|
||||
"actual vanilla client entered lobby world"
|
||||
],
|
||||
"not_tested": [
|
||||
"production public endpoint login",
|
||||
"Windows/macOS cold installation",
|
||||
"full GUI launcher install/account flow"
|
||||
],
|
||||
"notes": "Offline client emits Microsoft profile-certificate HTTP401; admission and world entry succeeded. Real account/session/password not used.",
|
||||
"backend_evidence": {
|
||||
"source": "backend agent read-only synthetic SQLite query",
|
||||
"issued_minigames_tickets_for_player": 1,
|
||||
"consumed_minigames_tickets_for_player": 1
|
||||
}
|
||||
}
|
||||
Generated
+589
-39
File diff suppressed because it is too large
Load Diff
+16
-11
@@ -1,27 +1,32 @@
|
||||
{
|
||||
"name": "shacraft-launcher-ui",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.7",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build": "npm run typecheck && vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "tsx --test src/services/async.test.ts src/state/game.test.ts src/state/profiles.test.ts src/state/account.test.ts src/components/account.test.tsx src/state/updater.test.ts src/components/updater.test.tsx",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "tauri build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@vitejs/plugin-react": "latest",
|
||||
"@tauri-apps/cli": "^2.9.4",
|
||||
"lucide-react": "latest",
|
||||
"react": "latest",
|
||||
"react-dom": "latest",
|
||||
"typescript": "latest",
|
||||
"vite": "latest"
|
||||
"@tauri-apps/api": "2.11.1",
|
||||
"lucide-react": "1.41.0",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.11.4"
|
||||
"@tauri-apps/cli": "2.11.4",
|
||||
"@types/node": "^22.19.0",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"tsx": "4.23.13",
|
||||
"typescript": "7.0.2",
|
||||
"vite": "8.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
__pycache__/
|
||||
@@ -0,0 +1,360 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Prepare and atomically publish a signed ShaCraft stable updater feed.
|
||||
|
||||
Requires Python 3.10+ and the minisign CLI. Only public keys are inputs.
|
||||
Signing is deliberately a separate, operator-controlled action.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import binascii
|
||||
import contextlib
|
||||
from datetime import datetime, timezone
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import selectors
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
ORIGIN = "https://shacraft.ru/downloads/shacraft-launcher/"
|
||||
PLATFORMS = {
|
||||
"linux-x86_64": (".AppImage",),
|
||||
"linux-x86_64-appimage": (".AppImage",),
|
||||
"linux-x86_64-deb": (".deb",),
|
||||
"windows-x86_64": (".exe", ".msi"),
|
||||
"darwin-x86_64": (".app.tar.gz",),
|
||||
"darwin-aarch64": (".app.tar.gz",),
|
||||
}
|
||||
FIELDS = {"version", "notes", "pub_date", "platforms"}
|
||||
MAX_ARTIFACT_BYTES = 256 * 1024 * 1024
|
||||
MAX_METADATA_BYTES = 64 * 1024
|
||||
DEB_PACKAGE = "sha-craft-launcher"
|
||||
DPKG_DEB = "/usr/bin/dpkg-deb"
|
||||
PACKAGE_TOOL_ENV = {"PATH": "/usr/bin:/bin", "LC_ALL": "C"}
|
||||
|
||||
|
||||
class InvalidRelease(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def version_tuple(version):
|
||||
if not isinstance(version, str) or not re.fullmatch(
|
||||
r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)", version
|
||||
):
|
||||
raise InvalidRelease("stable version must be plain MAJOR.MINOR.PATCH")
|
||||
parts = tuple(map(int, version.split(".")))
|
||||
if any(part > 2**64 - 1 for part in parts):
|
||||
raise InvalidRelease("version component exceeds SemVer range")
|
||||
return parts
|
||||
|
||||
|
||||
def artifact_name(platform, filename):
|
||||
if platform not in PLATFORMS:
|
||||
raise InvalidRelease("unsupported updater platform")
|
||||
if not isinstance(filename, str) or not re.fullmatch(
|
||||
r"[A-Za-z0-9][A-Za-z0-9._-]{0,199}", filename
|
||||
):
|
||||
raise InvalidRelease("artifact filename must be a plain ASCII filename")
|
||||
if not filename.endswith(PLATFORMS[platform]):
|
||||
raise InvalidRelease("artifact suffix does not match updater platform")
|
||||
return filename
|
||||
|
||||
|
||||
def regular_file(path, limit):
|
||||
info = path.lstat()
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_size == 0 or info.st_size > limit:
|
||||
raise InvalidRelease("input must be a nonempty regular file within size limit")
|
||||
return info
|
||||
|
||||
|
||||
def read_file(path, limit=MAX_METADATA_BYTES):
|
||||
regular_file(path, limit)
|
||||
with path.open("rb") as stream:
|
||||
value = stream.read(limit + 1)
|
||||
if len(value) > limit:
|
||||
raise InvalidRelease("input exceeds size limit")
|
||||
return value
|
||||
|
||||
|
||||
def decode_tauri(value):
|
||||
if not isinstance(value, str) or not value or len(value) > MAX_METADATA_BYTES:
|
||||
raise InvalidRelease("invalid Tauri base64 value")
|
||||
try:
|
||||
decoded = base64.b64decode(value, validate=True)
|
||||
decoded.decode("utf-8")
|
||||
except (binascii.Error, UnicodeDecodeError) as exc:
|
||||
raise InvalidRelease("invalid Tauri base64 encoding") from exc
|
||||
if base64.b64encode(decoded).decode("ascii") != value:
|
||||
raise InvalidRelease("noncanonical Tauri base64 encoding")
|
||||
return decoded
|
||||
|
||||
|
||||
def verify_signature(artifact, signature, public_key, minisign):
|
||||
# Tauri wraps the entire standard minisign text file in base64.
|
||||
signature_bytes = decode_tauri(signature)
|
||||
key_bytes = decode_tauri(public_key)
|
||||
with tempfile.TemporaryDirectory(prefix="shacraft-update-verify-") as temporary:
|
||||
root = Path(temporary)
|
||||
signature_path = root / "signature.minisig"
|
||||
key_path = root / "public.minisign.pub"
|
||||
signature_path.write_bytes(signature_bytes)
|
||||
key_path.write_bytes(key_bytes)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[minisign, "-V", "-q", "-m", str(artifact), "-x", str(signature_path),
|
||||
"-p", str(key_path)],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, timeout=120, check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise InvalidRelease("minisign verification could not run") from exc
|
||||
if result.returncode != 0:
|
||||
raise InvalidRelease("signature verification failed")
|
||||
|
||||
|
||||
def bounded_command_output(command, limit=4096, timeout=10):
|
||||
"""Run a fixed package inspector without shell, inherited hooks or unbounded output."""
|
||||
process = None
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL, env=PACKAGE_TOOL_ENV,
|
||||
)
|
||||
deadline = time.monotonic() + timeout
|
||||
output = bytearray()
|
||||
with selectors.DefaultSelector() as selector:
|
||||
selector.register(process.stdout, selectors.EVENT_READ)
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0 or not selector.select(remaining):
|
||||
raise InvalidRelease("package inspection timed out")
|
||||
chunk = os.read(process.stdout.fileno(), min(4096, limit + 1 - len(output)))
|
||||
if not chunk:
|
||||
break
|
||||
output.extend(chunk)
|
||||
if len(output) > limit:
|
||||
raise InvalidRelease("package inspection exceeds output limit")
|
||||
returncode = process.wait(timeout=max(0.001, deadline - time.monotonic()))
|
||||
if returncode != 0:
|
||||
raise InvalidRelease("package inspection failed")
|
||||
return bytes(output)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise InvalidRelease("package inspection could not run") from exc
|
||||
finally:
|
||||
if process is not None:
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
process.wait()
|
||||
process.stdout.close()
|
||||
|
||||
|
||||
def validate_artifact_format(platform, path, version):
|
||||
if platform in {"linux-x86_64", "linux-x86_64-appimage"}:
|
||||
with path.open("rb") as stream:
|
||||
header = stream.read(64)
|
||||
if (len(header) < 64 or header[:7] != b"\x7fELF\x02\x01\x01"
|
||||
or header[8:11] != b"AI\x02" or header[18:20] != b"\x3e\x00"):
|
||||
raise InvalidRelease("AppImage must be a type-2 x86_64 ELF image")
|
||||
elif platform == "linux-x86_64-deb":
|
||||
# Inspect only authenticated package bytes; dpkg-deb does not run maintainer scripts.
|
||||
output = bounded_command_output([
|
||||
DPKG_DEB, "--showformat=${Package}\n${Version}\n${Architecture}\n", "--show", str(path),
|
||||
])
|
||||
expected = f"{DEB_PACKAGE}\n{version}\namd64\n".encode("ascii")
|
||||
if output != expected:
|
||||
raise InvalidRelease("deb identity must match sha-craft-launcher, signed version and amd64")
|
||||
|
||||
|
||||
def validate_linux_aliases(platforms):
|
||||
if "linux-x86_64-appimage" in platforms or "linux-x86_64-deb" in platforms:
|
||||
legacy = platforms.get("linux-x86_64")
|
||||
exact = platforms.get("linux-x86_64-appimage")
|
||||
if legacy is None or exact is None or legacy != exact:
|
||||
raise InvalidRelease("format-aware Linux releases require identical legacy and AppImage entries")
|
||||
|
||||
|
||||
def strict_json(data):
|
||||
def unique(pairs):
|
||||
result = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise InvalidRelease("duplicate JSON key")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
try:
|
||||
return json.loads(data, object_pairs_hook=unique)
|
||||
except (ValueError, UnicodeDecodeError) as exc:
|
||||
raise InvalidRelease("invalid release JSON") from exc
|
||||
|
||||
|
||||
def canonical(payload):
|
||||
return json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
|
||||
def verify_payload_bytes(payload_bytes, signature, public_key, minisign):
|
||||
with tempfile.TemporaryDirectory(prefix="shacraft-update-payload-") as temporary:
|
||||
immutable_payload = Path(temporary) / "payload.json"
|
||||
immutable_payload.write_bytes(payload_bytes)
|
||||
verify_signature(immutable_payload, signature, public_key, minisign)
|
||||
|
||||
|
||||
def verified_previous(data, public_key, minisign):
|
||||
envelope = strict_json(data)
|
||||
if not isinstance(envelope, dict) or set(envelope) != FIELDS | {"signedPayload", "metadataSignature"}:
|
||||
raise InvalidRelease("existing feed must have authenticated metadata")
|
||||
payload_bytes = decode_tauri(envelope["signedPayload"])
|
||||
verify_payload_bytes(payload_bytes, envelope["metadataSignature"], public_key, minisign)
|
||||
payload = strict_json(payload_bytes)
|
||||
if payload != {key: envelope[key] for key in FIELDS}:
|
||||
raise InvalidRelease("existing feed fields differ from signed metadata")
|
||||
return payload
|
||||
|
||||
|
||||
def validate_payload(payload, downloads_root, public_key, minisign):
|
||||
if not isinstance(payload, dict) or set(payload) != FIELDS:
|
||||
raise InvalidRelease("payload must contain exactly the four Tauri release fields")
|
||||
version_tuple(payload["version"])
|
||||
if not isinstance(payload["notes"], str) or len(payload["notes"]) > 8000:
|
||||
raise InvalidRelease("release notes must contain at most 8000 characters")
|
||||
if not isinstance(payload["pub_date"], str):
|
||||
raise InvalidRelease("release date must be RFC3339 UTC")
|
||||
try:
|
||||
datetime.strptime(payload["pub_date"], "%Y-%m-%dT%H:%M:%SZ")
|
||||
except ValueError as exc:
|
||||
raise InvalidRelease("release date must be RFC3339 UTC") from exc
|
||||
platforms = payload["platforms"]
|
||||
if not isinstance(platforms, dict) or not platforms:
|
||||
raise InvalidRelease("at least one signed updater artifact is required")
|
||||
validate_linux_aliases(platforms)
|
||||
release_dir = downloads_root.resolve() / payload["version"]
|
||||
if release_dir.is_symlink() or not release_dir.is_dir():
|
||||
raise InvalidRelease("release directory must be an existing real directory")
|
||||
prefix = ORIGIN + payload["version"] + "/"
|
||||
for platform, artifact in platforms.items():
|
||||
if not isinstance(artifact, dict) or set(artifact) != {"url", "signature"}:
|
||||
raise InvalidRelease("artifact requires exactly url and signature")
|
||||
url = artifact["url"]
|
||||
if not isinstance(url, str) or not url.startswith(prefix):
|
||||
raise InvalidRelease("artifact must use the fixed ShaCraft release URL")
|
||||
filename = artifact_name(platform, url[len(prefix):])
|
||||
local_path = release_dir / filename
|
||||
before = regular_file(local_path, MAX_ARTIFACT_BYTES)
|
||||
verify_signature(local_path, artifact["signature"], public_key, minisign)
|
||||
validate_artifact_format(platform, local_path, payload["version"])
|
||||
after = regular_file(local_path, MAX_ARTIFACT_BYTES)
|
||||
if (before.st_ino, before.st_size, before.st_mtime_ns) != (
|
||||
after.st_ino, after.st_size, after.st_mtime_ns
|
||||
):
|
||||
raise InvalidRelease("artifact changed during verification")
|
||||
|
||||
|
||||
def atomic_write(destination, data):
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
descriptor, temporary = tempfile.mkstemp(prefix="." + destination.name + ".", dir=destination.parent)
|
||||
try:
|
||||
with os.fdopen(descriptor, "wb") as stream:
|
||||
os.fchmod(stream.fileno(), 0o644)
|
||||
stream.write(data)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, destination)
|
||||
directory = os.open(destination.parent, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(directory)
|
||||
finally:
|
||||
os.close(directory)
|
||||
finally:
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
os.unlink(temporary)
|
||||
|
||||
|
||||
def prepare(args, public_key):
|
||||
version_tuple(args.version)
|
||||
artifacts = {}
|
||||
for item in args.artifact:
|
||||
platform, separator, filename = item.partition("=")
|
||||
if not separator or platform in artifacts:
|
||||
raise InvalidRelease("use each --artifact PLATFORM=FILENAME exactly once")
|
||||
artifact_name(platform, filename)
|
||||
signature = read_file(args.downloads_root / args.version / (filename + ".sig"), 16384).decode("ascii").strip()
|
||||
artifacts[platform] = {
|
||||
"url": ORIGIN + args.version + "/" + filename,
|
||||
"signature": signature,
|
||||
}
|
||||
payload = {
|
||||
"version": args.version,
|
||||
"notes": read_file(args.notes_file).decode("utf-8").strip(),
|
||||
"pub_date": args.pub_date or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"platforms": artifacts,
|
||||
}
|
||||
validate_payload(payload, args.downloads_root, public_key, args.minisign)
|
||||
atomic_write(args.payload, canonical(payload))
|
||||
return payload
|
||||
|
||||
|
||||
def publish(args, public_key):
|
||||
payload_bytes = read_file(args.payload)
|
||||
payload = strict_json(payload_bytes)
|
||||
if not isinstance(payload, dict) or set(payload) != FIELDS:
|
||||
raise InvalidRelease("payload must contain exactly the four Tauri release fields")
|
||||
signature = read_file(args.signature, 16384).decode("ascii").strip()
|
||||
if canonical(payload) != payload_bytes:
|
||||
raise InvalidRelease("payload must be the exact canonical file from prepare")
|
||||
# Verify the captured bytes, so a changing operator input cannot replace
|
||||
# a verified file with different bytes in the feed.
|
||||
verify_payload_bytes(payload_bytes, signature, public_key, args.minisign)
|
||||
envelope = dict(payload)
|
||||
envelope["signedPayload"] = base64.b64encode(payload_bytes).decode("ascii")
|
||||
envelope["metadataSignature"] = signature
|
||||
data = json.dumps(envelope, ensure_ascii=False, indent=2).encode("utf-8") + b"\n"
|
||||
if len(data) > MAX_METADATA_BYTES:
|
||||
raise InvalidRelease("signed metadata exceeds size limit")
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
lock = args.output.with_name("." + args.output.name + ".lock")
|
||||
descriptor = os.open(lock, os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
||||
with os.fdopen(descriptor, "wb") as lock_stream:
|
||||
fcntl.flock(lock_stream, fcntl.LOCK_EX)
|
||||
if args.output.exists() or args.output.is_symlink():
|
||||
previous = verified_previous(read_file(args.output), public_key, args.minisign)
|
||||
if version_tuple(payload["version"]) <= version_tuple(previous["version"]):
|
||||
raise InvalidRelease("stable publication must strictly increase version")
|
||||
validate_payload(payload, args.downloads_root, public_key, args.minisign)
|
||||
if not args.dry_run:
|
||||
atomic_write(args.output, data)
|
||||
return payload
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
common = argparse.ArgumentParser(add_help=False)
|
||||
common.add_argument("--downloads-root", type=Path, required=True)
|
||||
common.add_argument("--public-key", type=Path, required=True, help="Tauri outer-base64 .pub file")
|
||||
common.add_argument("--minisign", default="minisign")
|
||||
common.add_argument("--payload", type=Path, required=True)
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
prepare_parser = commands.add_parser("prepare", parents=[common])
|
||||
prepare_parser.add_argument("--version", required=True)
|
||||
prepare_parser.add_argument("--artifact", action="append", required=True, metavar="PLATFORM=FILENAME")
|
||||
prepare_parser.add_argument("--notes-file", type=Path, required=True)
|
||||
prepare_parser.add_argument("--pub-date")
|
||||
publish_parser = commands.add_parser("publish", parents=[common])
|
||||
publish_parser.add_argument("--signature", type=Path, required=True)
|
||||
publish_parser.add_argument("--output", type=Path, required=True)
|
||||
publish_parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
public_key = read_file(args.public_key, 16384).decode("ascii").strip()
|
||||
payload = prepare(args, public_key) if args.command == "prepare" else publish(args, public_key)
|
||||
except (InvalidRelease, OSError, UnicodeError) as exc:
|
||||
parser.exit(1, f"Release rejected: {exc}\n")
|
||||
print(f"{args.command}: {payload['version']} ({', '.join(sorted(payload['platforms']))})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"bundle": {
|
||||
"createUpdaterArtifacts": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
"""Publisher policy and real minisign verification; keys exist only in tempdirs."""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import copy
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import publish_launcher_update as publisher
|
||||
|
||||
MINISIGN = os.environ.get("SHACRAFT_TEST_MINISIGN", "minisign")
|
||||
|
||||
|
||||
def appimage_fixture():
|
||||
header = bytearray(64)
|
||||
header[:7] = b"\x7fELF\x02\x01\x01"
|
||||
header[8:11] = b"AI\x02"
|
||||
header[18:20] = b"\x3e\x00"
|
||||
return bytes(header) + b"isolated format fixture; not a runnable launcher"
|
||||
|
||||
|
||||
class PolicyTests(unittest.TestCase):
|
||||
def test_stable_versions_are_strict_and_order_numerically(self):
|
||||
self.assertGreater(publisher.version_tuple("0.1.10"), publisher.version_tuple("0.1.9"))
|
||||
for value in ("v0.1.3", "0.01.3", "0.1.3-beta", "0.1.3+build", "../0.1.3", 3):
|
||||
with self.subTest(value=value), self.assertRaises(publisher.InvalidRelease):
|
||||
publisher.version_tuple(value)
|
||||
|
||||
def test_platform_filename_policy(self):
|
||||
publisher.artifact_name("linux-x86_64", "ShaCraft.Launcher_0.1.3_amd64.AppImage")
|
||||
publisher.artifact_name("linux-x86_64-appimage", "ShaCraft.Launcher_0.1.4_amd64.AppImage")
|
||||
publisher.artifact_name("linux-x86_64-deb", "ShaCraft.Launcher_0.1.4_amd64.deb")
|
||||
for platform, filename in (
|
||||
("linux-x86_64", "../bad.AppImage"), ("linux-x86_64", "foo.AppImage?secret"),
|
||||
("linux-x86_64", "%2e%2e.AppImage"), ("linux-x86_64", "install.exe"),
|
||||
("unknown", "test.AppImage"), ("darwin-aarch64", "installer.dmg"),
|
||||
("linux-x86_64", "install.deb"), ("linux-x86_64-appimage", "install.deb"),
|
||||
("linux-x86_64-deb", "install.AppImage"),
|
||||
):
|
||||
with self.subTest(filename=filename), self.assertRaises(publisher.InvalidRelease):
|
||||
publisher.artifact_name(platform, filename)
|
||||
|
||||
def test_duplicate_json_keys_are_rejected(self):
|
||||
with self.assertRaises(publisher.InvalidRelease):
|
||||
publisher.strict_json(b'{"version":"0.1.3","version":"9.0.0"}')
|
||||
|
||||
def test_package_inspection_is_bounded_and_clears_environment(self):
|
||||
with self.assertRaisesRegex(publisher.InvalidRelease, "output limit"):
|
||||
publisher.bounded_command_output([sys.executable, "-c", "print('x' * 8192)"], limit=128)
|
||||
with self.assertRaisesRegex(publisher.InvalidRelease, "timed out"):
|
||||
publisher.bounded_command_output([sys.executable, "-c", "import time; time.sleep(30)"], timeout=0.1)
|
||||
os.environ["SHACRAFT_INSPECTION_SECRET_TEST"] = "must-not-be-inherited"
|
||||
try:
|
||||
output = publisher.bounded_command_output([
|
||||
sys.executable, "-c", "import os; print(os.getenv('SHACRAFT_INSPECTION_SECRET_TEST', 'clean'))",
|
||||
])
|
||||
finally:
|
||||
del os.environ["SHACRAFT_INSPECTION_SECRET_TEST"]
|
||||
self.assertEqual(output, b"clean\n")
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which(MINISIGN), "minisign CLI required for signature integration tests")
|
||||
class SignatureTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory(prefix="shacraft-update-test-")
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.root = Path(self.temporary.name)
|
||||
self.key = self.root / "fixture.key"
|
||||
public = self.root / "fixture.pub"
|
||||
subprocess.run(
|
||||
[MINISIGN, "-G", "-W", "-p", str(public), "-s", str(self.key)],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True,
|
||||
)
|
||||
self.public_key = base64.b64encode(public.read_bytes()).decode("ascii")
|
||||
self.downloads = self.root / "downloads"
|
||||
release = self.downloads / "0.1.3"
|
||||
release.mkdir(parents=True)
|
||||
self.artifact = release / "fixture.AppImage"
|
||||
self.artifact.write_bytes(appimage_fixture())
|
||||
self.payload = {
|
||||
"version": "0.1.3", "notes": "Проверка обновления", "pub_date": "2026-09-10T00:00:00Z",
|
||||
"platforms": {"linux-x86_64": {
|
||||
"url": publisher.ORIGIN + "0.1.3/fixture.AppImage",
|
||||
"signature": self.sign(self.artifact),
|
||||
}},
|
||||
}
|
||||
self.payload_path = self.root / "payload.json"
|
||||
self.output = self.root / "stable.json"
|
||||
|
||||
def sign(self, path):
|
||||
signature_path = path.with_name(path.name + ".minisig")
|
||||
subprocess.run(
|
||||
[MINISIGN, "-S", "-s", str(self.key), "-m", str(path), "-x", str(signature_path), "-q"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True,
|
||||
)
|
||||
signature = base64.b64encode(signature_path.read_bytes()).decode("ascii")
|
||||
path.with_name(path.name + ".sig").write_text(signature, encoding="ascii")
|
||||
return signature
|
||||
|
||||
def publish(self, payload=None, dry_run=False):
|
||||
self.payload_path.write_bytes(publisher.canonical(payload or self.payload))
|
||||
self.sign(self.payload_path)
|
||||
args = argparse.Namespace(
|
||||
payload=self.payload_path, signature=self.payload_path.with_name("payload.json.sig"),
|
||||
output=self.output, downloads_root=self.downloads, minisign=MINISIGN, dry_run=dry_run,
|
||||
)
|
||||
return publisher.publish(args, self.public_key)
|
||||
|
||||
def test_valid_signed_feed_binds_metadata_and_artifact(self):
|
||||
self.publish()
|
||||
envelope = publisher.strict_json(self.output.read_bytes())
|
||||
payload_bytes = publisher.decode_tauri(envelope["signedPayload"])
|
||||
self.assertEqual(publisher.strict_json(payload_bytes), self.payload)
|
||||
self.assertEqual({key: envelope[key] for key in publisher.FIELDS}, self.payload)
|
||||
self.assertIn("metadataSignature", envelope)
|
||||
self.assertEqual(self.output.stat().st_mode & 0o777, 0o644)
|
||||
|
||||
def test_tampered_artifact_is_rejected_before_publication(self):
|
||||
self.artifact.write_bytes(b"replaced executable")
|
||||
with self.assertRaisesRegex(publisher.InvalidRelease, "signature verification failed"):
|
||||
self.publish()
|
||||
self.assertFalse(self.output.exists())
|
||||
|
||||
def test_tampered_metadata_signature_is_rejected(self):
|
||||
self.payload_path.write_bytes(publisher.canonical(self.payload))
|
||||
signature = self.sign(self.payload_path)
|
||||
self.payload["notes"] = "Changed after signing"
|
||||
self.payload_path.write_bytes(publisher.canonical(self.payload))
|
||||
with self.assertRaisesRegex(publisher.InvalidRelease, "signature verification failed"):
|
||||
publisher.verify_signature(self.payload_path, signature, self.public_key, MINISIGN)
|
||||
|
||||
def test_same_version_or_downgrade_keeps_original_feed(self):
|
||||
self.publish()
|
||||
original = self.output.read_bytes()
|
||||
for version in ("0.1.3", "0.1.2"):
|
||||
payload = copy.deepcopy(self.payload)
|
||||
payload["version"] = version
|
||||
with self.subTest(version=version), self.assertRaisesRegex(
|
||||
publisher.InvalidRelease, "strictly increase"
|
||||
):
|
||||
self.publish(payload)
|
||||
self.assertEqual(self.output.read_bytes(), original)
|
||||
|
||||
def test_foreign_url_cannot_be_signed_into_feed(self):
|
||||
self.payload["platforms"]["linux-x86_64"]["url"] = "https://example.com/test.AppImage"
|
||||
with self.assertRaisesRegex(publisher.InvalidRelease, "fixed ShaCraft release URL"):
|
||||
self.publish()
|
||||
self.assertFalse(self.output.exists())
|
||||
|
||||
def test_previous_version_must_also_be_authenticated(self):
|
||||
self.publish()
|
||||
envelope = publisher.strict_json(self.output.read_bytes())
|
||||
envelope["version"] = "99.0.0"
|
||||
self.output.write_bytes(publisher.canonical(envelope))
|
||||
with self.assertRaisesRegex(publisher.InvalidRelease, "differ from signed metadata"):
|
||||
self.publish()
|
||||
|
||||
def test_missing_signature_or_symlink_is_rejected(self):
|
||||
original = self.artifact.read_bytes()
|
||||
target = self.root / "outside.AppImage"
|
||||
target.write_bytes(original)
|
||||
self.artifact.unlink()
|
||||
self.artifact.symlink_to(target)
|
||||
with self.assertRaisesRegex(publisher.InvalidRelease, "regular file"):
|
||||
self.publish()
|
||||
self.artifact.unlink()
|
||||
self.artifact.write_bytes(original)
|
||||
self.payload["platforms"]["linux-x86_64"].pop("signature")
|
||||
with self.assertRaisesRegex(publisher.InvalidRelease, "exactly url and signature"):
|
||||
self.publish()
|
||||
|
||||
def test_dry_run_verifies_without_creating_feed(self):
|
||||
self.publish(dry_run=True)
|
||||
self.assertFalse(self.output.exists())
|
||||
|
||||
def make_deb(self, package="sha-craft-launcher", version=None, architecture="amd64"):
|
||||
if not Path(publisher.DPKG_DEB).is_file():
|
||||
self.skipTest("dpkg-deb required for real deb validation")
|
||||
version = version or self.payload["version"]
|
||||
tree = self.root / "deb-tree"
|
||||
control = tree / "DEBIAN"
|
||||
control.mkdir(parents=True, exist_ok=True)
|
||||
(control / "control").write_text(
|
||||
f"Package: {package}\nVersion: {version}\nArchitecture: {architecture}\n"
|
||||
"Maintainer: Test <test@example.invalid>\nDescription: isolated updater fixture\n",
|
||||
encoding="ascii",
|
||||
)
|
||||
# Inspection must not execute a package script, even for an authenticated package.
|
||||
script = control / "preinst"
|
||||
script.write_text(f"#!/bin/sh\ntouch '{self.root / 'script-executed'}'\n", encoding="ascii")
|
||||
script.chmod(0o755)
|
||||
deb = self.artifact.with_name("fixture.deb")
|
||||
subprocess.run(
|
||||
[publisher.DPKG_DEB, "--build", "--root-owner-group", str(tree), str(deb)],
|
||||
env=publisher.PACKAGE_TOOL_ENV, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
timeout=10, check=True,
|
||||
)
|
||||
self.payload["platforms"]["linux-x86_64-appimage"] = copy.deepcopy(
|
||||
self.payload["platforms"]["linux-x86_64"]
|
||||
)
|
||||
self.payload["platforms"]["linux-x86_64-deb"] = {
|
||||
"url": publisher.ORIGIN + self.payload["version"] + "/fixture.deb", "signature": self.sign(deb),
|
||||
}
|
||||
return deb
|
||||
|
||||
def test_format_aware_release_preserves_legacy_appimage_and_verifies_real_deb(self):
|
||||
self.make_deb()
|
||||
notes = self.root / "notes.txt"
|
||||
notes.write_text(self.payload["notes"], encoding="utf-8")
|
||||
args = argparse.Namespace(
|
||||
version="0.1.3", artifact=[
|
||||
"linux-x86_64=fixture.AppImage", "linux-x86_64-appimage=fixture.AppImage",
|
||||
"linux-x86_64-deb=fixture.deb",
|
||||
], downloads_root=self.downloads, notes_file=notes, payload=self.payload_path,
|
||||
pub_date=self.payload["pub_date"], minisign=MINISIGN,
|
||||
)
|
||||
self.assertEqual(publisher.prepare(args, self.public_key), self.payload)
|
||||
self.publish()
|
||||
feed = publisher.verified_previous(self.output.read_bytes(), self.public_key, MINISIGN)
|
||||
self.assertEqual(feed["platforms"]["linux-x86_64"], feed["platforms"]["linux-x86_64-appimage"])
|
||||
self.assertEqual(set(feed["platforms"]), {"linux-x86_64", "linux-x86_64-appimage", "linux-x86_64-deb"})
|
||||
self.assertFalse((self.root / "script-executed").exists())
|
||||
|
||||
def test_authenticated_legacy_feed_advances_to_format_aware_release(self):
|
||||
self.publish()
|
||||
old_release = self.artifact.parent
|
||||
old_bytes = self.artifact.read_bytes()
|
||||
new_release = self.downloads / "0.1.4"
|
||||
shutil.copytree(old_release, new_release)
|
||||
self.artifact = new_release / self.artifact.name
|
||||
self.payload["version"] = "0.1.4"
|
||||
self.payload["platforms"]["linux-x86_64"]["url"] = publisher.ORIGIN + "0.1.4/fixture.AppImage"
|
||||
self.make_deb()
|
||||
self.publish()
|
||||
feed = publisher.verified_previous(self.output.read_bytes(), self.public_key, MINISIGN)
|
||||
self.assertEqual(feed["version"], "0.1.4")
|
||||
self.assertEqual(len(feed["platforms"]), 3)
|
||||
self.assertEqual((old_release / self.artifact.name).read_bytes(), old_bytes)
|
||||
|
||||
def test_linux_format_release_cannot_drop_or_repoint_legacy_entry(self):
|
||||
self.make_deb()
|
||||
for key in ("linux-x86_64", "linux-x86_64-appimage"):
|
||||
payload = copy.deepcopy(self.payload)
|
||||
del payload["platforms"][key]
|
||||
with self.subTest(key=key), self.assertRaisesRegex(publisher.InvalidRelease, "identical legacy"):
|
||||
self.publish(payload)
|
||||
payload = copy.deepcopy(self.payload)
|
||||
payload["platforms"]["linux-x86_64-appimage"]["url"] = publisher.ORIGIN + "0.1.3/other.AppImage"
|
||||
with self.assertRaisesRegex(publisher.InvalidRelease, "identical legacy"):
|
||||
self.publish(payload)
|
||||
self.assertFalse(self.output.exists())
|
||||
|
||||
def test_deb_identity_must_match_application_signed_version_and_architecture(self):
|
||||
for changes in ({"package": "another-launcher"}, {"version": "9.0.0"}, {"architecture": "arm64"}):
|
||||
with self.subTest(changes=changes):
|
||||
self.make_deb(**changes)
|
||||
with self.assertRaisesRegex(publisher.InvalidRelease, "deb identity"):
|
||||
self.publish()
|
||||
self.assertFalse(self.output.exists())
|
||||
|
||||
def test_signed_invalid_deb_is_rejected_without_running_package_scripts(self):
|
||||
deb = self.make_deb()
|
||||
deb.write_bytes(b"not a Debian archive")
|
||||
self.payload["platforms"]["linux-x86_64-deb"]["signature"] = self.sign(deb)
|
||||
with self.assertRaisesRegex(publisher.InvalidRelease, "package inspection failed"):
|
||||
self.publish()
|
||||
self.assertFalse((self.root / "script-executed").exists())
|
||||
self.assertFalse(self.output.exists())
|
||||
|
||||
def test_signed_wrong_appimage_format_is_rejected(self):
|
||||
for changed_slice, replacement in ((slice(8, 11), b"AI\x01"), (slice(18, 20), b"\xb7\x00")):
|
||||
malformed = bytearray(appimage_fixture())
|
||||
malformed[changed_slice] = replacement
|
||||
self.artifact.write_bytes(malformed)
|
||||
self.payload["platforms"]["linux-x86_64"]["signature"] = self.sign(self.artifact)
|
||||
with self.subTest(replacement=replacement), self.assertRaisesRegex(publisher.InvalidRelease, "type-2 x86_64"):
|
||||
self.publish()
|
||||
self.assertFalse(self.output.exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Generated
+332
-8
@@ -1770,6 +1770,36 @@ dependencies = [
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"jni-macros",
|
||||
"jni-sys 0.4.1",
|
||||
"log",
|
||||
"simd_cesu8",
|
||||
"thiserror 2.0.20",
|
||||
"walkdir",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-macros"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"simd_cesu8",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.3.1"
|
||||
@@ -1975,6 +2005,12 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -2217,6 +2253,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
@@ -2232,6 +2269,18 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-osa-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-quartz-core"
|
||||
version = "0.3.2"
|
||||
@@ -2295,12 +2344,32 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "osakit"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"objc2-osa-kit",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.20",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pango"
|
||||
version = "0.18.3"
|
||||
@@ -2817,15 +2886,20 @@ dependencies = [
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -2893,6 +2967,18 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
@@ -2903,6 +2989,33 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
|
||||
dependencies = [
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"jni 0.22.4",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier-android"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
@@ -2935,6 +3048,15 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "0.8.22"
|
||||
@@ -2992,6 +3114,29 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.36.1"
|
||||
@@ -3216,13 +3361,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "shacraft-launcher"
|
||||
version = "0.1.0"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"ed25519-dalek",
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
"libc",
|
||||
"md-5",
|
||||
"minisign-verify",
|
||||
"reqwest 0.12.28",
|
||||
"reqwest 0.13.4",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
@@ -3230,8 +3380,11 @@ dependencies = [
|
||||
"tar",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-updater",
|
||||
"tempfile",
|
||||
"url",
|
||||
"zip",
|
||||
"zeroize",
|
||||
"zip 2.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3255,6 +3408,22 @@ version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "simd_cesu8"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
|
||||
dependencies = [
|
||||
"rustc_version",
|
||||
"simdutf8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simdutf8"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.3"
|
||||
@@ -3477,7 +3646,7 @@ dependencies = [
|
||||
"gdkwayland-sys",
|
||||
"gdkx11-sys",
|
||||
"gtk",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"log",
|
||||
"ndk",
|
||||
@@ -3544,7 +3713,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"heck 0.5.0",
|
||||
"http",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"log",
|
||||
"mime",
|
||||
@@ -3640,6 +3809,53 @@ dependencies = [
|
||||
"tauri-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin"
|
||||
version = "2.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"glob",
|
||||
"plist",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri-utils",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-updater"
|
||||
version = "2.11.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"dirs",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"http",
|
||||
"infer",
|
||||
"log",
|
||||
"minisign-verify",
|
||||
"osakit",
|
||||
"percent-encoding",
|
||||
"reqwest 0.13.4",
|
||||
"rustls",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tar",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tempfile",
|
||||
"thiserror 2.0.20",
|
||||
"time",
|
||||
"tokio",
|
||||
"url",
|
||||
"windows-sys 0.60.2",
|
||||
"zip 4.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.11.3"
|
||||
@@ -3650,7 +3866,7 @@ dependencies = [
|
||||
"dpi",
|
||||
"gtk",
|
||||
"http",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"objc2",
|
||||
"objc2-ui-kit",
|
||||
"objc2-web-kit",
|
||||
@@ -3673,7 +3889,7 @@ checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"http",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
@@ -3740,6 +3956,19 @@ dependencies = [
|
||||
"toml 1.1.5+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tendril"
|
||||
version = "0.5.1"
|
||||
@@ -4417,6 +4646,15 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.9"
|
||||
@@ -4674,6 +4912,15 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||
dependencies = [
|
||||
"windows-targets 0.53.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
@@ -4707,13 +4954,30 @@ dependencies = [
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_gnullvm 0.52.6",
|
||||
"windows_i686_msvc 0.52.6",
|
||||
"windows_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 0.52.6",
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.53.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
"windows_aarch64_gnullvm 0.53.1",
|
||||
"windows_aarch64_msvc 0.53.1",
|
||||
"windows_i686_gnu 0.53.1",
|
||||
"windows_i686_gnullvm 0.53.1",
|
||||
"windows_i686_msvc 0.53.1",
|
||||
"windows_x86_64_gnu 0.53.1",
|
||||
"windows_x86_64_gnullvm 0.53.1",
|
||||
"windows_x86_64_msvc 0.53.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-threading"
|
||||
version = "0.1.0"
|
||||
@@ -4744,6 +5008,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -4756,6 +5026,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.42.2"
|
||||
@@ -4768,12 +5044,24 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -4786,6 +5074,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.42.2"
|
||||
@@ -4798,6 +5092,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.42.2"
|
||||
@@ -4810,6 +5110,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -4822,6 +5128,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.5.40"
|
||||
@@ -4886,7 +5198,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"http",
|
||||
"javascriptcore-rs",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"ndk",
|
||||
"objc2",
|
||||
@@ -5043,6 +5355,18 @@ dependencies = [
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"indexmap 2.14.2",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.7"
|
||||
|
||||
+14
-2
@@ -1,8 +1,9 @@
|
||||
[package]
|
||||
name = "shacraft-launcher"
|
||||
version = "0.1.0"
|
||||
version = "0.1.7"
|
||||
description = "ShaCraft Minecraft launcher"
|
||||
authors = ["ShaCraft"]
|
||||
license = "MIT"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
@@ -19,10 +20,21 @@ sha1 = "0.10"
|
||||
sha2 = "0.10"
|
||||
md-5 = "0.10"
|
||||
base64 = "0.22"
|
||||
ed25519-dalek = "2"
|
||||
ed25519-dalek = { version = "2", features = ["pkcs8"] }
|
||||
getrandom = "0.3"
|
||||
zeroize = "1"
|
||||
libc = "0.2"
|
||||
tempfile = "3"
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-updater = { version = "=2.11.0", path = "../vendor/tauri-plugin-updater", default-features = false, features = ["rustls-tls", "zip"] }
|
||||
reqwest-updater = { package = "reqwest", version = "0.13", default-features = false }
|
||||
minisign-verify = "0.2"
|
||||
semver = "1"
|
||||
url = "2"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls", "json"] }
|
||||
flate2 = "1"
|
||||
tar = "0.4"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tauri = { version = "2", features = ["test"] }
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
//! Ephemeral proof of possession for one Aeronautics connection.
|
||||
//!
|
||||
//! Neither this private key nor its ticket crosses IPC, enters launch arguments,
|
||||
//! or is persisted. Only the new Java child's environment receives them. A new
|
||||
//! launch obtains a new key and ticket; account session credentials stay native.
|
||||
|
||||
use crate::session::PlayerIdentity;
|
||||
use base64::{
|
||||
engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
|
||||
Engine,
|
||||
};
|
||||
use ed25519_dalek::{
|
||||
pkcs8::{EncodePrivateKey, KeypairBytes},
|
||||
SigningKey,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::process::Command;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
pub(crate) const TICKET_ENV: &str = "SHACRAFT_ADMISSION_TICKET";
|
||||
pub(crate) const PRIVATE_KEY_ENV: &str = "SHACRAFT_ADMISSION_PRIVATE_KEY";
|
||||
#[cfg(test)]
|
||||
const SERVER_ID: &str = "aoc";
|
||||
|
||||
pub(crate) fn server_for_profile(profile: &str) -> Result<&'static str, &'static str> {
|
||||
match profile {
|
||||
"aeronautics" => Ok("aoc"),
|
||||
"minigames" => Ok("minigames"),
|
||||
_ => Err("Unknown ShaCraft profile"),
|
||||
}
|
||||
}
|
||||
const MAX_LIFETIME_SECONDS: u64 = 600;
|
||||
|
||||
// Deliberately no Debug, Clone or Serialize for secret-bearing values.
|
||||
pub(crate) struct AdmissionKey {
|
||||
server_id: &'static str,
|
||||
public_key: String,
|
||||
private_key: Zeroizing<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct TicketRequest<'a> {
|
||||
server_id: &'static str,
|
||||
public_key: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct TicketResponse {
|
||||
ticket_id: String,
|
||||
mc_username: String,
|
||||
server_id: String,
|
||||
expires_in_seconds: u64,
|
||||
}
|
||||
|
||||
pub(crate) struct Admission {
|
||||
server_id: &'static str,
|
||||
ticket_id: Zeroizing<String>,
|
||||
private_key: Zeroizing<String>,
|
||||
identity: PlayerIdentity,
|
||||
}
|
||||
|
||||
impl AdmissionKey {
|
||||
pub(crate) fn generate(server_id: &'static str) -> Result<Self, &'static str> {
|
||||
if !matches!(server_id, "aoc" | "minigames") { return Err("Unknown ShaCraft server"); }
|
||||
let mut seed = Zeroizing::new([0_u8; 32]);
|
||||
getrandom::fill(seed.as_mut())
|
||||
.map_err(|_| "Не удалось создать защищённый ключ входа. Повторите запуск лаунчера.")?;
|
||||
let signing_key = SigningKey::from_bytes(&seed);
|
||||
let public_key = STANDARD.encode(signing_key.verifying_key().to_bytes());
|
||||
// RFC 8410 PKCS#8 v1 (PrivateKeyInfo) without the optional public key.
|
||||
// This is accepted by Java 21's Ed25519 KeyFactory/PKCS8EncodedKeySpec.
|
||||
let key_bytes = KeypairBytes {
|
||||
secret_key: signing_key.to_bytes(),
|
||||
public_key: None,
|
||||
};
|
||||
let encoded = key_bytes
|
||||
.to_pkcs8_der()
|
||||
.map_err(|_| "Не удалось подготовить защищённый ключ входа.")?;
|
||||
Ok(Self {
|
||||
server_id,
|
||||
public_key,
|
||||
private_key: Zeroizing::new(STANDARD.encode(encoded.as_bytes())),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn request(&self) -> TicketRequest<'_> {
|
||||
TicketRequest {
|
||||
server_id: self.server_id,
|
||||
public_key: &self.public_key,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn bind(self, response: TicketResponse) -> Result<Admission, &'static str> {
|
||||
let ticket_id = Zeroizing::new(response.ticket_id);
|
||||
let valid_ticket = ticket_id.len() == 43
|
||||
&& URL_SAFE_NO_PAD
|
||||
.decode(ticket_id.as_bytes())
|
||||
.is_ok_and(|bytes| {
|
||||
bytes.len() == 32 && URL_SAFE_NO_PAD.encode(bytes) == *ticket_id
|
||||
});
|
||||
let valid_nickname = (3..=16).contains(&response.mc_username.len())
|
||||
&& response
|
||||
.mc_username
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_');
|
||||
if !valid_ticket
|
||||
|| !valid_nickname
|
||||
|| response.server_id != self.server_id
|
||||
|| !(1..=MAX_LIFETIME_SECONDS).contains(&response.expires_in_seconds)
|
||||
{
|
||||
return Err("Сервер вернул некорректное разрешение на вход. Повторите попытку позже.");
|
||||
}
|
||||
Ok(Admission {
|
||||
server_id: self.server_id,
|
||||
ticket_id,
|
||||
private_key: self.private_key,
|
||||
identity: PlayerIdentity::Offline {
|
||||
name: response.mc_username,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Admission {
|
||||
pub(crate) fn server_id(&self) -> &str { self.server_id }
|
||||
pub(crate) fn identity(&self) -> &PlayerIdentity {
|
||||
&self.identity
|
||||
}
|
||||
|
||||
pub(crate) fn configure_child(&self, command: &mut Command) {
|
||||
command.env(TICKET_ENV, self.ticket_id.as_str());
|
||||
command.env(PRIVATE_KEY_ENV, self.private_key.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::{pkcs8::DecodePrivateKey, Signer, Verifier};
|
||||
|
||||
fn response() -> TicketResponse {
|
||||
TicketResponse {
|
||||
ticket_id: URL_SAFE_NO_PAD.encode([37_u8; 32]),
|
||||
mc_username: "Canonical_Name".into(),
|
||||
server_id: SERVER_ID.into(),
|
||||
expires_in_seconds: 600,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_tickets_are_server_bound_with_shared_canonical_identity() {
|
||||
assert_eq!(server_for_profile("aeronautics").unwrap(), "aoc");
|
||||
assert_eq!(server_for_profile("minigames").unwrap(), "minigames");
|
||||
assert!(server_for_profile("../../other").is_err());
|
||||
assert!(AdmissionKey::generate("other").is_err());
|
||||
assert!(AdmissionKey::generate("minigames").unwrap().bind(response()).is_err());
|
||||
let key=AdmissionKey::generate("minigames").unwrap();
|
||||
assert_eq!(serde_json::to_value(key.request()).unwrap()["server_id"], "minigames");
|
||||
let mut payload=response(); payload.server_id="minigames".into();
|
||||
let admitted=key.bind(payload).unwrap();
|
||||
assert_eq!(admitted.server_id(), "minigames");
|
||||
assert_eq!(admitted.identity().name(), "Canonical_Name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generates_distinct_keys_and_only_sends_the_public_key() {
|
||||
let key = AdmissionKey::generate("aoc").unwrap();
|
||||
let other = AdmissionKey::generate("aoc").unwrap();
|
||||
assert_ne!(key.public_key, other.public_key);
|
||||
let payload = serde_json::to_value(key.request()).unwrap();
|
||||
assert_eq!(payload.as_object().unwrap().len(), 2);
|
||||
assert_eq!(payload["server_id"], SERVER_ID);
|
||||
assert_eq!(payload["public_key"], key.public_key);
|
||||
assert!(!payload.to_string().contains(key.private_key.as_str()));
|
||||
let der = Zeroizing::new(STANDARD.decode(key.private_key.as_bytes()).unwrap());
|
||||
let restored = SigningKey::from_pkcs8_der(&der).unwrap();
|
||||
assert_eq!(
|
||||
STANDARD.encode(restored.verifying_key().to_bytes()),
|
||||
key.public_key
|
||||
);
|
||||
let message = b"shacraft-admission-v1:challenge-fixture";
|
||||
restored
|
||||
.verifying_key()
|
||||
.verify(message, &restored.sign(message))
|
||||
.unwrap();
|
||||
// Java's standard Ed25519 encoding is the 48-byte private-key-only form.
|
||||
assert_eq!(der.len(), 48);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_untrusted_identity_ticket_server_and_expiry() {
|
||||
let mutations: Vec<Box<dyn Fn(&mut TicketResponse)>> = vec![
|
||||
Box::new(|r| r.ticket_id = "../unsafe".into()),
|
||||
Box::new(|r| r.ticket_id = "A".repeat(42) + "!"),
|
||||
Box::new(|r| r.ticket_id = "A".repeat(42) + "B"),
|
||||
Box::new(|r| r.mc_username = "../../outside".into()),
|
||||
Box::new(|r| r.mc_username = "ab".into()),
|
||||
Box::new(|r| r.mc_username = "a".repeat(17)),
|
||||
Box::new(|r| r.server_id = "other".into()),
|
||||
Box::new(|r| r.expires_in_seconds = 0),
|
||||
Box::new(|r| r.expires_in_seconds = 601),
|
||||
];
|
||||
for mutate in mutations {
|
||||
let mut payload = response();
|
||||
mutate(&mut payload);
|
||||
assert!(AdmissionKey::generate("aoc").unwrap().bind(payload).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secrets_only_enter_the_child_environment_and_identity_comes_from_ticket() {
|
||||
let original_ticket = std::env::var_os(TICKET_ENV);
|
||||
let original_key = std::env::var_os(PRIVATE_KEY_ENV);
|
||||
let admission = AdmissionKey::generate("aoc").unwrap().bind(response()).unwrap();
|
||||
let mut command = Command::new("java");
|
||||
command
|
||||
.arg("-Xmx6144M")
|
||||
.arg("net.minecraft.client.main.Main");
|
||||
admission.configure_child(&mut command);
|
||||
assert_eq!(admission.identity().name(), "Canonical_Name");
|
||||
let env: std::collections::HashMap<_, _> = command.get_envs().collect();
|
||||
assert_eq!(
|
||||
env.get(std::ffi::OsStr::new(TICKET_ENV)).unwrap().unwrap(),
|
||||
admission.ticket_id.as_str()
|
||||
);
|
||||
assert_eq!(
|
||||
env.get(std::ffi::OsStr::new(PRIVATE_KEY_ENV))
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
admission.private_key.as_str()
|
||||
);
|
||||
assert_eq!(env.len(), 2);
|
||||
for argument in command.get_args() {
|
||||
assert!(!argument
|
||||
.to_string_lossy()
|
||||
.contains(admission.ticket_id.as_str()));
|
||||
assert!(!argument
|
||||
.to_string_lossy()
|
||||
.contains(admission.private_key.as_str()));
|
||||
}
|
||||
assert_eq!(std::env::var_os(TICKET_ENV), original_ticket);
|
||||
assert_eq!(std::env::var_os(PRIVATE_KEY_ENV), original_key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
use super::data_dir;
|
||||
use crate::{msa, operations::LauncherOperations};
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DeviceCodePayload {
|
||||
verification_uri: String,
|
||||
user_code: String,
|
||||
expires_in_seconds: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct LoginResultPayload {
|
||||
ok: bool,
|
||||
profile: Option<msa::MinecraftProfile>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
/// Starts a Microsoft device-code login in the background. Emits
|
||||
/// `msa-login-code` as soon as the user code is available (show it to the
|
||||
/// player immediately — they have a limited time to enter it), then
|
||||
/// `msa-login-result` once sign-in finishes, fails, or times out. Returns
|
||||
/// immediately; it does not wait for the user to finish signing in.
|
||||
#[tauri::command]
|
||||
pub(crate) fn start_microsoft_login(
|
||||
app: AppHandle,
|
||||
state: State<'_, LauncherOperations>,
|
||||
) -> Result<(), String> {
|
||||
let directory = data_dir(&app)?;
|
||||
let permit = state.account.acquire("Account operation")?;
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let _permit = permit;
|
||||
let login = || -> Result<msa::MinecraftProfile, String> {
|
||||
let client = msa::http_client().map_err(|error| error.to_string())?;
|
||||
let start = msa::start_device_code(&client).map_err(|error| error.to_string())?;
|
||||
let _ = app.emit(
|
||||
"msa-login-code",
|
||||
DeviceCodePayload {
|
||||
verification_uri: start.verification_uri.clone(),
|
||||
user_code: start.user_code.clone(),
|
||||
expires_in_seconds: start.expires_in_seconds,
|
||||
},
|
||||
);
|
||||
let result =
|
||||
msa::login_with_device_code(&client, &start).map_err(|error| error.to_string())?;
|
||||
msa::save_refresh_token(&directory, &result.refresh_token)
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(result.profile)
|
||||
};
|
||||
let payload = match login() {
|
||||
Ok(profile) => LoginResultPayload {
|
||||
ok: true,
|
||||
profile: Some(profile),
|
||||
error: None,
|
||||
},
|
||||
Err(error) => LoginResultPayload {
|
||||
ok: false,
|
||||
profile: None,
|
||||
error: Some(error),
|
||||
},
|
||||
};
|
||||
let _ = app.emit("msa-login-result", payload);
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tries to restore a session from a previously saved refresh token
|
||||
/// (silent, no browser/user code). Returns `None` if there is none saved
|
||||
/// or it no longer works — the UI should fall back to offering login.
|
||||
#[tauri::command]
|
||||
pub(crate) async fn get_account(
|
||||
app: AppHandle,
|
||||
state: State<'_, LauncherOperations>,
|
||||
) -> Result<Option<msa::MinecraftProfile>, String> {
|
||||
let data_dir = data_dir(&app)?;
|
||||
let permit = state.account.acquire("Account operation")?;
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let _permit = permit;
|
||||
let Some(refresh_token) = msa::load_refresh_token(&data_dir) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let client = msa::http_client().map_err(|error| error.to_string())?;
|
||||
match msa::login_with_refresh_token(&client, &refresh_token) {
|
||||
Ok(result) => {
|
||||
msa::save_refresh_token(&data_dir, &result.refresh_token)
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(Some(result.profile))
|
||||
}
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("Account restore task failed: {error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn logout(
|
||||
app: AppHandle,
|
||||
state: State<'_, LauncherOperations>,
|
||||
) -> Result<(), String> {
|
||||
let data_dir = data_dir(&app)?;
|
||||
let permit = state.account.acquire("Account operation")?;
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let _permit = permit;
|
||||
msa::clear_account(&data_dir)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("Logout task failed: {error}"))?
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
use super::data_dir;
|
||||
use crate::{
|
||||
fabric, java, launch, manifest, mojang, neoforge, operations::LauncherOperations, remote, runtime,
|
||||
settings, shacraft_account,
|
||||
};
|
||||
use reqwest::blocking::Client;
|
||||
use serde::Serialize;
|
||||
use std::{path::Path, sync::Arc, time::SystemTime};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct InstallProgress {
|
||||
stage: &'static str,
|
||||
current_bytes: u64,
|
||||
total_bytes: u64,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
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())?;
|
||||
|
||||
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(
|
||||
&installer_client,
|
||||
java_executable,
|
||||
game_dir,
|
||||
cache_dir,
|
||||
&manifest.minecraft.loader.version,
|
||||
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())
|
||||
} else {
|
||||
mojang::merge_versions(&vanilla, None).map_err(|error| error.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.
|
||||
#[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");
|
||||
let permit = state.installation.acquire("Installation")?;
|
||||
|
||||
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 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(())
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("Install task failed: {error}"))?
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct GameExited {
|
||||
profile_id: String,
|
||||
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.
|
||||
#[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();
|
||||
|
||||
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())?;
|
||||
|
||||
// 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,
|
||||
};
|
||||
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}"))?
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
use super::data_dir;
|
||||
use crate::{java, manifest, msa};
|
||||
use serde::Serialize;
|
||||
use tauri::AppHandle;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct NativeHost {
|
||||
platform: &'static str,
|
||||
data_dir: String,
|
||||
launcher_version: &'static str,
|
||||
}
|
||||
|
||||
/// Returns non-sensitive environment information needed by the interface.
|
||||
#[tauri::command]
|
||||
pub(crate) fn native_host(app: AppHandle) -> Result<NativeHost, String> {
|
||||
let data_dir = data_dir(&app)?;
|
||||
|
||||
Ok(NativeHost {
|
||||
platform: std::env::consts::OS,
|
||||
data_dir: data_dir.display().to_string(),
|
||||
launcher_version: env!("CARGO_PKG_VERSION"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Detects an existing Java installation. This is read-only and never downloads Java.
|
||||
#[tauri::command]
|
||||
pub(crate) fn detect_java() -> Option<java::JavaInstallation> {
|
||||
java::detect()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn microsoft_login_available() -> bool {
|
||||
msa::is_configured()
|
||||
}
|
||||
|
||||
/// Validates an untrusted profile manifest before any file is downloaded.
|
||||
#[tauri::command]
|
||||
pub(crate) fn validate_manifest(manifest_json: String) -> Result<(), String> {
|
||||
manifest::validate_json(&manifest_json)
|
||||
.map(|_| ())
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! Thin Tauri adapters grouped by the domain they expose.
|
||||
pub(crate) mod account;
|
||||
pub(crate) mod game;
|
||||
pub(crate) mod host;
|
||||
pub(crate) mod preferences;
|
||||
pub(crate) mod profiles;
|
||||
pub(crate) mod shacraft;
|
||||
pub(crate) mod updater;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
fn data_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
app.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| format!("Cannot resolve launcher data directory: {error}"))
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
use super::data_dir;
|
||||
use crate::settings;
|
||||
use tauri::AppHandle;
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn load_settings(app: AppHandle) -> Result<settings::LauncherSettings, String> {
|
||||
let data_dir = data_dir(&app)?;
|
||||
tauri::async_runtime::spawn_blocking(move || settings::load(&data_dir))
|
||||
.await
|
||||
.map_err(|error| format!("Settings task failed: {error}"))?
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn save_settings(
|
||||
app: AppHandle,
|
||||
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())
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
use super::data_dir;
|
||||
use crate::{operations::LauncherOperations, profile, remote};
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
#[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())
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("Server-status task failed: {error}"))?
|
||||
}
|
||||
|
||||
/// Loads and validates the published ShaCraft manifest before inspecting a profile.
|
||||
#[tauri::command]
|
||||
pub(crate) async fn inspect_remote_profile(
|
||||
app: AppHandle,
|
||||
profile_id: String,
|
||||
) -> Result<profile::ProfileInspection, String> {
|
||||
let data_dir = data_dir(&app)?;
|
||||
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())
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("Profile inspection task failed: {error}"))?
|
||||
}
|
||||
|
||||
/// 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 permit = state.installation.acquire("Installation")?;
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
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())
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("Profile synchronization task failed: {error}"))?
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! ShaCraft sessions and verified account links are the launch identity source.
|
||||
use super::data_dir;
|
||||
use crate::{operations::LauncherOperations, shacraft_account};
|
||||
use std::path::Path;
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
async fn account_task<T: Send + 'static>(
|
||||
app: &AppHandle,
|
||||
operations: &LauncherOperations,
|
||||
work: impl FnOnce(&Path) -> Result<T, shacraft_account::AccountError> + Send + 'static,
|
||||
) -> Result<T, String> {
|
||||
let directory = data_dir(app)?;
|
||||
let permit = operations
|
||||
.shacraft_account
|
||||
.acquire("ShaCraft account operation")?;
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let _permit = permit;
|
||||
work(&directory).map_err(|error| error.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("ShaCraft account task failed: {error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn shacraft_authenticate(
|
||||
app: AppHandle,
|
||||
state: State<'_, LauncherOperations>,
|
||||
username: String,
|
||||
password: String,
|
||||
register: bool,
|
||||
) -> Result<shacraft_account::LoginResult, String> {
|
||||
account_task(&app, &state, move |directory| {
|
||||
shacraft_account::authenticate(directory, &username, &password, register)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn get_shacraft_account(
|
||||
app: AppHandle,
|
||||
state: State<'_, LauncherOperations>,
|
||||
) -> Result<Option<shacraft_account::Account>, String> {
|
||||
account_task(
|
||||
&app,
|
||||
&state,
|
||||
|directory| match shacraft_account::get_account(directory) {
|
||||
Ok(account) => Ok(Some(account)),
|
||||
Err(shacraft_account::AccountError::InvalidSession) => Ok(None),
|
||||
Err(error) => Err(error),
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn shacraft_logout(
|
||||
app: AppHandle,
|
||||
state: State<'_, LauncherOperations>,
|
||||
) -> Result<(), String> {
|
||||
account_task(&app, &state, shacraft_account::logout).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn shacraft_start_link(
|
||||
app: AppHandle,
|
||||
state: State<'_, LauncherOperations>,
|
||||
nickname: String,
|
||||
) -> Result<shacraft_account::LinkStart, String> {
|
||||
account_task(&app, &state, move |directory| {
|
||||
shacraft_account::start_link(directory, "aoc", &nickname)
|
||||
})
|
||||
.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,
|
||||
state: State<'_, LauncherOperations>,
|
||||
challenge_id: i64,
|
||||
) -> Result<shacraft_account::LinkStatus, String> {
|
||||
account_task(&app, &state, move |directory| {
|
||||
shacraft_account::link_status(directory, challenge_id)
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
use crate::{
|
||||
operations::LauncherOperations,
|
||||
updater::{self, LauncherUpdater, Stage, UpdateProgress, UpdateStatus},
|
||||
};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn get_launcher_update_status(
|
||||
app: AppHandle,
|
||||
updater: State<'_, LauncherUpdater>,
|
||||
) -> Result<UpdateStatus, String> {
|
||||
updater.status(&app)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn check_launcher_update(
|
||||
app: AppHandle,
|
||||
updater: State<'_, LauncherUpdater>,
|
||||
) -> Result<UpdateStatus, String> {
|
||||
let status = updater.status(&app)?;
|
||||
if !status.supported || status.stage == Stage::Ready {
|
||||
return Ok(status);
|
||||
}
|
||||
let permit = updater
|
||||
.operation
|
||||
.acquire("Проверка или установка обновления")
|
||||
.map_err(|_| "Проверка или установка обновления уже выполняется.".to_string())?;
|
||||
let updater = updater.inner().clone();
|
||||
updater.set_stage(Stage::Checking)?;
|
||||
// Detached task owns the permit: cancelling an IPC caller cannot release
|
||||
// the operation while its native HTTP request is still in flight.
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let _permit = permit;
|
||||
let result = async {
|
||||
let key = updater::public_key(&app)?;
|
||||
updater::check_candidate(
|
||||
updater::trusted_builder(&app)?,
|
||||
&key,
|
||||
&app.package_info().version.to_string(),
|
||||
updater::installation_kind(&app),
|
||||
)
|
||||
.await
|
||||
}
|
||||
.await;
|
||||
{
|
||||
let mut state = updater
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| "Состояние обновления недоступно.".to_string())?;
|
||||
match result {
|
||||
Ok(candidate) => {
|
||||
state.stage = if candidate.is_some() {
|
||||
Stage::Available
|
||||
} else {
|
||||
Stage::Idle
|
||||
};
|
||||
state.candidate = candidate;
|
||||
}
|
||||
Err(error) => {
|
||||
state.stage = if state.candidate.is_some() {
|
||||
Stage::Available
|
||||
} else {
|
||||
Stage::Idle
|
||||
};
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
updater.status(&app)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "Проверка обновления завершилась с ошибкой.".to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn install_launcher_update(
|
||||
app: AppHandle,
|
||||
updater: State<'_, LauncherUpdater>,
|
||||
operations: State<'_, LauncherOperations>,
|
||||
) -> Result<(), String> {
|
||||
if let Some(reason) = updater::unsupported_reason(&app) {
|
||||
return Err(reason);
|
||||
}
|
||||
let permit = updater
|
||||
.operation
|
||||
.acquire("Проверка или установка обновления")
|
||||
.map_err(|_| "Проверка или установка обновления уже выполняется.".to_string())?;
|
||||
let candidate = {
|
||||
let state = updater
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| "Состояние обновления недоступно.".to_string())?;
|
||||
if state.stage == Stage::Ready {
|
||||
return Err("Обновление уже установлено. Перезапустите лаунчер.".into());
|
||||
}
|
||||
state
|
||||
.candidate
|
||||
.clone()
|
||||
.ok_or("Сначала проверьте наличие обновлений.")?
|
||||
};
|
||||
let destination = updater::installation_path(&app)?;
|
||||
let mutation_permits = operations.acquire_update()
|
||||
.map_err(|_| "Закройте Minecraft и дождитесь завершения установки или входа в аккаунт перед обновлением.".to_string())?;
|
||||
let updater = updater.inner().clone();
|
||||
updater.set_stage(Stage::Downloading)?;
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let _permit = permit;
|
||||
let result = async {
|
||||
let key = updater::public_key(&app)?;
|
||||
let _ = app.emit(
|
||||
"launcher-update-progress",
|
||||
UpdateProgress {
|
||||
stage: Stage::Downloading,
|
||||
downloaded_bytes: 0,
|
||||
total_bytes: None,
|
||||
},
|
||||
);
|
||||
let bytes =
|
||||
updater::download_verified(&candidate, &key, |downloaded_bytes, total_bytes| {
|
||||
let _ = app.emit(
|
||||
"launcher-update-progress",
|
||||
UpdateProgress {
|
||||
stage: Stage::Downloading,
|
||||
downloaded_bytes,
|
||||
total_bytes,
|
||||
},
|
||||
);
|
||||
})
|
||||
.await?;
|
||||
let size = bytes.len() as u64;
|
||||
updater.set_stage(Stage::Installing)?;
|
||||
let _ = app.emit(
|
||||
"launcher-update-progress",
|
||||
UpdateProgress {
|
||||
stage: Stage::Installing,
|
||||
downloaded_bytes: size,
|
||||
total_bytes: Some(size),
|
||||
},
|
||||
);
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
updater::install_verified(&candidate, &bytes, &key, destination.as_deref())
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "Установка обновления завершилась с ошибкой.".to_string())??;
|
||||
Ok::<_, String>(size)
|
||||
}
|
||||
.await;
|
||||
match result {
|
||||
Ok(size) => {
|
||||
let mut state = updater
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| "Состояние обновления недоступно.".to_string())?;
|
||||
state.stage = Stage::Ready;
|
||||
state.restart_permits = Some(mutation_permits);
|
||||
let _ = app.emit(
|
||||
"launcher-update-progress",
|
||||
UpdateProgress {
|
||||
stage: Stage::Ready,
|
||||
downloaded_bytes: size,
|
||||
total_bytes: Some(size),
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => {
|
||||
updater.set_stage(Stage::Available)?;
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "Установка обновления завершилась с ошибкой.".to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn restart_launcher_after_update(
|
||||
app: AppHandle,
|
||||
updater: State<'_, LauncherUpdater>,
|
||||
) -> Result<(), String> {
|
||||
let _permit = updater
|
||||
.operation
|
||||
.acquire("Установка обновления")
|
||||
.map_err(|_| "Установка обновления ещё выполняется.".to_string())?;
|
||||
{
|
||||
let state = updater
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| "Состояние обновления недоступно.".to_string())?;
|
||||
if state.stage != Stage::Ready || state.restart_permits.is_none() {
|
||||
return Err("Сначала установите обновление лаунчера.".into());
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
if updater::installation_kind(&app) == updater::InstallationKind::Deb
|
||||
|| crate::deb_updater::is_deleted_installed_binary()
|
||||
{
|
||||
crate::deb_updater::restart()?;
|
||||
app.exit(0);
|
||||
return Ok(());
|
||||
}
|
||||
app.restart()
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
//! The only elevated updater operation. pkexec starts this installed, root-owned
|
||||
//! binary in an early non-GUI mode. Input is untrusted until BOTH signatures
|
||||
//! are verified again here. No user-supplied path, command or password is used.
|
||||
use crate::updater::{self, InstallationKind, MAX_ARTIFACT_BYTES, MAX_METADATA_BYTES};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs,
|
||||
io::{self, Read, Write},
|
||||
os::unix::{
|
||||
fs::{MetadataExt, PermissionsExt},
|
||||
process::CommandExt,
|
||||
},
|
||||
path::Path,
|
||||
process::{Command, ExitStatus, Stdio},
|
||||
sync::mpsc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tauri_plugin_updater::Update;
|
||||
use url::Url;
|
||||
|
||||
const BINARY: &str = "/usr/bin/shacraft-launcher";
|
||||
const HELPER_FLAG: &str = "--shacraft-install-deb";
|
||||
const PACKAGE: &str = "sha-craft-launcher";
|
||||
const PKEXEC: &str = "/usr/bin/pkexec";
|
||||
const DPKG: &str = "/usr/bin/dpkg";
|
||||
const QUERY: &str = "/usr/bin/dpkg-query";
|
||||
const DEB: &str = "/usr/bin/dpkg-deb";
|
||||
const OUTPUT_LIMIT: usize = 16 * 1024;
|
||||
const INPUT_MAGIC: &[u8; 8] = b"SCDUPD01";
|
||||
const REJECTED: i32 = 20;
|
||||
const LOCKED: i32 = 21;
|
||||
const INSTALL_FAILED: i32 = 22;
|
||||
const INVALID_HOST: i32 = 23;
|
||||
|
||||
fn architecture() -> &'static str {
|
||||
match std::env::consts::ARCH {
|
||||
"x86_64" => "amd64",
|
||||
"aarch64" => "arm64",
|
||||
_ => "unsupported",
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate the full path without following symlinks. The executable and every
|
||||
/// parent must be root-owned and not writable by group/other users.
|
||||
fn trusted_root_path(path: &Path, executable: bool) -> bool {
|
||||
if !path.is_absolute()
|
||||
|| path
|
||||
.components()
|
||||
.any(|c| matches!(c, std::path::Component::ParentDir))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mut leaf = true;
|
||||
for item in path.ancestors() {
|
||||
let Ok(meta) = fs::symlink_metadata(item) else {
|
||||
return false;
|
||||
};
|
||||
if meta.file_type().is_symlink() || meta.uid() != 0 || meta.mode() & 0o022 != 0 {
|
||||
return false;
|
||||
}
|
||||
if leaf && executable {
|
||||
if !meta.is_file() || meta.mode() & 0o111 == 0 {
|
||||
return false;
|
||||
}
|
||||
} else if !meta.is_dir() {
|
||||
return false;
|
||||
}
|
||||
leaf = false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn safe_sticky_temporary_parent(path: &Path) -> bool {
|
||||
fs::symlink_metadata(path).is_ok_and(|meta| {
|
||||
meta.is_dir()
|
||||
&& !meta.file_type().is_symlink()
|
||||
&& meta.uid() == 0
|
||||
&& (meta.mode() & 0o022 == 0 || meta.mode() & 0o1000 != 0)
|
||||
})
|
||||
}
|
||||
|
||||
fn fixed_command(path: &str) -> Command {
|
||||
let mut command = Command::new(path);
|
||||
command
|
||||
.env_clear()
|
||||
.env("PATH", "/usr/sbin:/usr/bin:/sbin:/bin")
|
||||
.env("LC_ALL", "C");
|
||||
command
|
||||
}
|
||||
|
||||
fn drain_capped(mut source: impl Read) -> io::Result<Vec<u8>> {
|
||||
let mut result = Vec::new();
|
||||
let mut buffer = [0_u8; 4096];
|
||||
loop {
|
||||
let read = source.read(&mut buffer)?;
|
||||
if read == 0 {
|
||||
return Ok(result);
|
||||
}
|
||||
let remaining = OUTPUT_LIMIT.saturating_sub(result.len());
|
||||
result.extend_from_slice(&buffer[..read.min(remaining)]);
|
||||
}
|
||||
}
|
||||
|
||||
struct Captured {
|
||||
status: ExitStatus,
|
||||
stdout: Vec<u8>,
|
||||
stderr: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Drain both pipes concurrently; output can never make the root helper buffer
|
||||
/// unbounded data or deadlock dpkg while it is changing the package database.
|
||||
fn capture(command: &mut Command) -> io::Result<Captured> {
|
||||
capture_with_deadline(command, Duration::from_secs(15))
|
||||
}
|
||||
|
||||
fn capture_with_deadline(command: &mut Command, deadline: Duration) -> io::Result<Captured> {
|
||||
// Read-only inspection has a deadline. Never kill dpkg during mutation:
|
||||
// interrupting it could leave a partially configured installed package.
|
||||
let inspection = command.get_program() != DPKG;
|
||||
if inspection {
|
||||
command.process_group(0);
|
||||
}
|
||||
let mut child = command
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?;
|
||||
let stdout = child.stdout.take().expect("piped stdout");
|
||||
let stderr = child.stderr.take().expect("piped stderr");
|
||||
let (out_send, out_receive) = mpsc::channel();
|
||||
let (err_send, err_receive) = mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let _ = out_send.send(drain_capped(stdout));
|
||||
});
|
||||
std::thread::spawn(move || {
|
||||
let _ = err_send.send(drain_capped(stderr));
|
||||
});
|
||||
let start = Instant::now();
|
||||
let mut stdout = None;
|
||||
let mut stderr = None;
|
||||
loop {
|
||||
if stdout.is_none() {
|
||||
stdout = out_receive.try_recv().ok();
|
||||
}
|
||||
if stderr.is_none() {
|
||||
stderr = err_receive.try_recv().ok();
|
||||
}
|
||||
// Do not reap the parent before its pipes close. Its unreaped PID
|
||||
// reserves the process-group id until a possible timeout kill below.
|
||||
if stdout.is_some() && stderr.is_some() {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
return Ok(Captured {
|
||||
status,
|
||||
stdout: stdout.unwrap()?,
|
||||
stderr: stderr.unwrap()?,
|
||||
});
|
||||
}
|
||||
}
|
||||
if inspection && start.elapsed() > deadline {
|
||||
// Also terminate dpkg-deb's decompressor descendants so they cannot
|
||||
// retain the pipes after the inspection parent has been killed.
|
||||
unsafe {
|
||||
libc::kill(-(child.id() as i32), libc::SIGKILL);
|
||||
}
|
||||
let _ = child.wait();
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"package inspection timed out",
|
||||
));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
fn installed_version() -> Result<String, ()> {
|
||||
let result = capture(fixed_command(QUERY).args([
|
||||
"--show",
|
||||
"--showformat=${db:Status-Status}\n${Version}\n${Architecture}\n",
|
||||
PACKAGE,
|
||||
]))
|
||||
.map_err(|_| ())?;
|
||||
if !result.status.success() {
|
||||
return Err(());
|
||||
}
|
||||
let fields = std::str::from_utf8(&result.stdout)
|
||||
.map_err(|_| ())?
|
||||
.lines()
|
||||
.collect::<Vec<_>>();
|
||||
if fields.len() != 3 || fields[0] != "installed" || fields[2] != architecture() {
|
||||
return Err(());
|
||||
}
|
||||
let version = semver::Version::parse(fields[1]).map_err(|_| ())?;
|
||||
if version.to_string() != fields[1] || !version.pre.is_empty() || !version.build.is_empty() {
|
||||
return Err(());
|
||||
}
|
||||
// dpkg's database must also assign the precise executable to our package.
|
||||
let owner = capture(fixed_command(QUERY).args(["--search", BINARY])).map_err(|_| ())?;
|
||||
if !owner.status.success() || owner.stdout != format!("{PACKAGE}: {BINARY}\n").as_bytes() {
|
||||
return Err(());
|
||||
}
|
||||
Ok(fields[1].to_owned())
|
||||
}
|
||||
|
||||
pub(crate) fn installed_binary_supported() -> bool {
|
||||
std::env::current_exe().is_ok_and(|path| path == Path::new(BINARY))
|
||||
&& trusted_root_path(Path::new(BINARY), true)
|
||||
&& [QUERY, DEB, DPKG]
|
||||
.iter()
|
||||
.all(|path| trusted_root_path(Path::new(path), true))
|
||||
&& installed_version().is_ok()
|
||||
}
|
||||
|
||||
pub(crate) fn unsupported_reason() -> Option<String> {
|
||||
if trusted_root_path(Path::new(PKEXEC), true) {
|
||||
None
|
||||
} else {
|
||||
Some("Для обновления deb нужен системный компонент pkexec (PolicyKit). Установите его или скачайте новый deb с shacraft.ru/help#launcher.".into())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_deleted_installed_binary() -> bool {
|
||||
std::env::current_exe()
|
||||
.is_ok_and(|path| path == Path::new("/usr/bin/shacraft-launcher (deleted)"))
|
||||
}
|
||||
|
||||
pub(crate) fn restart() -> Result<(), String> {
|
||||
if !trusted_root_path(Path::new(BINARY), true) {
|
||||
return Err("Установленный лаунчер недоступен. Запустите его из меню приложений.".into());
|
||||
}
|
||||
Command::new(BINARY).spawn().map_err(|_| {
|
||||
"Не удалось перезапустить лаунчер. Запустите его из меню приложений.".to_string()
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_input(mut output: impl Write, metadata: &[u8], bytes: &[u8]) -> io::Result<()> {
|
||||
if metadata.len() > MAX_METADATA_BYTES || bytes.len() > MAX_ARTIFACT_BYTES {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"update exceeds input limit",
|
||||
));
|
||||
}
|
||||
output.write_all(INPUT_MAGIC)?;
|
||||
output.write_all(&(metadata.len() as u64).to_be_bytes())?;
|
||||
output.write_all(metadata)?;
|
||||
output.write_all(&(bytes.len() as u64).to_be_bytes())?;
|
||||
output.write_all(bytes)
|
||||
}
|
||||
|
||||
fn read_part(input: &mut impl Read, maximum: usize) -> io::Result<Vec<u8>> {
|
||||
let mut length = [0_u8; 8];
|
||||
input.read_exact(&mut length)?;
|
||||
let length = u64::from_be_bytes(length);
|
||||
if length == 0 || length > maximum as u64 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"invalid update input length",
|
||||
));
|
||||
}
|
||||
let mut bytes = vec![0; length as usize];
|
||||
input.read_exact(&mut bytes)?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn read_input(mut input: impl Read) -> io::Result<(Value, Vec<u8>)> {
|
||||
let mut magic = [0_u8; 8];
|
||||
input.read_exact(&mut magic)?;
|
||||
if &magic != INPUT_MAGIC {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"invalid protocol",
|
||||
));
|
||||
}
|
||||
let metadata = read_part(&mut input, MAX_METADATA_BYTES)?;
|
||||
let bytes = read_part(&mut input, MAX_ARTIFACT_BYTES)?;
|
||||
let mut trailing = [0_u8];
|
||||
if input.read(&mut trailing)? != 0 {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "trailing input"));
|
||||
}
|
||||
let raw = serde_json::from_slice(&metadata)
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid metadata"))?;
|
||||
Ok((raw, bytes))
|
||||
}
|
||||
|
||||
fn exit_message(code: Option<i32>) -> String {
|
||||
match code {
|
||||
Some(0) => "",
|
||||
Some(126) => "Установка отменена в системном окне. Текущая версия лаунчера сохранена.",
|
||||
Some(127) => "Система не разрешила установку. Подтвердите права администратора в системном окне; при его отсутствии проверьте PolicyKit.",
|
||||
Some(REJECTED) => "Системная проверка подписи или версии deb не пройдена. Установка отменена.",
|
||||
Some(LOCKED) => "Пакетный менеджер занят другой установкой. Дождитесь её завершения и нажмите «Обновить» ещё раз.",
|
||||
Some(INVALID_HOST) => "Системная установка ShaCraft не подтверждена. Установите новый deb вручную с shacraft.ru/help#launcher.",
|
||||
_ => "Пакетный менеджер не завершил установку. Проверьте состояние пакетов в системе и повторите попытку; при необходимости установите deb вручную.",
|
||||
}.to_owned()
|
||||
}
|
||||
|
||||
pub(crate) fn install(update: &Update, bytes: &[u8]) -> Result<(), String> {
|
||||
if !installed_binary_supported() {
|
||||
return Err(exit_message(Some(INVALID_HOST)));
|
||||
}
|
||||
if let Some(reason) = unsupported_reason() {
|
||||
return Err(reason);
|
||||
}
|
||||
let metadata =
|
||||
serde_json::to_vec(&update.raw_json).map_err(|_| exit_message(Some(REJECTED)))?;
|
||||
let mut child = Command::new(PKEXEC)
|
||||
.args(["--disable-internal-agent", BINARY, HELPER_FLAG])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|_| exit_message(Some(127)))?;
|
||||
// Always wait even on EPIPE: declining the system dialog closes stdin, and
|
||||
// its exit status is the useful cancellation result, not "broken pipe".
|
||||
let write_result = write_input(
|
||||
child.stdin.take().expect("piped helper input"),
|
||||
&metadata,
|
||||
bytes,
|
||||
);
|
||||
let status = child.wait().map_err(|_| exit_message(None))?;
|
||||
if status.success() && write_result.is_ok() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(exit_message(status.code().filter(|code| *code != 0)))
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_deb_release(raw: &Value, bytes: &[u8], key: &str, installed: &str) -> Result<String, ()> {
|
||||
let metadata = updater::verified_metadata(raw, key).map_err(|_| ())?;
|
||||
if !updater::newer_version(&metadata, installed).map_err(|_| ())? {
|
||||
return Err(());
|
||||
}
|
||||
let version = metadata["version"].as_str().ok_or(())?;
|
||||
let target = format!("linux-{}-deb", std::env::consts::ARCH);
|
||||
let artifact = metadata["platforms"].get(&target).ok_or(())?;
|
||||
let url = Url::parse(artifact["url"].as_str().ok_or(())?).map_err(|_| ())?;
|
||||
updater::validate_download_url(&url, version, InstallationKind::Deb).map_err(|_| ())?;
|
||||
updater::verify_signature(bytes, artifact["signature"].as_str().ok_or(())?, key)
|
||||
.map_err(|_| ())?;
|
||||
Ok(version.to_owned())
|
||||
}
|
||||
|
||||
fn valid_package_fields(output: &[u8], version: &str) -> bool {
|
||||
std::str::from_utf8(output)
|
||||
.is_ok_and(|text| text == format!("{PACKAGE}\n{version}\n{}\n", architecture()))
|
||||
}
|
||||
|
||||
fn lock_error(stderr: &[u8]) -> bool {
|
||||
let text = String::from_utf8_lossy(stderr).to_ascii_lowercase();
|
||||
(text.contains("lock")
|
||||
&& (text.contains("locked")
|
||||
|| text.contains("another process")
|
||||
|| text.contains("resource temporarily unavailable")
|
||||
|| text.contains("unable to acquire")))
|
||||
|| text.contains("dpkg frontend lock was locked")
|
||||
}
|
||||
|
||||
fn embedded_key() -> Result<String, ()> {
|
||||
let config: Value = serde_json::from_str(include_str!("../tauri.conf.json")).map_err(|_| ())?;
|
||||
config["plugins"]["updater"]["pubkey"]
|
||||
.as_str()
|
||||
.map(str::to_owned)
|
||||
.ok_or(())
|
||||
}
|
||||
|
||||
fn run_helper() -> Result<(), i32> {
|
||||
// pkexec cleans the environment before executing this root-owned program.
|
||||
// Never initialize Tauri/GTK or network/account code in privileged mode.
|
||||
if unsafe { libc::geteuid() } != 0 || !installed_binary_supported() {
|
||||
return Err(INVALID_HOST);
|
||||
}
|
||||
let installed = installed_version().map_err(|_| INVALID_HOST)?;
|
||||
let (raw, bytes) = read_input(io::stdin().lock()).map_err(|_| REJECTED)?;
|
||||
let version = verify_deb_release(
|
||||
&raw,
|
||||
&bytes,
|
||||
&embedded_key().map_err(|_| REJECTED)?,
|
||||
&installed,
|
||||
)
|
||||
.map_err(|_| REJECTED)?;
|
||||
// No untrusted filesystem object crosses the privilege boundary. This
|
||||
// directory is created by root, mode 0700, after all signature checks.
|
||||
if !trusted_root_path(Path::new("/var"), false)
|
||||
|| !safe_sticky_temporary_parent(Path::new("/var/tmp"))
|
||||
{
|
||||
return Err(INVALID_HOST);
|
||||
}
|
||||
let temp = tempfile::Builder::new()
|
||||
.prefix("shacraft-update-")
|
||||
.tempdir_in("/var/tmp")
|
||||
.map_err(|_| INSTALL_FAILED)?;
|
||||
fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o700))
|
||||
.map_err(|_| INSTALL_FAILED)?;
|
||||
let package = temp.path().join("release.deb");
|
||||
let mut output = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&package)
|
||||
.map_err(|_| INSTALL_FAILED)?;
|
||||
output
|
||||
.set_permissions(fs::Permissions::from_mode(0o600))
|
||||
.map_err(|_| INSTALL_FAILED)?;
|
||||
output
|
||||
.write_all(&bytes)
|
||||
.and_then(|_| output.sync_all())
|
||||
.map_err(|_| INSTALL_FAILED)?;
|
||||
drop(output);
|
||||
let fields = capture(
|
||||
fixed_command(DEB)
|
||||
.arg("--show")
|
||||
.arg("--showformat=${Package}\n${Version}\n${Architecture}\n")
|
||||
.arg(&package),
|
||||
)
|
||||
.map_err(|_| REJECTED)?;
|
||||
if !fields.status.success() || !valid_package_fields(&fields.stdout, &version) {
|
||||
return Err(REJECTED);
|
||||
}
|
||||
// Check again immediately before mutation: another updater might have
|
||||
// installed the release while the authentication dialog was open.
|
||||
if !updater::newer_version(
|
||||
&serde_json::json!({"version": version}),
|
||||
&installed_version().map_err(|_| INVALID_HOST)?,
|
||||
)
|
||||
.map_err(|_| REJECTED)?
|
||||
{
|
||||
return Err(REJECTED);
|
||||
}
|
||||
let result = capture(
|
||||
fixed_command(DPKG)
|
||||
.args(["--refuse-downgrade", "--install"])
|
||||
.arg(&package),
|
||||
)
|
||||
.map_err(|_| INSTALL_FAILED)?;
|
||||
if !result.status.success() {
|
||||
return Err(if lock_error(&result.stderr) {
|
||||
LOCKED
|
||||
} else {
|
||||
INSTALL_FAILED
|
||||
});
|
||||
}
|
||||
if installed_version().map_err(|_| INSTALL_FAILED)? != version {
|
||||
return Err(INSTALL_FAILED);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The special flag is never registered as IPC and does not accept filenames.
|
||||
/// Even manually invoking it cannot bypass signatures, package identity or
|
||||
/// privilege checks. Errors intentionally print no package/metadata contents.
|
||||
pub(crate) fn run_helper_if_requested() -> Option<i32> {
|
||||
let arguments = std::env::args_os().skip(1).collect::<Vec<_>>();
|
||||
if !arguments.iter().any(|argument| argument == HELPER_FLAG) {
|
||||
return None;
|
||||
}
|
||||
if arguments.len() != 1 || arguments[0] != HELPER_FLAG {
|
||||
return Some(REJECTED);
|
||||
}
|
||||
Some(match run_helper() {
|
||||
Ok(()) => 0,
|
||||
Err(code) => code,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn framed_input_rejects_oversized_truncated_and_trailing_data() {
|
||||
let mut bytes = Vec::new();
|
||||
write_input(&mut bytes, b"{}", b"package").unwrap();
|
||||
let (metadata, package) = read_input(&bytes[..]).unwrap();
|
||||
assert_eq!(metadata, serde_json::json!({}));
|
||||
assert_eq!(package, b"package");
|
||||
assert!(read_input(&bytes[..bytes.len() - 1]).is_err());
|
||||
bytes.push(0);
|
||||
assert!(read_input(&bytes[..]).is_err());
|
||||
let mut oversized = INPUT_MAGIC.to_vec();
|
||||
oversized.extend_from_slice(&u64::MAX.to_be_bytes());
|
||||
assert!(read_input(&oversized[..]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_identity_version_architecture_are_exact() {
|
||||
let good = format!("{PACKAGE}\n0.1.4\n{}\n", architecture());
|
||||
assert!(valid_package_fields(good.as_bytes(), "0.1.4"));
|
||||
for wrong in [
|
||||
good.replace(PACKAGE, "another-package"),
|
||||
good.replace("0.1.4", "0.1.5"),
|
||||
good.replace(architecture(), "all"),
|
||||
format!("{good}extra\n"),
|
||||
] {
|
||||
assert!(!valid_package_fields(wrong.as_bytes(), "0.1.4"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_authorization_and_package_lock_remain_distinct() {
|
||||
assert!(exit_message(Some(126)).contains("отменена"));
|
||||
assert!(exit_message(Some(127)).contains("не разрешила"));
|
||||
assert!(exit_message(Some(LOCKED)).contains("занят"));
|
||||
assert!(lock_error(
|
||||
b"dpkg: error: dpkg frontend lock was locked by another process"
|
||||
));
|
||||
assert!(!lock_error(
|
||||
b"dpkg: dependency problems prevent configuration"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn privileged_path_rejects_user_owned_files_symlinks_and_relative_paths() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let file = directory.path().join("launcher");
|
||||
fs::write(&file, b"file").unwrap();
|
||||
fs::set_permissions(&file, fs::Permissions::from_mode(0o777)).unwrap();
|
||||
assert!(!trusted_root_path(&file, true));
|
||||
let link = directory.path().join("link");
|
||||
std::os::unix::fs::symlink("/usr/bin/dpkg", &link).unwrap();
|
||||
assert!(!trusted_root_path(&link, true));
|
||||
assert!(!trusted_root_path(Path::new("usr/bin/dpkg"), true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_verification_does_not_accept_legacy_appimage_as_deb() {
|
||||
let fixture: Value =
|
||||
serde_json::from_str(include_str!("../tests/fixtures/updater-signed.json")).unwrap();
|
||||
assert!(verify_deb_release(
|
||||
&fixture["metadata"],
|
||||
fixture["artifactText"].as_str().unwrap().as_bytes(),
|
||||
fixture["publicKey"].as_str().unwrap(),
|
||||
"0.0.0"
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspection_timeout_kills_descendants_holding_output_pipes() {
|
||||
let start = Instant::now();
|
||||
let result = capture_with_deadline(
|
||||
Command::new("/bin/sh").args(["-c", "sleep 30 & exit 0"]),
|
||||
Duration::from_millis(100),
|
||||
);
|
||||
assert!(matches!(result, Err(error) if error.kind() == io::ErrorKind::TimedOut));
|
||||
assert!(start.elapsed() < Duration::from_secs(3));
|
||||
let output = capture(fixed_command(DEB).arg("--version")).unwrap();
|
||||
assert!(output.status.success());
|
||||
assert!(String::from_utf8_lossy(&output.stdout).contains("Debian"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_output_is_bounded_and_fully_drained() {
|
||||
let input = vec![b'x'; OUTPUT_LIMIT * 4];
|
||||
assert_eq!(drain_capped(input.as_slice()).unwrap().len(), OUTPUT_LIMIT);
|
||||
}
|
||||
}
|
||||
+109
-31
@@ -1,11 +1,12 @@
|
||||
use reqwest::blocking::{Client, Response};
|
||||
use crate::storage::AtomicFile;
|
||||
use reqwest::blocking::Client;
|
||||
use sha1::Sha1;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
fmt,
|
||||
fs::{self, File},
|
||||
io::{self, Read, Write},
|
||||
path::{Path, PathBuf},
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
@@ -73,12 +74,19 @@ pub fn file_hashes(path: &Path) -> io::Result<(String, String)> {
|
||||
sha1.update(&buffer[..read]);
|
||||
sha256.update(&buffer[..read]);
|
||||
}
|
||||
Ok((format!("{:x}", sha1.finalize()), format!("{:x}", sha256.finalize())))
|
||||
Ok((
|
||||
format!("{:x}", sha1.finalize()),
|
||||
format!("{:x}", sha256.finalize()),
|
||||
))
|
||||
}
|
||||
|
||||
/// True if `path` already exists, matches `expected_size` (when given) and
|
||||
/// `checksum`. Used to skip re-downloading files that are already current.
|
||||
pub fn is_current(path: &Path, expected_size: Option<u64>, checksum: &Checksum) -> io::Result<bool> {
|
||||
pub fn is_current(
|
||||
path: &Path,
|
||||
expected_size: Option<u64>,
|
||||
checksum: &Checksum,
|
||||
) -> io::Result<bool> {
|
||||
let metadata = match path.metadata() {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false),
|
||||
@@ -96,14 +104,6 @@ pub fn is_current(path: &Path, expected_size: Option<u64>, checksum: &Checksum)
|
||||
Ok(checksum.matches(&sha1_hex, &sha256_hex))
|
||||
}
|
||||
|
||||
fn temp_path(target: &Path) -> Result<PathBuf, DownloadError> {
|
||||
let file_name = target
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.ok_or(DownloadError::InvalidTargetPath)?;
|
||||
Ok(target.with_file_name(format!(".{file_name}.shacraft.part")))
|
||||
}
|
||||
|
||||
/// Downloads `url` to `target`, verifying size (if known ahead of time) and
|
||||
/// `checksum` before atomically renaming the temporary file into place.
|
||||
/// `on_progress(downloaded_bytes, total_bytes)` is called after every chunk;
|
||||
@@ -126,30 +126,34 @@ pub fn download_verified(
|
||||
let total = expected_size.or_else(|| response.content_length());
|
||||
if let (Some(expected), Some(length)) = (expected_size, response.content_length()) {
|
||||
if expected != length {
|
||||
return Err(DownloadError::SizeMismatch { expected, actual: length });
|
||||
return Err(DownloadError::SizeMismatch {
|
||||
expected,
|
||||
actual: length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let temporary = temp_path(target)?;
|
||||
let result = write_and_verify(&mut response, &temporary, expected_size, checksum, total, &mut on_progress);
|
||||
if let Err(error) = result {
|
||||
let _ = fs::remove_file(&temporary);
|
||||
return Err(error);
|
||||
}
|
||||
let bytes = result.unwrap();
|
||||
fs::rename(&temporary, target).map_err(DownloadError::Io)?;
|
||||
let mut output = AtomicFile::new(target).map_err(DownloadError::Io)?;
|
||||
let bytes = write_and_verify(
|
||||
&mut response,
|
||||
output.writer(),
|
||||
expected_size,
|
||||
checksum,
|
||||
total,
|
||||
&mut on_progress,
|
||||
)?;
|
||||
output.commit().map_err(DownloadError::Io)?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn write_and_verify(
|
||||
response: &mut Response,
|
||||
temporary: &Path,
|
||||
response: &mut impl Read,
|
||||
output: &mut impl Write,
|
||||
expected_size: Option<u64>,
|
||||
checksum: &Checksum,
|
||||
total: Option<u64>,
|
||||
on_progress: &mut impl FnMut(u64, Option<u64>),
|
||||
) -> Result<u64, DownloadError> {
|
||||
let mut output = File::create(temporary).map_err(DownloadError::Io)?;
|
||||
let mut sha1 = Sha1::new();
|
||||
let mut sha256 = Sha256::new();
|
||||
let mut bytes = 0_u64;
|
||||
@@ -160,17 +164,29 @@ fn write_and_verify(
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
output.write_all(&buffer[..read]).map_err(DownloadError::Io)?;
|
||||
bytes += read as u64;
|
||||
if let Some(expected) = expected_size {
|
||||
if bytes > expected {
|
||||
return Err(DownloadError::SizeMismatch {
|
||||
expected,
|
||||
actual: bytes,
|
||||
});
|
||||
}
|
||||
}
|
||||
output
|
||||
.write_all(&buffer[..read])
|
||||
.map_err(DownloadError::Io)?;
|
||||
sha1.update(&buffer[..read]);
|
||||
sha256.update(&buffer[..read]);
|
||||
bytes += read as u64;
|
||||
on_progress(bytes, total);
|
||||
}
|
||||
output.sync_all().map_err(DownloadError::Io)?;
|
||||
|
||||
if let Some(expected) = expected_size {
|
||||
if bytes != expected {
|
||||
return Err(DownloadError::SizeMismatch { expected, actual: bytes });
|
||||
return Err(DownloadError::SizeMismatch {
|
||||
expected,
|
||||
actual: bytes,
|
||||
});
|
||||
}
|
||||
}
|
||||
let sha1_hex = format!("{:x}", sha1.finalize());
|
||||
@@ -184,13 +200,19 @@ fn write_and_verify(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{file_hashes, is_current, Checksum};
|
||||
use std::{fs, process, time::{SystemTime, UNIX_EPOCH}};
|
||||
use std::{
|
||||
fs, process,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
fn temp_file(contents: &[u8]) -> std::path::PathBuf {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"shacraft-download-test-{}-{}",
|
||||
process::id(),
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos()
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
fs::write(&path, contents).unwrap();
|
||||
path
|
||||
@@ -201,7 +223,10 @@ mod tests {
|
||||
let path = temp_file(b"hello shacraft");
|
||||
let (sha1_hex, sha256_hex) = file_hashes(&path).unwrap();
|
||||
assert_eq!(sha1_hex, "124b319646ec08b4fb2a2b65bbd21c0431b4eaf4");
|
||||
assert_eq!(sha256_hex, "d34eb8ea6396e8492109813c717f7eefd0437c10ff55a7b11949cfae900c946d");
|
||||
assert_eq!(
|
||||
sha256_hex,
|
||||
"d34eb8ea6396e8492109813c717f7eefd0437c10ff55a7b11949cfae900c946d"
|
||||
);
|
||||
fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
@@ -220,4 +245,57 @@ mod tests {
|
||||
let path = std::env::temp_dir().join("shacraft-download-test-missing-file-xyz");
|
||||
assert!(!is_current(&path, None, &Checksum::Sha256("0".repeat(64))).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_download_keeps_existing_file_and_cleans_temporary() {
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
net::TcpListener,
|
||||
};
|
||||
let path = temp_file(b"previous version");
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let url = format!("http://{}/test.jar", listener.local_addr().unwrap());
|
||||
let server = std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut request = [0_u8; 4096];
|
||||
let _ = stream.read(&mut request).unwrap();
|
||||
stream
|
||||
.write_all(
|
||||
b"HTTP/1.1 200 OK\r\nContent-Length: 7\r\nConnection: close\r\n\r\ncorrupt",
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
let error = super::download_verified(
|
||||
&reqwest::blocking::Client::new(),
|
||||
&url,
|
||||
&path,
|
||||
Some(7),
|
||||
&Checksum::Sha256("0".repeat(64)),
|
||||
|_, _| {},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, super::DownloadError::ChecksumMismatch));
|
||||
assert_eq!(fs::read(&path).unwrap(), b"previous version");
|
||||
server.join().unwrap();
|
||||
fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_body_is_stopped_before_writing_excess() {
|
||||
let mut output = Vec::new();
|
||||
let error = super::write_and_verify(
|
||||
&mut std::io::repeat(b'x'),
|
||||
&mut output,
|
||||
Some(2),
|
||||
&Checksum::Sha256("0".repeat(64)),
|
||||
Some(2),
|
||||
&mut |_, _| {},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
super::DownloadError::SizeMismatch { expected: 2, .. }
|
||||
));
|
||||
assert!(output.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
//! Fabric metadata and Maven are fixed, independent game trust domains.
|
||||
//! The signed ShaCraft manifest selects versions, never arbitrary loader URLs.
|
||||
use crate::mojang::{Artifact, LibraryDownloads, VersionJson};
|
||||
use reqwest::blocking::Client;
|
||||
use serde::Deserialize;
|
||||
use std::{io::Read, time::Duration};
|
||||
|
||||
pub(crate) const MAVEN_HOST: &str = "maven.fabricmc.net";
|
||||
const HOSTS: [&str; 2] = ["meta.fabricmc.net", MAVEN_HOST];
|
||||
const MAX_PROFILE: usize = 512 * 1024;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FabricLibrary {
|
||||
name: String,
|
||||
url: String,
|
||||
sha1: Option<String>,
|
||||
size: Option<u64>,
|
||||
}
|
||||
|
||||
fn coordinate_path(name: &str) -> Result<String, String> {
|
||||
let pieces: Vec<_> = name.split(':').collect();
|
||||
if pieces.len() != 3
|
||||
|| pieces.iter().any(|p| {
|
||||
!crate::manifest::is_portable_component(p)
|
||||
|| *p == "."
|
||||
|| *p == ".."
|
||||
|| !p
|
||||
.bytes()
|
||||
.all(|c| c.is_ascii_alphanumeric() || b"._+-".contains(&c))
|
||||
})
|
||||
|| pieces[0].split('.').any(|p| !crate::manifest::is_portable_component(p))
|
||||
{
|
||||
return Err("Invalid Fabric library coordinate".into());
|
||||
}
|
||||
Ok(format!(
|
||||
"{}/{}/{}/{}-{}.jar",
|
||||
pieces[0].replace('.', "/"),
|
||||
pieces[1],
|
||||
pieces[2],
|
||||
pieces[1],
|
||||
pieces[2]
|
||||
))
|
||||
}
|
||||
|
||||
fn get(client: &Client, url: &str, limit: usize) -> Result<Vec<u8>, String> {
|
||||
let response = client
|
||||
.get(url)
|
||||
.send()
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut bytes = Vec::new();
|
||||
response
|
||||
.take((limit + 1) as u64)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if bytes.len() > limit {
|
||||
return Err("Fabric metadata exceeds its size limit".into());
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
pub(crate) fn fetch_profile(minecraft: &str, loader: &str) -> Result<VersionJson, String> {
|
||||
let client =
|
||||
crate::trusted_http::client(&HOSTS, Duration::from_secs(30)).map_err(|e| e.to_string())?;
|
||||
// Defence in depth: these values normally already passed manifest validation.
|
||||
if [minecraft, loader].iter().any(|v| {
|
||||
!crate::manifest::is_portable_component(v)
|
||||
|| v.contains('/')
|
||||
|| !v
|
||||
.bytes()
|
||||
.all(|c| c.is_ascii_alphanumeric() || b"._+-".contains(&c))
|
||||
}) {
|
||||
return Err("Invalid Fabric version".into());
|
||||
}
|
||||
let bytes = get(
|
||||
&client,
|
||||
&format!("https://meta.fabricmc.net/v2/versions/loader/{minecraft}/{loader}/profile/json"),
|
||||
MAX_PROFILE,
|
||||
)?;
|
||||
normalize(&client, &bytes, minecraft, loader)
|
||||
}
|
||||
|
||||
fn normalize(
|
||||
client: &Client,
|
||||
bytes: &[u8],
|
||||
minecraft: &str,
|
||||
loader: &str,
|
||||
) -> Result<VersionJson, String> {
|
||||
// Flattening libraries would consume the same key twice; parse the two views explicitly.
|
||||
let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| e.to_string())?;
|
||||
let parent = value.get("inheritsFrom").and_then(|v| v.as_str());
|
||||
let mut version: VersionJson =
|
||||
serde_json::from_value(value.clone()).map_err(|e| e.to_string())?;
|
||||
if parent != Some(minecraft)
|
||||
|| version.id != format!("fabric-loader-{loader}-{minecraft}")
|
||||
|| version.main_class != "net.fabricmc.loader.impl.launch.knot.KnotClient"
|
||||
{
|
||||
return Err("Fabric profile identity mismatch".into());
|
||||
}
|
||||
let artifacts: Vec<FabricLibrary> =
|
||||
serde_json::from_value(value["libraries"].clone()).map_err(|e| e.to_string())?;
|
||||
if artifacts.is_empty() || artifacts.len() > 32 {
|
||||
return Err("Invalid Fabric library count".into());
|
||||
}
|
||||
for (library, artifact) in version.libraries.iter_mut().zip(artifacts) {
|
||||
if artifact.url != "https://maven.fabricmc.net/" {
|
||||
return Err("Untrusted Fabric Maven URL".into());
|
||||
}
|
||||
let path = coordinate_path(&artifact.name)?;
|
||||
let url = format!("https://{MAVEN_HOST}/{path}");
|
||||
let sha1 = match artifact.sha1 {
|
||||
Some(hash) => hash,
|
||||
None => String::from_utf8(get(client, &format!("{url}.sha1"), 128)?)
|
||||
.map_err(|e| e.to_string())?
|
||||
.trim()
|
||||
.to_owned(),
|
||||
};
|
||||
let size = match artifact.size {
|
||||
Some(size) => size,
|
||||
None => client
|
||||
.head(&url)
|
||||
.send()
|
||||
.map_err(|e| e.to_string())?
|
||||
.error_for_status()
|
||||
.map_err(|e| e.to_string())?
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_LENGTH)
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.ok_or("Fabric library has no size")?,
|
||||
};
|
||||
if sha1.len() != 40
|
||||
|| !sha1.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
|| size == 0
|
||||
|| size > 64 * 1024 * 1024
|
||||
{
|
||||
return Err("Invalid Fabric library hash or size".into());
|
||||
}
|
||||
library.downloads = Some(LibraryDownloads {
|
||||
artifact: Some(Artifact {
|
||||
path,
|
||||
url,
|
||||
sha1,
|
||||
size,
|
||||
}),
|
||||
});
|
||||
}
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn maven_coordinates_cannot_escape_library_directory() {
|
||||
assert_eq!(
|
||||
coordinate_path("net.fabricmc:fabric-loader:0.19.5").unwrap(),
|
||||
"net/fabricmc/fabric-loader/0.19.5/fabric-loader-0.19.5.jar"
|
||||
);
|
||||
for bad in [
|
||||
"x:y:../evil",
|
||||
"a..b:c:1",
|
||||
"x:/tmp:1",
|
||||
"x:y:1:extra",
|
||||
"x:y:\\evil",
|
||||
"x:y:..",
|
||||
"CON:y:1",
|
||||
"a:y.:1",
|
||||
"a:AUX:1",
|
||||
] {
|
||||
assert!(coordinate_path(bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn loader_identity_and_maven_origin_are_checked_before_downloads() {
|
||||
let client = Client::new();
|
||||
let base = serde_json::json!({"id":"fabric-loader-0.19.5-26.2", "inheritsFrom":"26.2", "mainClass":"net.fabricmc.loader.impl.launch.knot.KnotClient", "libraries":[{"name":"net.fabricmc:fabric-loader:0.19.5", "url":"https://maven.fabricmc.net/", "sha1":"a".repeat(40), "size":42}]});
|
||||
assert!(normalize(
|
||||
&client,
|
||||
&serde_json::to_vec(&base).unwrap(),
|
||||
"26.2",
|
||||
"0.19.5"
|
||||
)
|
||||
.is_ok());
|
||||
for (field, value) in [
|
||||
("inheritsFrom", "1.21.1"),
|
||||
("mainClass", "attacker.Main"),
|
||||
("id", "wrong"),
|
||||
] {
|
||||
let mut bad = base.clone();
|
||||
bad[field] = value.into();
|
||||
assert!(normalize(
|
||||
&client,
|
||||
&serde_json::to_vec(&bad).unwrap(),
|
||||
"26.2",
|
||||
"0.19.5"
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
let mut bad = base;
|
||||
bad["libraries"][0]["url"] = "https://attacker.invalid/".into();
|
||||
assert!(normalize(
|
||||
&client,
|
||||
&serde_json::to_vec(&bad).unwrap(),
|
||||
"26.2",
|
||||
"0.19.5"
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "downloads official Fabric profile metadata"]
|
||||
fn live_resolves_fabric_26_2() {
|
||||
let profile = fetch_profile("26.2", "0.19.5").unwrap();
|
||||
assert!(profile
|
||||
.libraries
|
||||
.iter()
|
||||
.all(|library| library.downloads.is_some()));
|
||||
}
|
||||
}
|
||||
+26
-9
@@ -2,7 +2,11 @@ use crate::download::ProgressCallback;
|
||||
use crate::runtime::{self, RuntimeError};
|
||||
use reqwest::blocking::Client;
|
||||
use serde::Serialize;
|
||||
use std::{env, fmt, path::{Path, PathBuf}, process::Command};
|
||||
use std::{
|
||||
env, fmt,
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -21,9 +25,14 @@ pub enum EnsureJavaError {
|
||||
impl fmt::Display for EnsureJavaError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Provisioning(error) => write!(formatter, "Cannot install a Java runtime: {error}"),
|
||||
Self::Provisioning(error) => {
|
||||
write!(formatter, "Cannot install a Java runtime: {error}")
|
||||
}
|
||||
Self::ProvisionedButUnrecognised(path) => {
|
||||
write!(formatter, "Installed a Java runtime at {path:?}, but it did not report a usable version")
|
||||
write!(
|
||||
formatter,
|
||||
"Installed a Java runtime at {path:?}, but it did not report a usable version"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,21 +47,29 @@ pub fn detect() -> Option<JavaInstallation> {
|
||||
candidates().into_iter().find_map(check_candidate)
|
||||
}
|
||||
|
||||
/// Returns a Java runtime with at least `required_major`, preferring
|
||||
/// whatever the user already has installed. Only downloads and extracts a
|
||||
/// Returns a Java runtime with exactly `required_major`, preferring a matching
|
||||
/// installation already on the machine. Newer JVM majors are not assumed to
|
||||
/// be compatible with the selected NeoForge/modpack version. Only downloads and extracts a
|
||||
/// ShaCraft-managed Eclipse Temurin JRE under `runtime_root` (never touches
|
||||
/// the user's own Java) when nothing suitable is already on the machine.
|
||||
/// `on_progress` reports real download bytes when a JRE actually needs
|
||||
/// fetching; it fires once with `(1, 1)` when an existing Java is reused.
|
||||
pub fn ensure_java(client: &Client, runtime_root: &Path, required_major: u8, on_progress: &ProgressCallback) -> Result<JavaInstallation, EnsureJavaError> {
|
||||
pub fn ensure_java(
|
||||
client: &Client,
|
||||
runtime_root: &Path,
|
||||
required_major: u8,
|
||||
on_progress: &ProgressCallback,
|
||||
) -> Result<JavaInstallation, EnsureJavaError> {
|
||||
if let Some(installation) = detect() {
|
||||
if installation.major >= required_major {
|
||||
if installation.major == required_major {
|
||||
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()).ok_or(EnsureJavaError::ProvisionedButUnrecognised(executable))
|
||||
let executable = runtime::ensure_runtime(client, runtime_root, required_major, on_progress)
|
||||
.map_err(EnsureJavaError::Provisioning)?;
|
||||
check_candidate(executable.clone())
|
||||
.ok_or(EnsureJavaError::ProvisionedButUnrecognised(executable))
|
||||
}
|
||||
|
||||
fn candidates() -> Vec<PathBuf> {
|
||||
|
||||
+192
-26
@@ -1,11 +1,10 @@
|
||||
//! Builds and spawns the real `java` invocation for a merged launch
|
||||
//! profile. The `${auth_*}` placeholders are filled from a `PlayerIdentity`,
|
||||
//! which is either a real Microsoft-authenticated session (`msa::LoginResult`)
|
||||
//! or an explicit offline account (`PlayerIdentity::Offline`). Offline mode is
|
||||
//! never silently substituted for a Microsoft session.
|
||||
//! profile. The `${auth_*}` placeholders use only the identity bound to the
|
||||
//! server-issued admission ticket. Its one-use proof stays out of arguments
|
||||
//! and files; only the Java child's environment receives it.
|
||||
|
||||
use crate::admission::Admission;
|
||||
use crate::mojang::{self, MergedVersion};
|
||||
use crate::session::PlayerIdentity;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
@@ -44,7 +43,7 @@ pub struct LaunchRequest<'a> {
|
||||
/// the shared `game_dir`.
|
||||
pub profile_dir: &'a Path,
|
||||
pub merged: &'a MergedVersion,
|
||||
pub identity: &'a PlayerIdentity,
|
||||
pub admission: &'a Admission,
|
||||
pub memory_mb: u16,
|
||||
pub log_path: &'a Path,
|
||||
}
|
||||
@@ -69,7 +68,12 @@ fn build_classpath(game_dir: &Path, merged: &MergedVersion, client_jar: &Path) -
|
||||
.libraries
|
||||
.iter()
|
||||
.filter(|library| mojang::rule_allows(&library.rules, &no_features))
|
||||
.filter_map(|library| library.downloads.as_ref().and_then(|downloads| downloads.artifact.as_ref()))
|
||||
.filter_map(|library| {
|
||||
library
|
||||
.downloads
|
||||
.as_ref()
|
||||
.and_then(|downloads| downloads.artifact.as_ref())
|
||||
})
|
||||
.map(|artifact| game_dir.join("libraries").join(&artifact.path))
|
||||
.collect();
|
||||
entries.push(client_jar.to_path_buf());
|
||||
@@ -78,7 +82,11 @@ fn build_classpath(game_dir: &Path, merged: &MergedVersion, client_jar: &Path) -
|
||||
// (for example on gson-2.10.1.jar), so preserve order and keep each path
|
||||
// only once.
|
||||
let entries = unique_classpath_entries(entries);
|
||||
entries.iter().map(|path| path.display().to_string()).collect::<Vec<_>>().join(classpath_separator())
|
||||
entries
|
||||
.iter()
|
||||
.map(|path| path.display().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(classpath_separator())
|
||||
}
|
||||
|
||||
/// A persistent-but-not-security-sensitive per-install identifier for the
|
||||
@@ -104,7 +112,13 @@ static UUID_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn random_uuid_v4() -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos().to_le_bytes());
|
||||
hasher.update(
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
.to_le_bytes(),
|
||||
);
|
||||
hasher.update(std::process::id().to_le_bytes());
|
||||
hasher.update(UUID_COUNTER.fetch_add(1, Ordering::Relaxed).to_le_bytes());
|
||||
let stack_marker = 0_u8;
|
||||
@@ -114,7 +128,10 @@ fn random_uuid_v4() -> String {
|
||||
bytes.copy_from_slice(&digest[0..16]);
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant
|
||||
let hex = bytes.iter().map(|byte| format!("{byte:02x}")).collect::<String>();
|
||||
let hex = bytes
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>();
|
||||
crate::session::format_uuid_with_dashes(&hex)
|
||||
}
|
||||
|
||||
@@ -129,22 +146,58 @@ fn substitute(template: &str, vars: &HashMap<&str, String>) -> String {
|
||||
result
|
||||
}
|
||||
|
||||
/// Java's argument-file syntax is independent of the platform shell. Keeping
|
||||
/// the large JVM/module/classpath portion in an argfile avoids Windows'
|
||||
/// 32,767 UTF-16 command-line limit while leaving account tokens out of it.
|
||||
fn quote_argfile_argument(argument: &str) -> String {
|
||||
let mut quoted = String::with_capacity(argument.len() + 2);
|
||||
quoted.push('"');
|
||||
for character in argument.chars() {
|
||||
match character {
|
||||
'\\' => quoted.push_str("\\\\"),
|
||||
'"' => quoted.push_str("\\\""),
|
||||
'\n' => quoted.push_str("\\n"),
|
||||
'\r' => quoted.push_str("\\r"),
|
||||
'\t' => quoted.push_str("\\t"),
|
||||
other => quoted.push(other),
|
||||
}
|
||||
}
|
||||
quoted.push('"');
|
||||
quoted
|
||||
}
|
||||
|
||||
fn write_jvm_argfile(path: &Path, arguments: &[String]) -> io::Result<()> {
|
||||
let mut contents = arguments
|
||||
.iter()
|
||||
.map(|argument| quote_argfile_argument(argument))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
contents.push('\n');
|
||||
fs::write(path, contents)
|
||||
}
|
||||
|
||||
/// Builds the full `java` command line for `request.merged` and spawns it
|
||||
/// detached, with stdout/stderr both redirected to `request.log_path`.
|
||||
/// Never blocks on the child exiting — the caller decides how to observe
|
||||
/// 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)?;
|
||||
let assets_root = request.game_dir.join("assets");
|
||||
let libraries_dir = request.game_dir.join("libraries");
|
||||
let client_jar = mojang::client_jar_path(request.game_dir, &request.merged.client_jar_version_id);
|
||||
let client_jar =
|
||||
mojang::client_jar_path(request.game_dir, &request.merged.client_jar_version_id);
|
||||
let classpath = build_classpath(request.game_dir, request.merged, &client_jar);
|
||||
|
||||
let mut vars: HashMap<&str, String> = HashMap::new();
|
||||
vars.insert("auth_player_name", request.identity.name().to_string());
|
||||
let identity = request.admission.identity();
|
||||
vars.insert("auth_player_name", identity.name().to_string());
|
||||
// NeoForge's inherited JVM profile uses `${version_name}.jar` in
|
||||
// `-DignoreList`. The actual client jar belongs to the vanilla parent
|
||||
// (`1.21.1.jar`), not to the child profile (`neoforge-...`), so this
|
||||
@@ -154,11 +207,11 @@ pub fn launch(request: &LaunchRequest) -> Result<Child, LaunchError> {
|
||||
vars.insert("game_directory", request.profile_dir.display().to_string());
|
||||
vars.insert("assets_root", assets_root.display().to_string());
|
||||
vars.insert("assets_index_name", request.merged.asset_index.id.clone());
|
||||
vars.insert("auth_uuid", request.identity.uuid());
|
||||
vars.insert("auth_access_token", request.identity.access_token().to_string());
|
||||
vars.insert("auth_uuid", identity.uuid());
|
||||
vars.insert("auth_access_token", identity.access_token().to_string());
|
||||
vars.insert("clientid", launcher_client_id(request.game_dir)?);
|
||||
vars.insert("auth_xuid", request.identity.xuid().to_string());
|
||||
vars.insert("user_type", request.identity.user_type().to_string());
|
||||
vars.insert("auth_xuid", identity.xuid().to_string());
|
||||
vars.insert("user_type", identity.user_type().to_string());
|
||||
vars.insert("version_type", "ShaCraft Launcher".to_string());
|
||||
vars.insert("natives_directory", natives_dir.display().to_string());
|
||||
vars.insert("launcher_name", "ShaCraft Launcher".to_string());
|
||||
@@ -168,43 +221,134 @@ pub fn launch(request: &LaunchRequest) -> Result<Child, LaunchError> {
|
||||
vars.insert("classpath_separator", classpath_separator().to_string());
|
||||
|
||||
let no_features = HashMap::new();
|
||||
let jvm_args = mojang::resolve_arguments(&request.merged.jvm_arguments, &no_features);
|
||||
let jvm_args = mojang::resolve_arguments(&request.merged.jvm_arguments, &no_features)
|
||||
.into_iter()
|
||||
.map(|argument| substitute(&argument, &vars))
|
||||
.collect::<Vec<_>>();
|
||||
let game_args = mojang::resolve_arguments(&request.merged.game_arguments, &no_features);
|
||||
|
||||
let mut command = Command::new(request.java_executable);
|
||||
command.arg(format!("-Xmx{}M", request.memory_mb));
|
||||
for argument in jvm_args {
|
||||
command.arg(substitute(&argument, &vars));
|
||||
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");
|
||||
let mut argfile_arguments = Vec::with_capacity(jvm_args.len() + 1);
|
||||
argfile_arguments.push(memory_argument);
|
||||
argfile_arguments.extend(jvm_args);
|
||||
write_jvm_argfile(&argfile, &argfile_arguments)?;
|
||||
command.arg(format!("@{}", argfile.display()));
|
||||
} else {
|
||||
command.arg(memory_argument);
|
||||
command.args(jvm_args);
|
||||
}
|
||||
command.arg(&request.merged.main_class);
|
||||
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", "shacraft.ru:25568"]);
|
||||
}
|
||||
command.current_dir(request.profile_dir);
|
||||
|
||||
let log_file = fs::File::create(request.log_path)?;
|
||||
command.stdout(Stdio::from(log_file.try_clone()?));
|
||||
command.stderr(Stdio::from(log_file));
|
||||
|
||||
Ok(command.spawn()?)
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn launch_arguments_and_written_files_never_contain_admission_secrets() {
|
||||
use crate::admission::{AdmissionKey, PRIVATE_KEY_ENV, TICKET_ENV};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
|
||||
let directory =
|
||||
std::env::temp_dir().join(format!("shacraft-launch-proof-{}", random_uuid_v4()));
|
||||
let game_dir = directory.join("game");
|
||||
let profile_dir = directory.join("profile");
|
||||
let log_path = directory.join("game.log");
|
||||
for server_id in ["aoc", "minigames"] {
|
||||
let vanilla: mojang::VersionJson = serde_json::from_value(serde_json::json!({
|
||||
"id": "1.21.1",
|
||||
"mainClass": "net.minecraft.client.main.Main",
|
||||
"arguments": {
|
||||
"game": ["--username", "${auth_player_name}", "--uuid", "${auth_uuid}", "--accessToken", "${auth_access_token}"],
|
||||
"jvm": ["-cp", "${classpath}", "-Dlauncher=${launcher_name}"]
|
||||
},
|
||||
"assetIndex": {"id": "17", "sha1": "0".repeat(40), "size": 1, "url": "https://piston-meta.mojang.com/assets"},
|
||||
"downloads": {"client": {"sha1": "0".repeat(40), "size": 1, "url": "https://piston-data.mojang.com/client.jar"}}
|
||||
})).unwrap();
|
||||
let merged = mojang::merge_versions(&vanilla, None).unwrap();
|
||||
let response = serde_json::from_value(serde_json::json!({
|
||||
"ticket_id": URL_SAFE_NO_PAD.encode([73_u8; 32]), "mc_username": "Ticket_Name",
|
||||
"server_id": server_id, "expires_in_seconds": 600
|
||||
}))
|
||||
.unwrap();
|
||||
let admission = AdmissionKey::generate(server_id).unwrap().bind(response).unwrap();
|
||||
let request = LaunchRequest {
|
||||
java_executable: Path::new("java"),
|
||||
game_dir: &game_dir,
|
||||
profile_dir: &profile_dir,
|
||||
merged: &merged,
|
||||
admission: &admission,
|
||||
memory_mb: 6144,
|
||||
log_path: &log_path,
|
||||
};
|
||||
let command = build_command(&request).unwrap();
|
||||
let env: HashMap<_, _> = command.get_envs().collect();
|
||||
let proof = [TICKET_ENV, PRIVATE_KEY_ENV]
|
||||
.map(|name| env[std::ffi::OsStr::new(name)].unwrap().to_str().unwrap());
|
||||
let arguments: Vec<_> = command
|
||||
.get_args()
|
||||
.map(|arg| arg.to_string_lossy())
|
||||
.collect();
|
||||
assert!(arguments
|
||||
.windows(2)
|
||||
.any(|args| args == ["--username", "Ticket_Name"]));
|
||||
assert!(arguments
|
||||
.windows(2)
|
||||
.any(|args| args == ["--accessToken", "0"]));
|
||||
assert_eq!(arguments.windows(2).any(|pair| pair == ["--quickPlayMultiplayer", "shacraft.ru: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();
|
||||
let parts: Vec<&str> = id.split('-').collect();
|
||||
assert_eq!(parts.len(), 5);
|
||||
assert_eq!(parts[2].chars().next().unwrap(), '4');
|
||||
assert!(matches!(parts[3].chars().next().unwrap(), '8' | '9' | 'a' | 'b'));
|
||||
assert!(matches!(
|
||||
parts[3].chars().next().unwrap(),
|
||||
'8' | '9' | 'a' | 'b'
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_id_is_persisted_across_calls() {
|
||||
let dir = std::env::temp_dir().join(format!("shacraft-launch-clientid-test-{}", std::process::id()));
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"shacraft-launch-clientid-test-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let first = launcher_client_id(&dir).unwrap();
|
||||
let second = launcher_client_id(&dir).unwrap();
|
||||
assert_eq!(first, second);
|
||||
@@ -217,12 +361,34 @@ mod tests {
|
||||
vars.insert("auth_player_name", "Steve".to_string());
|
||||
assert_eq!(substitute("--username", &vars), "--username");
|
||||
assert_eq!(substitute("${auth_player_name}", &vars), "Steve");
|
||||
assert_eq!(substitute("-Djava.library.path=${natives_directory}", &vars), "-Djava.library.path=${natives_directory}");
|
||||
assert_eq!(
|
||||
substitute("-Djava.library.path=${natives_directory}", &vars),
|
||||
"-Djava.library.path=${natives_directory}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classpath_entries_are_unique() {
|
||||
let entries = unique_classpath_entries(vec![PathBuf::from("gson.jar"), PathBuf::from("gson.jar"), PathBuf::from("client.jar")]);
|
||||
assert_eq!(entries, vec![PathBuf::from("gson.jar"), PathBuf::from("client.jar")]);
|
||||
let entries = unique_classpath_entries(vec![
|
||||
PathBuf::from("gson.jar"),
|
||||
PathBuf::from("gson.jar"),
|
||||
PathBuf::from("client.jar"),
|
||||
]);
|
||||
assert_eq!(
|
||||
entries,
|
||||
vec![PathBuf::from("gson.jar"), PathBuf::from("client.jar")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quotes_java_argfile_arguments() {
|
||||
assert_eq!(
|
||||
quote_argfile_argument(r#"-Dpath=C:\\Users\\Jane Doe\\game"#),
|
||||
r#""-Dpath=C:\\\\Users\\\\Jane Doe\\\\game""#
|
||||
);
|
||||
assert_eq!(
|
||||
quote_argfile_argument(r#"-Dname="ShaCraft""#),
|
||||
r#""-Dname=\"ShaCraft\"""#
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+57
-406
@@ -1,4 +1,8 @@
|
||||
mod admission;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod deb_updater;
|
||||
mod download;
|
||||
mod fabric;
|
||||
mod java;
|
||||
mod launch;
|
||||
mod manifest;
|
||||
@@ -10,417 +14,64 @@ mod remote;
|
||||
mod runtime;
|
||||
mod session;
|
||||
mod settings;
|
||||
mod shacraft_account;
|
||||
mod storage;
|
||||
mod trusted_http;
|
||||
mod updater;
|
||||
|
||||
use reqwest::blocking::Client;
|
||||
use serde::Serialize;
|
||||
use std::{path::Path, sync::Arc, time::SystemTime};
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
|
||||
fn http_client() -> Client {
|
||||
Client::new()
|
||||
}
|
||||
|
||||
fn game_dir(app: &AppHandle) -> Result<std::path::PathBuf, String> {
|
||||
Ok(app.path().app_data_dir().map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?.join("game"))
|
||||
}
|
||||
|
||||
fn profile_dir(app: &AppHandle, profile_id: &str) -> Result<std::path::PathBuf, String> {
|
||||
Ok(app.path().app_data_dir().map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?.join("profiles").join(profile_id))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct NativeHost {
|
||||
platform: &'static str,
|
||||
data_dir: String,
|
||||
launcher_version: &'static str,
|
||||
}
|
||||
|
||||
/// Returns non-sensitive environment information needed by the interface.
|
||||
/// File access and child-process launching are deliberately not exposed yet.
|
||||
#[tauri::command]
|
||||
fn native_host(app: AppHandle) -> Result<NativeHost, String> {
|
||||
let data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?;
|
||||
|
||||
Ok(NativeHost {
|
||||
platform: std::env::consts::OS,
|
||||
data_dir: data_dir.display().to_string(),
|
||||
launcher_version: env!("CARGO_PKG_VERSION"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Detects an existing Java installation. This is read-only and never downloads Java.
|
||||
#[tauri::command]
|
||||
fn detect_java() -> Option<java::JavaInstallation> {
|
||||
java::detect()
|
||||
}
|
||||
|
||||
/// Whether Microsoft sign-in was configured for this launcher build.
|
||||
/// The UI uses this to avoid advertising a login flow that cannot start.
|
||||
#[tauri::command]
|
||||
fn microsoft_login_available() -> bool {
|
||||
msa::is_configured()
|
||||
}
|
||||
|
||||
/// Validates an untrusted profile manifest before any file is downloaded.
|
||||
#[tauri::command]
|
||||
fn validate_manifest(manifest_json: String) -> Result<(), String> {
|
||||
manifest::validate_json(&manifest_json).map(|_| ()).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// Inspects the local profile without changing player files.
|
||||
#[tauri::command]
|
||||
async fn inspect_profile(app: AppHandle, manifest_json: String) -> Result<profile::ProfileInspection, String> {
|
||||
let manifest = manifest::validate_json(&manifest_json).map_err(|error| error.to_string())?;
|
||||
let root = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?
|
||||
.join("profiles")
|
||||
.join(&manifest.id);
|
||||
|
||||
tauri::async_runtime::spawn_blocking(move || profile::inspect(&root, &manifest))
|
||||
.await
|
||||
.map_err(|error| format!("Profile inspection task failed: {error}"))?
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// Synchronizes launcher-managed files after manifest validation.
|
||||
#[tauri::command]
|
||||
async fn sync_profile(app: AppHandle, manifest_json: String) -> Result<profile::SyncResult, String> {
|
||||
let manifest = manifest::validate_json(&manifest_json).map_err(|error| error.to_string())?;
|
||||
let root = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?
|
||||
.join("profiles")
|
||||
.join(&manifest.id);
|
||||
|
||||
tauri::async_runtime::spawn_blocking(move || profile::sync(&root, &manifest))
|
||||
.await
|
||||
.map_err(|error| format!("Profile synchronization task failed: {error}"))?
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// Loads and validates the published ShaCraft manifest before inspecting a profile.
|
||||
#[tauri::command]
|
||||
async fn inspect_remote_profile(app: AppHandle, profile_id: String) -> Result<profile::ProfileInspection, String> {
|
||||
let data_dir = app.path().app_data_dir()
|
||||
.map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?;
|
||||
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())
|
||||
}).await.map_err(|error| format!("Profile inspection task failed: {error}"))?
|
||||
}
|
||||
|
||||
/// Downloads missing or changed ShaCraft-managed files from the fixed v2 endpoint.
|
||||
#[tauri::command]
|
||||
async fn sync_remote_profile(app: AppHandle, profile_id: String) -> Result<profile::SyncResult, String> {
|
||||
let data_dir = app.path().app_data_dir()
|
||||
.map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?;
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
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())
|
||||
}).await.map_err(|error| format!("Profile synchronization task failed: {error}"))?
|
||||
}
|
||||
|
||||
/// Gets live, read-only player count for a supported profile. Failure is
|
||||
/// surfaced to the interface, which displays the server as unavailable.
|
||||
#[tauri::command]
|
||||
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()))
|
||||
.await
|
||||
.map_err(|error| format!("Server-status task failed: {error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn load_settings(app: AppHandle) -> Result<settings::LauncherSettings, String> {
|
||||
let data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?;
|
||||
tauri::async_runtime::spawn_blocking(move || settings::load(&data_dir))
|
||||
.await
|
||||
.map_err(|error| format!("Settings task failed: {error}"))?
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn save_settings(app: AppHandle, settings: settings::LauncherSettings) -> Result<settings::LauncherSettings, String> {
|
||||
let data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?;
|
||||
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())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Microsoft account login
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DeviceCodePayload {
|
||||
verification_uri: String,
|
||||
user_code: String,
|
||||
expires_in_seconds: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LoginResultPayload {
|
||||
ok: bool,
|
||||
profile: Option<msa::MinecraftProfile>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
/// Starts a Microsoft device-code login in the background. Emits
|
||||
/// `msa-login-code` as soon as the user code is available (show it to the
|
||||
/// player immediately — they have a limited time to enter it), then
|
||||
/// `msa-login-result` once sign-in finishes, fails, or times out. Returns
|
||||
/// immediately; it does not wait for the user to finish signing in.
|
||||
#[tauri::command]
|
||||
fn start_microsoft_login(app: AppHandle) -> Result<(), String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let client = http_client();
|
||||
let start = match msa::start_device_code(&client) {
|
||||
Ok(start) => start,
|
||||
Err(error) => {
|
||||
let _ = app.emit("msa-login-result", LoginResultPayload { ok: false, profile: None, error: Some(error.to_string()) });
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ = app.emit(
|
||||
"msa-login-code",
|
||||
DeviceCodePayload { verification_uri: start.verification_uri.clone(), user_code: start.user_code.clone(), expires_in_seconds: start.expires_in_seconds },
|
||||
);
|
||||
|
||||
match msa::login_with_device_code(&client, &start) {
|
||||
Ok(result) => {
|
||||
if let Ok(data_dir) = app.path().app_data_dir() {
|
||||
let _ = msa::save_refresh_token(&data_dir, &result.refresh_token);
|
||||
}
|
||||
let _ = app.emit("msa-login-result", LoginResultPayload { ok: true, profile: Some(result.profile), error: None });
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = app.emit("msa-login-result", LoginResultPayload { ok: false, profile: None, error: Some(error.to_string()) });
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tries to restore a session from a previously saved refresh token
|
||||
/// (silent, no browser/user code). Returns `None` if there is none saved
|
||||
/// or it no longer works — the UI should fall back to offering login.
|
||||
#[tauri::command]
|
||||
async fn get_account(app: AppHandle) -> Result<Option<msa::MinecraftProfile>, String> {
|
||||
let data_dir = app.path().app_data_dir().map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?;
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let Some(refresh_token) = msa::load_refresh_token(&data_dir) else { return Ok(None) };
|
||||
let client = http_client();
|
||||
match msa::login_with_refresh_token(&client, &refresh_token) {
|
||||
Ok(result) => {
|
||||
let _ = msa::save_refresh_token(&data_dir, &result.refresh_token);
|
||||
Ok(Some(result.profile))
|
||||
}
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("Account restore task failed: {error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn logout(app: AppHandle) -> Result<(), String> {
|
||||
let data_dir = app.path().app_data_dir().map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?;
|
||||
tauri::async_runtime::spawn_blocking(move || msa::clear_account(&data_dir))
|
||||
.await
|
||||
.map_err(|error| format!("Logout task failed: {error}"))?
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// Resolves the identity to launch as, based on the persisted `account_mode`.
|
||||
/// In `Microsoft` mode this requires a real signed-in session (see
|
||||
/// `msa::login_with_refresh_token`) and returns an error if there is none;
|
||||
/// in `Offline` mode it uses the local nickname from settings, so no
|
||||
/// Microsoft account is needed at all. Offline is never silently used in
|
||||
/// place of a missing Microsoft session.
|
||||
fn resolve_identity(client: &Client, data_dir: &Path) -> Result<session::PlayerIdentity, String> {
|
||||
let settings = settings::load(data_dir).map_err(|error| error.to_string())?;
|
||||
match settings.account_mode {
|
||||
settings::AccountMode::Offline => Ok(session::PlayerIdentity::Offline { name: settings.nickname }),
|
||||
settings::AccountMode::Microsoft => {
|
||||
let refresh_token = msa::load_refresh_token(data_dir).ok_or("Not signed in with a Microsoft account")?;
|
||||
let result = msa::login_with_refresh_token(client, &refresh_token).map_err(|error| error.to_string())?;
|
||||
let _ = msa::save_refresh_token(data_dir, &result.refresh_token);
|
||||
Ok(session::PlayerIdentity::Microsoft(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Game install + launch
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct InstallProgress {
|
||||
stage: &'static str,
|
||||
current_bytes: u64,
|
||||
total_bytes: u64,
|
||||
}
|
||||
|
||||
/// 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, 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())?;
|
||||
|
||||
if manifest.minecraft.loader.kind == "neoforge" {
|
||||
let neoforge_version = neoforge::ensure_client_installed(client, java_executable, game_dir, cache_dir, &manifest.minecraft.loader.version, on_progress).map_err(|error| error.to_string())?;
|
||||
mojang::merge_versions(&vanilla, Some(&neoforge_version)).map_err(|error| error.to_string())
|
||||
} else {
|
||||
mojang::merge_versions(&vanilla, None).map_err(|error| error.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.
|
||||
#[tauri::command]
|
||||
async fn ensure_game_installed(app: AppHandle, profile_id: String) -> Result<(), String> {
|
||||
let game_dir = game_dir(&app)?;
|
||||
let runtime_root = game_dir.join("runtime");
|
||||
let cache_dir = game_dir.join("cache");
|
||||
|
||||
tauri::async_runtime::spawn_blocking(move || -> Result<(), String> {
|
||||
let client = http_client();
|
||||
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(&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"))?;
|
||||
// The NeoForge installer creates the loader profile and patched
|
||||
// client, but it does not guarantee that every vanilla runtime
|
||||
// library (notably LWJGL and its platform natives) is present.
|
||||
// Verify the complete merged launch set for every loader kind.
|
||||
mojang::ensure_client_jar(&client, &game_dir, &merged.client_jar_version_id, &merged.client).map_err(|error| error.to_string())?;
|
||||
mojang::ensure_libraries(&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(())
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("Install task failed: {error}"))?
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GameExited {
|
||||
profile_id: String,
|
||||
exit_code: Option<i32>,
|
||||
}
|
||||
|
||||
/// Launches `profile_id` as the account chosen in settings (`account_mode`).
|
||||
/// In `Microsoft` mode a real session is required (see `resolve_identity`);
|
||||
/// in `Offline` mode the local nickname from settings is used, so no
|
||||
/// Microsoft account is needed. Spawns the game detached; watches it on a
|
||||
/// background thread only to emit `game-exited` when it eventually closes.
|
||||
#[tauri::command]
|
||||
async fn launch_game(app: AppHandle, profile_id: String) -> Result<(), String> {
|
||||
let game_dir = game_dir(&app)?;
|
||||
let profile_dir = profile_dir(&app, &profile_id)?;
|
||||
let data_dir = app.path().app_data_dir().map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?;
|
||||
|
||||
tauri::async_runtime::spawn_blocking(move || -> Result<(), String> {
|
||||
let client = http_client();
|
||||
let identity = resolve_identity(&client, &data_dir)?;
|
||||
let manifest = remote::fetch_manifest(&profile_id).map_err(|error| error.to_string())?;
|
||||
let settings = settings::load(&data_dir).map_err(|error| error.to_string())?;
|
||||
|
||||
// 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(&client, &game_dir.join("runtime"), manifest.minecraft.java_major, &no_progress).map_err(|error| error.to_string())?;
|
||||
let merged = resolve_merged_version(&client, &manifest, Path::new(&java_install.executable), &game_dir, &game_dir.join("cache"), &no_progress)?;
|
||||
|
||||
let timestamp = SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||
let log_dir = data_dir.join("logs");
|
||||
std::fs::create_dir_all(&log_dir).map_err(|error| error.to_string())?;
|
||||
let log_path = log_dir.join(format!("{profile_id}-{timestamp}.log"));
|
||||
|
||||
let request = launch::LaunchRequest {
|
||||
java_executable: Path::new(&java_install.executable),
|
||||
game_dir: &game_dir,
|
||||
profile_dir: &profile_dir,
|
||||
merged: &merged,
|
||||
identity: &identity,
|
||||
memory_mb: settings.memory_mb,
|
||||
log_path: &log_path,
|
||||
};
|
||||
let mut child = launch::launch(&request).map_err(|error| error.to_string())?;
|
||||
|
||||
let watch_app = app.clone();
|
||||
let watch_profile_id = profile_id.clone();
|
||||
std::thread::spawn(move || {
|
||||
let exit_code = child.wait().ok().and_then(|status| status.code());
|
||||
let _ = watch_app.emit("game-exited", GameExited { profile_id: watch_profile_id, exit_code });
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("Launch task failed: {error}"))?
|
||||
}
|
||||
mod commands;
|
||||
mod operations;
|
||||
|
||||
pub fn run() {
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(code) = deb_updater::run_helper_if_requested() {
|
||||
std::process::exit(code);
|
||||
}
|
||||
use tauri::Manager;
|
||||
tauri::Builder::default()
|
||||
.manage(operations::LauncherOperations::default())
|
||||
.manage(updater::LauncherUpdater::default())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.on_window_event(|window, event| {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
// Do not interrupt the short package replacement step. A
|
||||
// download can safely be abandoned before any file changes.
|
||||
if let Some(updater) = window.try_state::<updater::LauncherUpdater>() {
|
||||
if updater
|
||||
.state
|
||||
.lock()
|
||||
.is_ok_and(|state| state.stage == updater::Stage::Installing)
|
||||
{
|
||||
api.prevent_close();
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
native_host,
|
||||
detect_java,
|
||||
microsoft_login_available,
|
||||
validate_manifest,
|
||||
inspect_profile,
|
||||
sync_profile,
|
||||
inspect_remote_profile,
|
||||
sync_remote_profile,
|
||||
get_server_status,
|
||||
load_settings,
|
||||
save_settings,
|
||||
start_microsoft_login,
|
||||
get_account,
|
||||
logout,
|
||||
ensure_game_installed,
|
||||
launch_game
|
||||
commands::host::native_host,
|
||||
commands::host::detect_java,
|
||||
commands::host::microsoft_login_available,
|
||||
commands::host::validate_manifest,
|
||||
commands::profiles::inspect_remote_profile,
|
||||
commands::profiles::sync_remote_profile,
|
||||
commands::profiles::get_server_status,
|
||||
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
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running ShaCraft Launcher");
|
||||
|
||||
+105
-24
@@ -1,6 +1,5 @@
|
||||
use serde::Deserialize;
|
||||
use std::{collections::HashSet, fmt};
|
||||
use url::Url;
|
||||
|
||||
const MAX_MANIFEST_BYTES: usize = 2 * 1024 * 1024;
|
||||
const CURRENT_SCHEMA_VERSION: u32 = 1;
|
||||
@@ -82,19 +81,27 @@ fn validate(manifest: &Manifest) -> Result<(), ManifestError> {
|
||||
)));
|
||||
}
|
||||
if !is_identifier(&manifest.id) {
|
||||
return Err(ManifestError::Invalid("Profile id must contain only lowercase letters, numbers and hyphens".into()));
|
||||
return Err(ManifestError::Invalid(
|
||||
"Profile id must contain only lowercase letters, numbers and hyphens".into(),
|
||||
));
|
||||
}
|
||||
if manifest.display_name.trim().is_empty() {
|
||||
return Err(ManifestError::Invalid("Profile displayName cannot be empty".into()));
|
||||
return Err(ManifestError::Invalid(
|
||||
"Profile displayName cannot be empty".into(),
|
||||
));
|
||||
}
|
||||
if manifest.minecraft.version.trim().is_empty()
|
||||
|| manifest.minecraft.loader.kind.trim().is_empty()
|
||||
|| manifest.minecraft.loader.version.trim().is_empty()
|
||||
if !is_version(&manifest.minecraft.version)
|
||||
|| !matches!(manifest.minecraft.loader.kind.as_str(), "neoforge" | "fabric" | "vanilla")
|
||||
|| !is_version(&manifest.minecraft.loader.version)
|
||||
{
|
||||
return Err(ManifestError::Invalid("Minecraft version and loader must be specified".into()));
|
||||
return Err(ManifestError::Invalid(
|
||||
"Minecraft version and loader must be specified".into(),
|
||||
));
|
||||
}
|
||||
if !(8..=25).contains(&manifest.minecraft.java_major) {
|
||||
return Err(ManifestError::Invalid("Unsupported Java major version".into()));
|
||||
return Err(ManifestError::Invalid(
|
||||
"Unsupported Java major version".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut paths = HashSet::new();
|
||||
@@ -103,19 +110,35 @@ fn validate(manifest: &Manifest) -> Result<(), ManifestError> {
|
||||
FilePolicy::Managed | FilePolicy::Seed => {}
|
||||
}
|
||||
if !is_safe_relative_path(&file.path) {
|
||||
return Err(ManifestError::Invalid(format!("Unsafe file path: {}", file.path)));
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"Unsafe file path: {}",
|
||||
file.path
|
||||
)));
|
||||
}
|
||||
if !paths.insert(&file.path) {
|
||||
return Err(ManifestError::Invalid(format!("Duplicate file path: {}", file.path)));
|
||||
// A manifest must resolve to the same distinct files on Windows/macOS.
|
||||
if !paths.insert(file.path.to_lowercase()) {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"Duplicate file path: {}",
|
||||
file.path
|
||||
)));
|
||||
}
|
||||
if !is_allowed_download_url(&file.url) {
|
||||
return Err(ManifestError::Invalid(format!("File URL must use HTTPS and a ShaCraft host: {}", file.path)));
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"File URL must use HTTPS and a ShaCraft host: {}",
|
||||
file.path
|
||||
)));
|
||||
}
|
||||
if file.size == 0 {
|
||||
return Err(ManifestError::Invalid(format!("File has zero size: {}", file.path)));
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"File has zero size: {}",
|
||||
file.path
|
||||
)));
|
||||
}
|
||||
if file.sha256.len() != 64 || !file.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||||
return Err(ManifestError::Invalid(format!("Invalid SHA-256 for {}", file.path)));
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"Invalid SHA-256 for {}",
|
||||
file.path
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -124,22 +147,49 @@ fn validate(manifest: &Manifest) -> Result<(), ManifestError> {
|
||||
fn is_identifier(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 48
|
||||
&& value.bytes().all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
|
||||
}
|
||||
|
||||
fn is_version(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 128
|
||||
&& value != "."
|
||||
&& value != ".."
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || b".-_".contains(&byte))
|
||||
}
|
||||
|
||||
fn is_safe_relative_path(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& !value.starts_with('/')
|
||||
&& !value.starts_with('\\')
|
||||
&& !value.contains('\\')
|
||||
&& !value.split('/').any(|part| part.is_empty() || part == "." || part == "..")
|
||||
!value.is_empty() && value.split('/').all(is_portable_component)
|
||||
}
|
||||
|
||||
pub(crate) fn is_portable_component(value: &str) -> bool {
|
||||
if value.is_empty()
|
||||
|| value.ends_with(['.', ' '])
|
||||
|| value
|
||||
.chars()
|
||||
.any(|ch| ch.is_control() || "\\:<>\"|?*".contains(ch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let stem = value
|
||||
.split('.')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_ascii_uppercase();
|
||||
!matches!(
|
||||
stem.as_str(),
|
||||
"CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$"
|
||||
) && !(stem.len() == 4
|
||||
&& (stem.starts_with("COM") || stem.starts_with("LPT"))
|
||||
&& matches!(stem.as_bytes()[3], b'1'..=b'9'))
|
||||
}
|
||||
|
||||
pub(crate) fn is_allowed_download_url(value: &str) -> bool {
|
||||
let Ok(url) = Url::parse(value) else {
|
||||
return false;
|
||||
};
|
||||
url.scheme() == "https" && url.host_str().is_some_and(|host| DOWNLOAD_HOSTS.contains(&host))
|
||||
crate::trusted_http::allows(value, &DOWNLOAD_HOSTS)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -179,4 +229,35 @@ mod tests {
|
||||
fn rejects_third_party_download_hosts() {
|
||||
assert!(validate_json(&VALID.replace("cdn.shacraft.ru", "example.com")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_nonportable_paths_and_version_traversal() {
|
||||
for path in [
|
||||
"C:/escape.jar",
|
||||
"mods/file.jar:stream",
|
||||
"mods/CON.jar",
|
||||
"mods/LPT1",
|
||||
"mods/file.jar.",
|
||||
"mods/file.jar ",
|
||||
"mods//file.jar",
|
||||
"mods/../file.jar",
|
||||
] {
|
||||
assert!(
|
||||
validate_json(&VALID.replace("mods/example.jar", path)).is_err(),
|
||||
"{path}"
|
||||
);
|
||||
}
|
||||
assert!(validate_json(&VALID.replace("21.1.248", "../../escape")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_ambiguous_download_authorities() {
|
||||
for host in [
|
||||
"user@cdn.shacraft.ru",
|
||||
"cdn.shacraft.ru:444",
|
||||
"cdn.shacraft.ru.evil.example",
|
||||
] {
|
||||
assert!(validate_json(&VALID.replace("cdn.shacraft.ru", host)).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+213
-42
@@ -25,9 +25,9 @@ use std::{
|
||||
Arc, Mutex,
|
||||
},
|
||||
};
|
||||
use url::Url;
|
||||
|
||||
const VERSION_MANIFEST_URL: &str = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json";
|
||||
const VERSION_MANIFEST_URL: &str =
|
||||
"https://piston-meta.mojang.com/mc/game/version_manifest_v2.json";
|
||||
const MOJANG_HOSTS: [&str; 4] = [
|
||||
"piston-meta.mojang.com",
|
||||
"piston-data.mojang.com",
|
||||
@@ -41,14 +41,34 @@ const MOJANG_HOSTS: [&str; 4] = [
|
||||
const ASSET_WORKERS: usize = 48;
|
||||
|
||||
pub fn is_allowed_host(url: &str) -> bool {
|
||||
Url::parse(url).ok().and_then(|parsed| parsed.host_str().map(|host| MOJANG_HOSTS.contains(&host))).unwrap_or(false)
|
||||
crate::trusted_http::allows(url, &MOJANG_HOSTS)
|
||||
}
|
||||
|
||||
pub fn http_client() -> Result<Client, reqwest::Error> {
|
||||
crate::trusted_http::client(&MOJANG_HOSTS, std::time::Duration::from_secs(10 * 60))
|
||||
}
|
||||
|
||||
/// The verified merged profile can contain both Mojang and NeoForge artifacts.
|
||||
/// This broader client is used only for that library list, never metadata.
|
||||
pub fn library_http_client() -> Result<Client, reqwest::Error> {
|
||||
crate::trusted_http::client(
|
||||
&[
|
||||
MOJANG_HOSTS[0],
|
||||
MOJANG_HOSTS[1],
|
||||
MOJANG_HOSTS[2],
|
||||
MOJANG_HOSTS[3],
|
||||
crate::neoforge::NEOFORGE_HOST,
|
||||
crate::fabric::MAVEN_HOST,
|
||||
],
|
||||
std::time::Duration::from_secs(10 * 60),
|
||||
)
|
||||
}
|
||||
|
||||
/// Library entries in a merged loader profile may point at the loader's
|
||||
/// own fixed Maven. The profile itself comes from the SHA-256-verified
|
||||
/// NeoForge installer, never from the ShaCraft manifest.
|
||||
fn is_allowed_library_host(url: &str) -> bool {
|
||||
is_allowed_host(url) || crate::neoforge::is_allowed_host(url)
|
||||
is_allowed_host(url) || crate::neoforge::is_allowed_host(url) || crate::trusted_http::allows(url, &[crate::fabric::MAVEN_HOST])
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -59,6 +79,7 @@ pub enum MojangError {
|
||||
ChecksumMismatch(String),
|
||||
DisallowedHost(String),
|
||||
MissingField(String),
|
||||
ConflictingLibrary(String),
|
||||
Download(DownloadError),
|
||||
Io(io::Error),
|
||||
}
|
||||
@@ -70,8 +91,14 @@ impl fmt::Display for MojangError {
|
||||
Self::HttpStatus(status) => write!(formatter, "Mojang returned {status}"),
|
||||
Self::InvalidJson(error) => write!(formatter, "invalid Mojang JSON: {error}"),
|
||||
Self::ChecksumMismatch(context) => write!(formatter, "checksum mismatch for {context}"),
|
||||
Self::DisallowedHost(url) => write!(formatter, "URL is not a recognised Mojang host: {url}"),
|
||||
Self::DisallowedHost(url) => {
|
||||
write!(formatter, "URL is not a recognised Mojang host: {url}")
|
||||
}
|
||||
Self::MissingField(field) => write!(formatter, "version JSON is missing {field}"),
|
||||
Self::ConflictingLibrary(path) => write!(
|
||||
formatter,
|
||||
"merged version contains conflicting library entries for {path}"
|
||||
),
|
||||
Self::Download(error) => write!(formatter, "{error}"),
|
||||
Self::Io(error) => write!(formatter, "I/O error: {error}"),
|
||||
}
|
||||
@@ -110,7 +137,10 @@ pub fn fetch_version_manifest(client: &Client) -> Result<VersionManifest, Mojang
|
||||
fetch_json(client, VERSION_MANIFEST_URL, None)
|
||||
}
|
||||
|
||||
pub fn find_version<'a>(manifest: &'a VersionManifest, id: &str) -> Option<&'a VersionManifestEntry> {
|
||||
pub fn find_version<'a>(
|
||||
manifest: &'a VersionManifest,
|
||||
id: &str,
|
||||
) -> Option<&'a VersionManifestEntry> {
|
||||
manifest.versions.iter().find(|entry| entry.id == id)
|
||||
}
|
||||
|
||||
@@ -145,7 +175,10 @@ pub struct Arguments {
|
||||
#[serde(untagged)]
|
||||
pub enum ArgumentValue {
|
||||
Plain(String),
|
||||
Conditional { rules: Vec<Rule>, value: StringOrList },
|
||||
Conditional {
|
||||
rules: Vec<Rule>,
|
||||
value: StringOrList,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
@@ -231,7 +264,11 @@ fn current_os_name() -> &'static str {
|
||||
}
|
||||
|
||||
fn arch_matches(expected: &str) -> bool {
|
||||
let normalized = if expected == "arm64" { "aarch64" } else { expected };
|
||||
let normalized = if expected == "arm64" {
|
||||
"aarch64"
|
||||
} else {
|
||||
expected
|
||||
};
|
||||
normalized == std::env::consts::ARCH
|
||||
}
|
||||
|
||||
@@ -250,7 +287,9 @@ fn os_matches(os: &RuleOs) -> bool {
|
||||
}
|
||||
|
||||
fn features_match(required: &HashMap<String, bool>, active: &HashMap<String, bool>) -> bool {
|
||||
required.iter().all(|(key, value)| active.get(key).copied().unwrap_or(false) == *value)
|
||||
required
|
||||
.iter()
|
||||
.all(|(key, value)| active.get(key).copied().unwrap_or(false) == *value)
|
||||
}
|
||||
|
||||
/// Evaluates a Mojang-style rule list: no rules means always allowed;
|
||||
@@ -265,7 +304,10 @@ pub fn rule_allows(rules: &[Rule], active_features: &HashMap<String, bool>) -> b
|
||||
let mut allowed = false;
|
||||
for rule in rules {
|
||||
let os_ok = rule.os.as_ref().is_none_or(os_matches);
|
||||
let features_ok = rule.features.as_ref().is_none_or(|required| features_match(required, active_features));
|
||||
let features_ok = rule
|
||||
.features
|
||||
.as_ref()
|
||||
.is_none_or(|required| features_match(required, active_features));
|
||||
if os_ok && features_ok {
|
||||
allowed = rule.action == RuleAction::Allow;
|
||||
}
|
||||
@@ -275,7 +317,10 @@ pub fn rule_allows(rules: &[Rule], active_features: &HashMap<String, bool>) -> b
|
||||
|
||||
/// Flattens an argument list into plain strings, dropping conditional
|
||||
/// entries whose rules don't match this platform/feature set.
|
||||
pub fn resolve_arguments(arguments: &[ArgumentValue], active_features: &HashMap<String, bool>) -> Vec<String> {
|
||||
pub fn resolve_arguments(
|
||||
arguments: &[ArgumentValue],
|
||||
active_features: &HashMap<String, bool>,
|
||||
) -> Vec<String> {
|
||||
let mut resolved = Vec::new();
|
||||
for argument in arguments {
|
||||
match argument {
|
||||
@@ -293,11 +338,18 @@ pub fn resolve_arguments(arguments: &[ArgumentValue], active_features: &HashMap<
|
||||
resolved
|
||||
}
|
||||
|
||||
pub fn fetch_version_json(client: &Client, entry: &VersionManifestEntry) -> Result<VersionJson, MojangError> {
|
||||
pub fn fetch_version_json(
|
||||
client: &Client,
|
||||
entry: &VersionManifestEntry,
|
||||
) -> Result<VersionJson, MojangError> {
|
||||
fetch_json(client, &entry.url, Some(&entry.sha1))
|
||||
}
|
||||
|
||||
fn fetch_json<T: DeserializeOwned>(client: &Client, url: &str, expected_sha1: Option<&str>) -> Result<T, MojangError> {
|
||||
fn fetch_json<T: DeserializeOwned>(
|
||||
client: &Client,
|
||||
url: &str,
|
||||
expected_sha1: Option<&str>,
|
||||
) -> Result<T, MojangError> {
|
||||
if !is_allowed_host(url) {
|
||||
return Err(MojangError::DisallowedHost(url.to_string()));
|
||||
}
|
||||
@@ -348,8 +400,14 @@ pub struct MergedVersion {
|
||||
/// the parent's, and its libraries are appended after the parent's.
|
||||
/// `assetIndex`/`downloads.client` always come from the parent, since
|
||||
/// modloader profiles don't redeclare them.
|
||||
pub fn merge_versions(parent: &VersionJson, child: Option<&VersionJson>) -> Result<MergedVersion, MojangError> {
|
||||
let asset_index = parent.asset_index.clone().ok_or_else(|| MojangError::MissingField("assetIndex".into()))?;
|
||||
pub fn merge_versions(
|
||||
parent: &VersionJson,
|
||||
child: Option<&VersionJson>,
|
||||
) -> Result<MergedVersion, MojangError> {
|
||||
let asset_index = parent
|
||||
.asset_index
|
||||
.clone()
|
||||
.ok_or_else(|| MojangError::MissingField("assetIndex".into()))?;
|
||||
let client = parent
|
||||
.downloads
|
||||
.as_ref()
|
||||
@@ -401,34 +459,69 @@ pub fn natives_directory(game_dir: &Path, version_id: &str) -> PathBuf {
|
||||
}
|
||||
|
||||
pub fn client_jar_path(game_dir: &Path, version_id: &str) -> PathBuf {
|
||||
game_dir.join("versions").join(version_id).join(format!("{version_id}.jar"))
|
||||
game_dir
|
||||
.join("versions")
|
||||
.join(version_id)
|
||||
.join(format!("{version_id}.jar"))
|
||||
}
|
||||
|
||||
pub fn ensure_client_jar(client: &Client, game_dir: &Path, version_id: &str, download_ref: &DownloadRef) -> Result<PathBuf, MojangError> {
|
||||
pub fn ensure_client_jar(
|
||||
client: &Client,
|
||||
game_dir: &Path,
|
||||
version_id: &str,
|
||||
download_ref: &DownloadRef,
|
||||
) -> Result<PathBuf, MojangError> {
|
||||
if !is_allowed_host(&download_ref.url) {
|
||||
return Err(MojangError::DisallowedHost(download_ref.url.clone()));
|
||||
}
|
||||
let target = client_jar_path(game_dir, version_id);
|
||||
let checksum = Checksum::Sha1(download_ref.sha1.clone());
|
||||
if !download::is_current(&target, Some(download_ref.size), &checksum)? {
|
||||
download::download_verified(client, &download_ref.url, &target, Some(download_ref.size), &checksum, |_, _| {})?;
|
||||
download::download_verified(
|
||||
client,
|
||||
&download_ref.url,
|
||||
&target,
|
||||
Some(download_ref.size),
|
||||
&checksum,
|
||||
|_, _| {},
|
||||
)?;
|
||||
}
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
/// Downloads every rule-allowed library with a `downloads.artifact`,
|
||||
/// returning the resulting jar paths in the same order as `libraries`.
|
||||
pub fn ensure_libraries(client: &Client, game_dir: &Path, libraries: &[Library], on_progress: &ProgressCallback) -> Result<Vec<PathBuf>, MojangError> {
|
||||
pub fn ensure_libraries(
|
||||
client: &Client,
|
||||
game_dir: &Path,
|
||||
libraries: &[Library],
|
||||
on_progress: &ProgressCallback,
|
||||
) -> Result<Vec<PathBuf>, MojangError> {
|
||||
let mut paths = Vec::new();
|
||||
let mut tasks = Vec::new();
|
||||
let mut seen: HashMap<PathBuf, (String, u64, String)> = HashMap::new();
|
||||
for library in libraries {
|
||||
if !rule_allows(&library.rules, &HashMap::new()) {
|
||||
continue;
|
||||
}
|
||||
let Some(artifact) = library.downloads.as_ref().and_then(|downloads| downloads.artifact.as_ref()) else {
|
||||
let Some(artifact) = library
|
||||
.downloads
|
||||
.as_ref()
|
||||
.and_then(|downloads| downloads.artifact.as_ref())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let target = game_dir.join("libraries").join(&artifact.path);
|
||||
let identity = (artifact.url.clone(), artifact.size, artifact.sha1.clone());
|
||||
if let Some(existing) = seen.get(&target) {
|
||||
if existing != &identity {
|
||||
return Err(MojangError::ConflictingLibrary(
|
||||
target.display().to_string(),
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
seen.insert(target.clone(), identity);
|
||||
paths.push(target.clone());
|
||||
tasks.push(DownloadTask {
|
||||
url: artifact.url.clone(),
|
||||
@@ -452,20 +545,39 @@ pub struct AssetObject {
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
pub fn ensure_asset_index(client: &Client, game_dir: &Path, asset_index: &AssetIndexRef) -> Result<AssetIndex, MojangError> {
|
||||
pub fn ensure_asset_index(
|
||||
client: &Client,
|
||||
game_dir: &Path,
|
||||
asset_index: &AssetIndexRef,
|
||||
) -> Result<AssetIndex, MojangError> {
|
||||
if !is_allowed_host(&asset_index.url) {
|
||||
return Err(MojangError::DisallowedHost(asset_index.url.clone()));
|
||||
}
|
||||
let target = game_dir.join("assets").join("indexes").join(format!("{}.json", asset_index.id));
|
||||
let target = game_dir
|
||||
.join("assets")
|
||||
.join("indexes")
|
||||
.join(format!("{}.json", asset_index.id));
|
||||
let checksum = Checksum::Sha1(asset_index.sha1.clone());
|
||||
if !download::is_current(&target, Some(asset_index.size), &checksum)? {
|
||||
download::download_verified(client, &asset_index.url, &target, Some(asset_index.size), &checksum, |_, _| {})?;
|
||||
download::download_verified(
|
||||
client,
|
||||
&asset_index.url,
|
||||
&target,
|
||||
Some(asset_index.size),
|
||||
&checksum,
|
||||
|_, _| {},
|
||||
)?;
|
||||
}
|
||||
let bytes = fs::read(&target)?;
|
||||
serde_json::from_slice(&bytes).map_err(MojangError::InvalidJson)
|
||||
}
|
||||
|
||||
pub fn ensure_assets(client: &Client, game_dir: &Path, index: &AssetIndex, on_progress: &ProgressCallback) -> Result<(), MojangError> {
|
||||
pub fn ensure_assets(
|
||||
client: &Client,
|
||||
game_dir: &Path,
|
||||
index: &AssetIndex,
|
||||
on_progress: &ProgressCallback,
|
||||
) -> Result<(), MojangError> {
|
||||
let objects_dir = game_dir.join("assets").join("objects");
|
||||
let tasks = index
|
||||
.objects
|
||||
@@ -473,7 +585,10 @@ pub fn ensure_assets(client: &Client, game_dir: &Path, index: &AssetIndex, on_pr
|
||||
.map(|object| {
|
||||
let prefix = &object.hash[0..2];
|
||||
DownloadTask {
|
||||
url: format!("https://resources.download.minecraft.net/{prefix}/{}", object.hash),
|
||||
url: format!(
|
||||
"https://resources.download.minecraft.net/{prefix}/{}",
|
||||
object.hash
|
||||
),
|
||||
target: objects_dir.join(prefix).join(&object.hash),
|
||||
size: object.size,
|
||||
checksum: Checksum::Sha1(object.hash.clone()),
|
||||
@@ -500,7 +615,14 @@ const MAX_DOWNLOAD_ATTEMPTS: u32 = 5;
|
||||
fn download_with_retries(client: &Client, task: &DownloadTask) -> Result<u64, DownloadError> {
|
||||
let mut last_error = None;
|
||||
for attempt in 1..=MAX_DOWNLOAD_ATTEMPTS {
|
||||
match download::download_verified(client, &task.url, &task.target, Some(task.size), &task.checksum, |_, _| {}) {
|
||||
match download::download_verified(
|
||||
client,
|
||||
&task.url,
|
||||
&task.target,
|
||||
Some(task.size),
|
||||
&task.checksum,
|
||||
|_, _| {},
|
||||
) {
|
||||
Ok(bytes) => return Ok(bytes),
|
||||
Err(error) => {
|
||||
last_error = Some(error);
|
||||
@@ -540,12 +662,16 @@ fn download_many(
|
||||
if first_error.lock().unwrap().is_some() {
|
||||
break;
|
||||
}
|
||||
let Some(task) = queue.lock().unwrap().pop() else { break };
|
||||
let Some(task) = queue.lock().unwrap().pop() else {
|
||||
break;
|
||||
};
|
||||
if !is_allowed(&task.url) {
|
||||
*first_error.lock().unwrap() = Some(MojangError::DisallowedHost(task.url));
|
||||
continue;
|
||||
}
|
||||
let already_current = download::is_current(&task.target, Some(task.size), &task.checksum).unwrap_or(false);
|
||||
let already_current =
|
||||
download::is_current(&task.target, Some(task.size), &task.checksum)
|
||||
.unwrap_or(false);
|
||||
if !already_current {
|
||||
if let Err(error) = download_with_retries(client, &task) {
|
||||
*first_error.lock().unwrap() = Some(MojangError::Download(error));
|
||||
@@ -571,7 +697,10 @@ mod tests {
|
||||
fn rule(action: RuleAction, os_name: Option<&str>) -> Rule {
|
||||
Rule {
|
||||
action,
|
||||
os: os_name.map(|name| RuleOs { name: Some(name.into()), arch: None }),
|
||||
os: os_name.map(|name| RuleOs {
|
||||
name: Some(name.into()),
|
||||
arch: None,
|
||||
}),
|
||||
features: None,
|
||||
}
|
||||
}
|
||||
@@ -589,7 +718,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn non_matching_os_rule_disallows() {
|
||||
let other = if current_os_name() == "windows" { "linux" } else { "windows" };
|
||||
let other = if current_os_name() == "windows" {
|
||||
"linux"
|
||||
} else {
|
||||
"windows"
|
||||
};
|
||||
let rules = vec![rule(RuleAction::Allow, Some(other))];
|
||||
assert!(!rule_allows(&rules, &HashMap::new()));
|
||||
}
|
||||
@@ -598,7 +731,11 @@ mod tests {
|
||||
fn unsupported_feature_is_excluded_by_default() {
|
||||
let mut features = HashMap::new();
|
||||
features.insert("is_demo_user".to_string(), true);
|
||||
let rules = vec![Rule { action: RuleAction::Allow, os: None, features: Some(features) }];
|
||||
let rules = vec![Rule {
|
||||
action: RuleAction::Allow,
|
||||
os: None,
|
||||
features: Some(features),
|
||||
}];
|
||||
// We never activate optional features, so a rule requiring one
|
||||
// must not match even though there's no OS constraint.
|
||||
assert!(!rule_allows(&rules, &HashMap::new()));
|
||||
@@ -619,7 +756,10 @@ mod tests {
|
||||
},
|
||||
];
|
||||
let resolved = resolve_arguments(&args, &HashMap::new());
|
||||
assert_eq!(resolved, vec!["--username", "${auth_player_name}", "--this-os-only"]);
|
||||
assert_eq!(
|
||||
resolved,
|
||||
vec!["--username", "${auth_player_name}", "--this-os-only"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -649,18 +789,38 @@ mod tests {
|
||||
let merged = merge_versions(&parent, Some(&child)).unwrap();
|
||||
assert_eq!(merged.id, "neoforge-21.1.248");
|
||||
assert_eq!(merged.client_jar_version_id, "1.21.1");
|
||||
assert_eq!(merged.main_class, "cpw.mods.bootstraplauncher.BootstrapLauncher");
|
||||
assert_eq!(resolve_arguments(&merged.game_arguments, &HashMap::new()), vec!["--parentGame", "--childGame"]);
|
||||
assert_eq!(resolve_arguments(&merged.jvm_arguments, &HashMap::new()), vec!["--parentJvm", "--childJvm"]);
|
||||
assert_eq!(merged.libraries.iter().map(|library| library.name.as_str()).collect::<Vec<_>>(), vec!["parent:lib:1", "child:lib:1"]);
|
||||
assert_eq!(
|
||||
merged.main_class,
|
||||
"cpw.mods.bootstraplauncher.BootstrapLauncher"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_arguments(&merged.game_arguments, &HashMap::new()),
|
||||
vec!["--parentGame", "--childGame"]
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_arguments(&merged.jvm_arguments, &HashMap::new()),
|
||||
vec!["--parentJvm", "--childJvm"]
|
||||
);
|
||||
assert_eq!(
|
||||
merged
|
||||
.libraries
|
||||
.iter()
|
||||
.map(|library| library.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["parent:lib:1", "child:lib:1"]
|
||||
);
|
||||
assert_eq!(merged.asset_index.id, "17");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disallowed_host_is_rejected() {
|
||||
assert!(!is_allowed_host("https://example.com/evil.jar"));
|
||||
assert!(is_allowed_host("https://piston-data.mojang.com/v1/objects/x/client.jar"));
|
||||
assert!(is_allowed_library_host("https://maven.neoforged.net/releases/net/neoforged/example.jar"));
|
||||
assert!(is_allowed_host(
|
||||
"https://piston-data.mojang.com/v1/objects/x/client.jar"
|
||||
));
|
||||
assert!(is_allowed_library_host(
|
||||
"https://maven.neoforged.net/releases/net/neoforged/example.jar"
|
||||
));
|
||||
assert!(!is_allowed_library_host("https://example.com/evil.jar"));
|
||||
}
|
||||
|
||||
@@ -677,7 +837,8 @@ mod tests {
|
||||
let version = fetch_version_json(&client, entry).unwrap();
|
||||
assert_eq!(version.main_class, "net.minecraft.client.main.Main");
|
||||
|
||||
let game_dir = std::env::temp_dir().join(format!("shacraft-mojang-live-{}", std::process::id()));
|
||||
let game_dir =
|
||||
std::env::temp_dir().join(format!("shacraft-mojang-live-{}", std::process::id()));
|
||||
|
||||
let merged = merge_versions(&version, None).unwrap();
|
||||
let client_jar = ensure_client_jar(&client, &game_dir, &merged.id, &merged.client).unwrap();
|
||||
@@ -689,11 +850,20 @@ mod tests {
|
||||
let mut small_libraries: Vec<Library> = merged
|
||||
.libraries
|
||||
.iter()
|
||||
.filter(|library| library.downloads.as_ref().and_then(|downloads| downloads.artifact.as_ref()).is_some_and(|artifact| artifact.size < 200_000))
|
||||
.filter(|library| {
|
||||
library
|
||||
.downloads
|
||||
.as_ref()
|
||||
.and_then(|downloads| downloads.artifact.as_ref())
|
||||
.is_some_and(|artifact| artifact.size < 200_000)
|
||||
})
|
||||
.take(5)
|
||||
.cloned()
|
||||
.collect();
|
||||
assert!(!small_libraries.is_empty(), "expected at least one small library to sanity-check downloads with");
|
||||
assert!(
|
||||
!small_libraries.is_empty(),
|
||||
"expected at least one small library to sanity-check downloads with"
|
||||
);
|
||||
small_libraries.truncate(5);
|
||||
let progress: ProgressCallback = Arc::new(|_, _| {});
|
||||
let paths = ensure_libraries(&client, &game_dir, &small_libraries, &progress).unwrap();
|
||||
@@ -703,7 +873,8 @@ mod tests {
|
||||
|
||||
// Re-running against already-downloaded files must be a no-op (the
|
||||
// `is_current` fast path), not re-download or fail.
|
||||
let client_jar_again = ensure_client_jar(&client, &game_dir, &merged.id, &merged.client).unwrap();
|
||||
let client_jar_again =
|
||||
ensure_client_jar(&client, &game_dir, &merged.id, &merged.client).unwrap();
|
||||
assert_eq!(client_jar, client_jar_again);
|
||||
|
||||
fs::remove_dir_all(&game_dir).ok();
|
||||
|
||||
+133
-37
@@ -41,10 +41,23 @@ const DEVICE_CODE_URL: &str = "https://login.microsoftonline.com/consumers/oauth
|
||||
const TOKEN_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token";
|
||||
const XBOX_USER_AUTH_URL: &str = "https://user.auth.xboxlive.com/user/authenticate";
|
||||
const XSTS_AUTHORIZE_URL: &str = "https://xsts.auth.xboxlive.com/xsts/authorize";
|
||||
const MINECRAFT_LOGIN_URL: &str = "https://api.minecraftservices.com/authentication/login_with_xbox";
|
||||
const MINECRAFT_LOGIN_URL: &str =
|
||||
"https://api.minecraftservices.com/authentication/login_with_xbox";
|
||||
const MINECRAFT_PROFILE_URL: &str = "https://api.minecraftservices.com/minecraft/profile";
|
||||
const ACCOUNT_FILE: &str = "account.json";
|
||||
|
||||
pub fn http_client() -> Result<Client, reqwest::Error> {
|
||||
crate::trusted_http::client(
|
||||
&[
|
||||
"login.microsoftonline.com",
|
||||
"user.auth.xboxlive.com",
|
||||
"xsts.auth.xboxlive.com",
|
||||
"api.minecraftservices.com",
|
||||
],
|
||||
Duration::from_secs(30),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum MsaError {
|
||||
NotConfigured,
|
||||
@@ -111,7 +124,10 @@ pub fn start_device_code(client: &Client) -> Result<DeviceCodeStart, MsaError> {
|
||||
}
|
||||
let response = client
|
||||
.post(DEVICE_CODE_URL)
|
||||
.form(&[("client_id", MSA_CLIENT_ID), ("scope", "XboxLive.signin offline_access")])
|
||||
.form(&[
|
||||
("client_id", MSA_CLIENT_ID),
|
||||
("scope", "XboxLive.signin offline_access"),
|
||||
])
|
||||
.send()
|
||||
.map_err(MsaError::Network)?;
|
||||
if !response.status().is_success() {
|
||||
@@ -144,7 +160,10 @@ struct TokenResponse {
|
||||
/// decline. This is the slow step in the whole login flow — the caller
|
||||
/// should already have shown `verification_uri`/`user_code` to the user
|
||||
/// before calling this (see `start_device_code`).
|
||||
pub fn poll_device_code(client: &Client, start: &DeviceCodeStart) -> Result<MicrosoftTokens, MsaError> {
|
||||
pub fn poll_device_code(
|
||||
client: &Client,
|
||||
start: &DeviceCodeStart,
|
||||
) -> Result<MicrosoftTokens, MsaError> {
|
||||
let deadline = Instant::now() + Duration::from_secs(start.expires_in_seconds);
|
||||
let mut interval = Duration::from_secs(start.interval_seconds);
|
||||
|
||||
@@ -167,10 +186,16 @@ pub fn poll_device_code(client: &Client, start: &DeviceCodeStart) -> Result<Micr
|
||||
let body: TokenResponse = response.json().map_err(MsaError::Network)?;
|
||||
|
||||
if status.is_success() {
|
||||
let (Some(access_token), Some(refresh_token)) = (body.access_token, body.refresh_token) else {
|
||||
return Err(MsaError::UnexpectedResponse("token response missing access_token/refresh_token".into()));
|
||||
let (Some(access_token), Some(refresh_token)) = (body.access_token, body.refresh_token)
|
||||
else {
|
||||
return Err(MsaError::UnexpectedResponse(
|
||||
"token response missing access_token/refresh_token".into(),
|
||||
));
|
||||
};
|
||||
return Ok(MicrosoftTokens { access_token, refresh_token });
|
||||
return Ok(MicrosoftTokens {
|
||||
access_token,
|
||||
refresh_token,
|
||||
});
|
||||
}
|
||||
|
||||
match body.error.as_deref() {
|
||||
@@ -181,12 +206,19 @@ pub fn poll_device_code(client: &Client, start: &DeviceCodeStart) -> Result<Micr
|
||||
}
|
||||
Some("authorization_declined") => return Err(MsaError::AuthorizationDeclined),
|
||||
Some("expired_token") => return Err(MsaError::AuthorizationExpired),
|
||||
other => return Err(MsaError::UnexpectedResponse(other.unwrap_or("unknown device code error").into())),
|
||||
other => {
|
||||
return Err(MsaError::UnexpectedResponse(
|
||||
other.unwrap_or("unknown device code error").into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn refresh_microsoft_tokens(client: &Client, refresh_token: &str) -> Result<MicrosoftTokens, MsaError> {
|
||||
pub fn refresh_microsoft_tokens(
|
||||
client: &Client,
|
||||
refresh_token: &str,
|
||||
) -> Result<MicrosoftTokens, MsaError> {
|
||||
if !is_configured() {
|
||||
return Err(MsaError::NotConfigured);
|
||||
}
|
||||
@@ -205,9 +237,14 @@ pub fn refresh_microsoft_tokens(client: &Client, refresh_token: &str) -> Result<
|
||||
}
|
||||
let body: TokenResponse = response.json().map_err(MsaError::Network)?;
|
||||
let (Some(access_token), Some(refresh_token)) = (body.access_token, body.refresh_token) else {
|
||||
return Err(MsaError::UnexpectedResponse("refresh response missing access_token/refresh_token".into()));
|
||||
return Err(MsaError::UnexpectedResponse(
|
||||
"refresh response missing access_token/refresh_token".into(),
|
||||
));
|
||||
};
|
||||
Ok(MicrosoftTokens { access_token, refresh_token })
|
||||
Ok(MicrosoftTokens {
|
||||
access_token,
|
||||
refresh_token,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -274,7 +311,10 @@ struct XboxUserHash {
|
||||
xid: Option<String>,
|
||||
}
|
||||
|
||||
fn xbox_live_user_token(client: &Client, microsoft_access_token: &str) -> Result<(String, String), MsaError> {
|
||||
fn xbox_live_user_token(
|
||||
client: &Client,
|
||||
microsoft_access_token: &str,
|
||||
) -> Result<(String, String), MsaError> {
|
||||
let request = XboxUserAuthRequest {
|
||||
properties: XboxUserAuthProperties {
|
||||
auth_method: "RPS",
|
||||
@@ -284,22 +324,42 @@ fn xbox_live_user_token(client: &Client, microsoft_access_token: &str) -> Result
|
||||
relying_party: "http://auth.xboxlive.com",
|
||||
token_type: "JWT",
|
||||
};
|
||||
let response = client.post(XBOX_USER_AUTH_URL).json(&request).send().map_err(MsaError::Network)?;
|
||||
let response = client
|
||||
.post(XBOX_USER_AUTH_URL)
|
||||
.json(&request)
|
||||
.send()
|
||||
.map_err(MsaError::Network)?;
|
||||
if !response.status().is_success() {
|
||||
return Err(MsaError::HttpStatus(response.status()));
|
||||
}
|
||||
let body: XboxTokenResponse = response.json().map_err(MsaError::Network)?;
|
||||
let uhs = body.display_claims.xui.into_iter().next().map(|claim| claim.uhs).ok_or_else(|| MsaError::UnexpectedResponse("missing uhs".into()))?;
|
||||
let uhs = body
|
||||
.display_claims
|
||||
.xui
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|claim| claim.uhs)
|
||||
.ok_or_else(|| MsaError::UnexpectedResponse("missing uhs".into()))?;
|
||||
Ok((body.token, uhs))
|
||||
}
|
||||
|
||||
fn xsts_authorize(client: &Client, xbox_live_token: &str) -> Result<(String, String, Option<String>), MsaError> {
|
||||
fn xsts_authorize(
|
||||
client: &Client,
|
||||
xbox_live_token: &str,
|
||||
) -> Result<(String, String, Option<String>), MsaError> {
|
||||
let request = XstsRequest {
|
||||
properties: XstsProperties { sandbox_id: "RETAIL", user_tokens: [xbox_live_token] },
|
||||
properties: XstsProperties {
|
||||
sandbox_id: "RETAIL",
|
||||
user_tokens: [xbox_live_token],
|
||||
},
|
||||
relying_party: "rp://api.minecraftservices.com/",
|
||||
token_type: "JWT",
|
||||
};
|
||||
let response = client.post(XSTS_AUTHORIZE_URL).json(&request).send().map_err(MsaError::Network)?;
|
||||
let response = client
|
||||
.post(XSTS_AUTHORIZE_URL)
|
||||
.json(&request)
|
||||
.send()
|
||||
.map_err(MsaError::Network)?;
|
||||
let status = response.status();
|
||||
if status.as_u16() == 401 {
|
||||
// XErr 2148916233 means the account has no Xbox profile at all
|
||||
@@ -312,7 +372,12 @@ fn xsts_authorize(client: &Client, xbox_live_token: &str) -> Result<(String, Str
|
||||
return Err(MsaError::HttpStatus(status));
|
||||
}
|
||||
let body: XboxTokenResponse = response.json().map_err(MsaError::Network)?;
|
||||
let claim = body.display_claims.xui.into_iter().next().ok_or_else(|| MsaError::UnexpectedResponse("missing uhs".into()))?;
|
||||
let claim = body
|
||||
.display_claims
|
||||
.xui
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| MsaError::UnexpectedResponse("missing uhs".into()))?;
|
||||
Ok((body.token, claim.uhs, claim.xid))
|
||||
}
|
||||
|
||||
@@ -328,8 +393,14 @@ struct MinecraftLoginResponse {
|
||||
}
|
||||
|
||||
fn minecraft_login(client: &Client, user_hash: &str, xsts_token: &str) -> Result<String, MsaError> {
|
||||
let request = MinecraftLoginRequest { identity_token: format!("XBL3.0 x={user_hash};{xsts_token}") };
|
||||
let response = client.post(MINECRAFT_LOGIN_URL).json(&request).send().map_err(MsaError::Network)?;
|
||||
let request = MinecraftLoginRequest {
|
||||
identity_token: format!("XBL3.0 x={user_hash};{xsts_token}"),
|
||||
};
|
||||
let response = client
|
||||
.post(MINECRAFT_LOGIN_URL)
|
||||
.json(&request)
|
||||
.send()
|
||||
.map_err(MsaError::Network)?;
|
||||
if !response.status().is_success() {
|
||||
return Err(MsaError::HttpStatus(response.status()));
|
||||
}
|
||||
@@ -347,7 +418,10 @@ pub struct MinecraftProfile {
|
||||
/// Confirms game ownership. A 404 here means the account has no Java
|
||||
/// Edition profile — i.e. doesn't own the game — and nothing should
|
||||
/// install or launch.
|
||||
fn fetch_minecraft_profile(client: &Client, minecraft_access_token: &str) -> Result<MinecraftProfile, MsaError> {
|
||||
fn fetch_minecraft_profile(
|
||||
client: &Client,
|
||||
minecraft_access_token: &str,
|
||||
) -> Result<MinecraftProfile, MsaError> {
|
||||
let response = client
|
||||
.get(MINECRAFT_PROFILE_URL)
|
||||
.bearer_auth(minecraft_access_token)
|
||||
@@ -376,15 +450,26 @@ fn complete_login(client: &Client, tokens: MicrosoftTokens) -> Result<LoginResul
|
||||
let (xsts_token, user_hash, xuid) = xsts_authorize(client, &xbox_live_token)?;
|
||||
let minecraft_access_token = minecraft_login(client, &user_hash, &xsts_token)?;
|
||||
let profile = fetch_minecraft_profile(client, &minecraft_access_token)?;
|
||||
Ok(LoginResult { minecraft_access_token, profile, refresh_token: tokens.refresh_token, xuid })
|
||||
Ok(LoginResult {
|
||||
minecraft_access_token,
|
||||
profile,
|
||||
refresh_token: tokens.refresh_token,
|
||||
xuid,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn login_with_device_code(client: &Client, start: &DeviceCodeStart) -> Result<LoginResult, MsaError> {
|
||||
pub fn login_with_device_code(
|
||||
client: &Client,
|
||||
start: &DeviceCodeStart,
|
||||
) -> Result<LoginResult, MsaError> {
|
||||
let tokens = poll_device_code(client, start)?;
|
||||
complete_login(client, tokens)
|
||||
}
|
||||
|
||||
pub fn login_with_refresh_token(client: &Client, refresh_token: &str) -> Result<LoginResult, MsaError> {
|
||||
pub fn login_with_refresh_token(
|
||||
client: &Client,
|
||||
refresh_token: &str,
|
||||
) -> Result<LoginResult, MsaError> {
|
||||
let tokens = refresh_microsoft_tokens(client, refresh_token)?;
|
||||
complete_login(client, tokens)
|
||||
}
|
||||
@@ -401,18 +486,18 @@ struct StoredAccount {
|
||||
|
||||
pub fn save_refresh_token(data_dir: &Path, refresh_token: &str) -> io::Result<()> {
|
||||
fs::create_dir_all(data_dir)?;
|
||||
let saved_at_unix = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||
let contents = serde_json::to_vec_pretty(&StoredAccount { refresh_token: refresh_token.to_string(), saved_at_unix }).expect("StoredAccount is serializable");
|
||||
let saved_at_unix = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let contents = serde_json::to_vec_pretty(&StoredAccount {
|
||||
refresh_token: refresh_token.to_string(),
|
||||
saved_at_unix,
|
||||
})
|
||||
.expect("StoredAccount is serializable");
|
||||
|
||||
let target = data_dir.join(ACCOUNT_FILE);
|
||||
let temporary = data_dir.join(".account.json.shacraft.part");
|
||||
fs::write(&temporary, contents)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
fs::rename(temporary, target)
|
||||
crate::storage::write_atomic(&target, &contents)
|
||||
}
|
||||
|
||||
pub fn load_refresh_token(data_dir: &Path) -> Option<String> {
|
||||
@@ -438,7 +523,10 @@ mod tests {
|
||||
let dir = std::env::temp_dir().join(format!("shacraft-msa-test-{}", std::process::id()));
|
||||
assert!(load_refresh_token(&dir).is_none());
|
||||
save_refresh_token(&dir, "super-secret-refresh-token").unwrap();
|
||||
assert_eq!(load_refresh_token(&dir).as_deref(), Some("super-secret-refresh-token"));
|
||||
assert_eq!(
|
||||
load_refresh_token(&dir).as_deref(),
|
||||
Some("super-secret-refresh-token")
|
||||
);
|
||||
clear_account(&dir).unwrap();
|
||||
assert!(load_refresh_token(&dir).is_none());
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
@@ -448,9 +536,14 @@ mod tests {
|
||||
#[test]
|
||||
fn stored_account_file_is_not_world_or_group_readable() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = std::env::temp_dir().join(format!("shacraft-msa-perm-test-{}", std::process::id()));
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("shacraft-msa-perm-test-{}", std::process::id()));
|
||||
save_refresh_token(&dir, "secret").unwrap();
|
||||
let mode = fs::metadata(dir.join(ACCOUNT_FILE)).unwrap().permissions().mode() & 0o777;
|
||||
let mode = fs::metadata(dir.join(ACCOUNT_FILE))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777;
|
||||
assert_eq!(mode, 0o600);
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
@@ -459,7 +552,10 @@ mod tests {
|
||||
fn refuses_to_run_with_placeholder_client_id() {
|
||||
assert!(!is_configured());
|
||||
let client = Client::builder().build().unwrap();
|
||||
assert!(matches!(start_device_code(&client), Err(MsaError::NotConfigured)));
|
||||
assert!(matches!(
|
||||
start_device_code(&client),
|
||||
Err(MsaError::NotConfigured)
|
||||
));
|
||||
}
|
||||
|
||||
/// Live smoke test: requests a real device code from Microsoft and
|
||||
|
||||
+237
-41
@@ -39,9 +39,8 @@ use std::{
|
||||
},
|
||||
thread,
|
||||
};
|
||||
use url::Url;
|
||||
|
||||
const NEOFORGE_HOST: &str = "maven.neoforged.net";
|
||||
pub(crate) const NEOFORGE_HOST: &str = "maven.neoforged.net";
|
||||
|
||||
/// Minimal `launcher_profiles.json` accepted by the legacy NeoForge/Forge
|
||||
/// installer as proof that a directory is a legitimate launcher data
|
||||
@@ -57,21 +56,34 @@ pub enum NeoForgeError {
|
||||
Download(DownloadError),
|
||||
Io(io::Error),
|
||||
InvalidJson(serde_json::Error),
|
||||
InstallerFailed { exit_code: Option<i32>, output_tail: String },
|
||||
InstallerFailed {
|
||||
exit_code: Option<i32>,
|
||||
output_tail: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl fmt::Display for NeoForgeError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::DisallowedHost(url) => write!(formatter, "URL is not a recognised NeoForge host: {url}"),
|
||||
Self::DisallowedHost(url) => {
|
||||
write!(formatter, "URL is not a recognised NeoForge host: {url}")
|
||||
}
|
||||
Self::Network(error) => write!(formatter, "network error: {error}"),
|
||||
Self::HttpStatus(status) => write!(formatter, "maven.neoforged.net returned {status}"),
|
||||
Self::InvalidChecksum(text) => write!(formatter, "unexpected checksum response: {text}"),
|
||||
Self::InvalidChecksum(text) => {
|
||||
write!(formatter, "unexpected checksum response: {text}")
|
||||
}
|
||||
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::InstallerFailed { exit_code, output_tail } => {
|
||||
write!(formatter, "NeoForge installer failed (exit {exit_code:?}):\n{output_tail}")
|
||||
Self::InstallerFailed {
|
||||
exit_code,
|
||||
output_tail,
|
||||
} => {
|
||||
write!(
|
||||
formatter,
|
||||
"NeoForge installer failed (exit {exit_code:?}):\n{output_tail}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,7 +101,11 @@ impl From<io::Error> for NeoForgeError {
|
||||
}
|
||||
|
||||
pub(crate) fn is_allowed_host(url: &str) -> bool {
|
||||
Url::parse(url).ok().and_then(|parsed| parsed.host_str().map(|host| host == NEOFORGE_HOST)).unwrap_or(false)
|
||||
crate::trusted_http::allows(url, &[NEOFORGE_HOST])
|
||||
}
|
||||
|
||||
pub fn http_client() -> Result<Client, reqwest::Error> {
|
||||
crate::trusted_http::client(&[NEOFORGE_HOST], std::time::Duration::from_secs(10 * 60))
|
||||
}
|
||||
|
||||
fn installer_jar_url(loader_version: &str) -> String {
|
||||
@@ -99,18 +115,29 @@ fn installer_jar_url(loader_version: &str) -> String {
|
||||
/// Downloads (or reuses a cached, still-valid) NeoForge installer jar,
|
||||
/// verified against the `.sha256` sidecar Maven publishes next to every
|
||||
/// artifact.
|
||||
pub fn ensure_installer(client: &Client, cache_dir: &Path, loader_version: &str) -> Result<PathBuf, NeoForgeError> {
|
||||
pub fn ensure_installer(
|
||||
client: &Client,
|
||||
cache_dir: &Path,
|
||||
loader_version: &str,
|
||||
) -> Result<PathBuf, NeoForgeError> {
|
||||
let jar_url = installer_jar_url(loader_version);
|
||||
let checksum_url = format!("{jar_url}.sha256");
|
||||
if !is_allowed_host(&jar_url) {
|
||||
return Err(NeoForgeError::DisallowedHost(jar_url));
|
||||
}
|
||||
|
||||
let response = client.get(&checksum_url).send().map_err(NeoForgeError::Network)?;
|
||||
let response = client
|
||||
.get(&checksum_url)
|
||||
.send()
|
||||
.map_err(NeoForgeError::Network)?;
|
||||
if !response.status().is_success() {
|
||||
return Err(NeoForgeError::HttpStatus(response.status()));
|
||||
}
|
||||
let sha256 = response.text().map_err(NeoForgeError::Network)?.trim().to_ascii_lowercase();
|
||||
let sha256 = response
|
||||
.text()
|
||||
.map_err(NeoForgeError::Network)?
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
if sha256.len() != 64 || !sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||||
return Err(NeoForgeError::InvalidChecksum(sha256));
|
||||
}
|
||||
@@ -139,6 +166,23 @@ 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
|
||||
/// run to patch the client — the same manifest the installer itself reads.
|
||||
@@ -161,7 +205,14 @@ fn read_install_profile_counts(installer_path: &Path) -> Option<(u64, u64)> {
|
||||
/// installer logging a couple of extra non-library downloads) never exceeds
|
||||
/// or exceeds `total` by much. `total_libraries` caps the download half so
|
||||
/// those extra lines cannot crowd out the processor half of the bar.
|
||||
fn observe_installer_line(line: &str, downloads_done: &AtomicU64, processors_done: &AtomicU64, total_libraries: u64, total: u64, on_progress: &ProgressCallback) {
|
||||
fn observe_installer_line(
|
||||
line: &str,
|
||||
downloads_done: &AtomicU64,
|
||||
processors_done: &AtomicU64,
|
||||
total_libraries: u64,
|
||||
total: u64,
|
||||
on_progress: &ProgressCallback,
|
||||
) {
|
||||
let trimmed = line.trim_start();
|
||||
if trimmed.starts_with("Download completed") {
|
||||
downloads_done.fetch_add(1, Ordering::Relaxed);
|
||||
@@ -173,12 +224,19 @@ fn observe_installer_line(line: &str, downloads_done: &AtomicU64, processors_don
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
let current = downloads_done.load(Ordering::Relaxed).min(total_libraries) + processors_done.load(Ordering::Relaxed);
|
||||
let current = downloads_done.load(Ordering::Relaxed).min(total_libraries)
|
||||
+ processors_done.load(Ordering::Relaxed);
|
||||
on_progress(current.min(total), total);
|
||||
}
|
||||
|
||||
fn truncate_tail(text: &str) -> String {
|
||||
text.chars().rev().take(4000).collect::<String>().chars().rev().collect()
|
||||
text.chars()
|
||||
.rev()
|
||||
.take(4000)
|
||||
.collect::<String>()
|
||||
.chars()
|
||||
.rev()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Runs the installer with piped output, reporting live progress as its own
|
||||
@@ -219,7 +277,14 @@ fn run_installer_with_progress(
|
||||
let on_progress = Arc::clone(on_progress);
|
||||
thread::spawn(move || {
|
||||
for line in BufReader::new(stdout).lines().map_while(Result::ok) {
|
||||
observe_installer_line(&line, &downloads_done, &processors_done, total_libraries, total, &on_progress);
|
||||
observe_installer_line(
|
||||
&line,
|
||||
&downloads_done,
|
||||
&processors_done,
|
||||
total_libraries,
|
||||
total,
|
||||
&on_progress,
|
||||
);
|
||||
let mut log = combined_log.lock().unwrap();
|
||||
log.push_str(&line);
|
||||
log.push('\n');
|
||||
@@ -243,7 +308,10 @@ fn run_installer_with_progress(
|
||||
let tail = truncate_tail(&combined_log.lock().unwrap());
|
||||
|
||||
if !status.success() {
|
||||
return Err(NeoForgeError::InstallerFailed { exit_code: status.code(), output_tail: tail });
|
||||
return Err(NeoForgeError::InstallerFailed {
|
||||
exit_code: status.code(),
|
||||
output_tail: tail,
|
||||
});
|
||||
}
|
||||
Ok((status.code(), tail))
|
||||
}
|
||||
@@ -258,19 +326,48 @@ fn run_installer_with_progress(
|
||||
/// 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.
|
||||
pub fn ensure_client_installed(client: &Client, java_executable: &Path, game_dir: &Path, cache_dir: &Path, loader_version: &str, on_progress: &ProgressCallback) -> Result<VersionJson, NeoForgeError> {
|
||||
pub fn ensure_client_installed(
|
||||
client: &Client,
|
||||
java_executable: &Path,
|
||||
game_dir: &Path,
|
||||
cache_dir: &Path,
|
||||
loader_version: &str,
|
||||
on_progress: &ProgressCallback,
|
||||
) -> Result<VersionJson, NeoForgeError> {
|
||||
let version_json_path = installed_version_json_path(game_dir, loader_version);
|
||||
if !version_json_path.exists() {
|
||||
if !installation_complete(game_dir, loader_version) {
|
||||
ensure_launcher_profiles_stub(game_dir)?;
|
||||
let installer_path = ensure_installer(client, cache_dir, loader_version)?;
|
||||
|
||||
let (total_libraries, total_processors) = read_install_profile_counts(&installer_path).unwrap_or((0, 0));
|
||||
// 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 !version_json_path.exists() {
|
||||
return Err(NeoForgeError::InstallerFailed { exit_code, output_tail: tail });
|
||||
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 {
|
||||
@@ -296,12 +393,15 @@ mod tests {
|
||||
#[test]
|
||||
fn rejects_non_neoforge_hosts() {
|
||||
assert!(!is_allowed_host("https://example.com/evil.jar"));
|
||||
assert!(is_allowed_host("https://maven.neoforged.net/releases/x.jar"));
|
||||
assert!(is_allowed_host(
|
||||
"https://maven.neoforged.net/releases/x.jar"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launcher_profiles_stub_is_idempotent() {
|
||||
let dir = std::env::temp_dir().join(format!("shacraft-neoforge-test-{}", std::process::id()));
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("shacraft-neoforge-test-{}", std::process::id()));
|
||||
ensure_launcher_profiles_stub(&dir).unwrap();
|
||||
let first = fs::read_to_string(dir.join("launcher_profiles.json")).unwrap();
|
||||
fs::write(dir.join("launcher_profiles.json"), "custom-content").unwrap();
|
||||
@@ -312,6 +412,25 @@ 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);
|
||||
@@ -326,12 +445,47 @@ mod tests {
|
||||
|
||||
// A "Downloading library from ..." start line reports nothing by
|
||||
// itself; only its "Download completed" confirmation counts.
|
||||
observe_installer_line("Downloading library from https://example/a.jar", &downloads_done, &processors_done, total_libraries, total, &on_progress);
|
||||
observe_installer_line("Download completed: Checksum validated.", &downloads_done, &processors_done, total_libraries, total, &on_progress);
|
||||
observe_installer_line("Download completed: Checksum validated.", &downloads_done, &processors_done, total_libraries, total, &on_progress);
|
||||
observe_installer_line("Processor: net.neoforged.installertools:jarsplitter", &downloads_done, &processors_done, total_libraries, total, &on_progress);
|
||||
observe_installer_line(
|
||||
"Downloading library from https://example/a.jar",
|
||||
&downloads_done,
|
||||
&processors_done,
|
||||
total_libraries,
|
||||
total,
|
||||
&on_progress,
|
||||
);
|
||||
observe_installer_line(
|
||||
"Download completed: Checksum validated.",
|
||||
&downloads_done,
|
||||
&processors_done,
|
||||
total_libraries,
|
||||
total,
|
||||
&on_progress,
|
||||
);
|
||||
observe_installer_line(
|
||||
"Download completed: Checksum validated.",
|
||||
&downloads_done,
|
||||
&processors_done,
|
||||
total_libraries,
|
||||
total,
|
||||
&on_progress,
|
||||
);
|
||||
observe_installer_line(
|
||||
"Processor: net.neoforged.installertools:jarsplitter",
|
||||
&downloads_done,
|
||||
&processors_done,
|
||||
total_libraries,
|
||||
total,
|
||||
&on_progress,
|
||||
);
|
||||
// A processor's sub-step lines (three colons) must not double-count.
|
||||
observe_installer_line("Processor: net.neoforged.installertools:jarsplitter: Loading patch files", &downloads_done, &processors_done, total_libraries, total, &on_progress);
|
||||
observe_installer_line(
|
||||
"Processor: net.neoforged.installertools:jarsplitter: Loading patch files",
|
||||
&downloads_done,
|
||||
&processors_done,
|
||||
total_libraries,
|
||||
total,
|
||||
&on_progress,
|
||||
);
|
||||
|
||||
assert_eq!(*calls.lock().unwrap(), vec![(1, 3), (2, 3), (3, 3)]);
|
||||
}
|
||||
@@ -350,7 +504,8 @@ 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().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();
|
||||
@@ -360,7 +515,8 @@ mod tests {
|
||||
let vanilla = mojang::fetch_version_json(&client, entry).unwrap();
|
||||
|
||||
let no_progress: ProgressCallback = Arc::new(|_, _| {});
|
||||
let java_install = java::ensure_java(&client, &root.join("runtime"), 21, &no_progress).unwrap();
|
||||
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.
|
||||
@@ -369,25 +525,65 @@ mod tests {
|
||||
let progress_calls = Arc::clone(&progress_calls);
|
||||
Arc::new(move |current, total| progress_calls.lock().unwrap().push((current, total)))
|
||||
};
|
||||
let neoforge_version = ensure_client_installed(&client, Path::new(&java_install.executable), &game_dir, &cache_dir, "21.1.248", &progress).unwrap();
|
||||
let neoforge_version = ensure_client_installed(
|
||||
&client,
|
||||
Path::new(&java_install.executable),
|
||||
&game_dir,
|
||||
&cache_dir,
|
||||
"21.1.248",
|
||||
&progress,
|
||||
)
|
||||
.unwrap();
|
||||
let merged = mojang::merge_versions(&vanilla, Some(&neoforge_version)).unwrap();
|
||||
assert_eq!(merged.main_class, "cpw.mods.bootstraplauncher.BootstrapLauncher");
|
||||
assert!(merged.libraries.len() > 100, "expected vanilla (97) + neoforge (47) libraries, got {}", merged.libraries.len());
|
||||
assert_eq!(
|
||||
merged.main_class,
|
||||
"cpw.mods.bootstraplauncher.BootstrapLauncher"
|
||||
);
|
||||
assert!(
|
||||
merged.libraries.len() > 100,
|
||||
"expected vanilla (97) + neoforge (47) libraries, got {}",
|
||||
merged.libraries.len()
|
||||
);
|
||||
|
||||
let patched_client = game_dir.join("libraries/net/neoforged/neoforge/21.1.248/neoforge-21.1.248-client.jar");
|
||||
assert!(patched_client.exists(), "FancyModLoader needs this at runtime even though it is not on the generic classpath");
|
||||
let patched_client =
|
||||
game_dir.join("libraries/net/neoforged/neoforge/21.1.248/neoforge-21.1.248-client.jar");
|
||||
assert!(
|
||||
patched_client.exists(),
|
||||
"FancyModLoader needs this at runtime even though it is not on the generic classpath"
|
||||
);
|
||||
|
||||
let calls = progress_calls.lock().unwrap();
|
||||
assert!(calls.len() > 5, "expected many incremental progress calls, got {}", calls.len());
|
||||
assert!(
|
||||
calls.len() > 5,
|
||||
"expected many incremental progress calls, got {}",
|
||||
calls.len()
|
||||
);
|
||||
let (last_current, last_total) = *calls.last().unwrap();
|
||||
assert_eq!(last_current, last_total, "progress must reach 100% on success");
|
||||
assert!(calls.windows(2).all(|pair| pair[0].0 <= pair[1].0), "reported progress must never go backwards");
|
||||
assert_eq!(
|
||||
last_current, last_total,
|
||||
"progress must reach 100% on success"
|
||||
);
|
||||
assert!(
|
||||
calls.windows(2).all(|pair| pair[0].0 <= pair[1].0),
|
||||
"reported progress must never go backwards"
|
||||
);
|
||||
drop(calls);
|
||||
|
||||
// Re-running must skip straight to reading the cached version JSON
|
||||
// rather than invoking the installer again.
|
||||
let neoforge_again = ensure_client_installed(&client, Path::new(&java_install.executable), &game_dir, &cache_dir, "21.1.248", &no_progress).unwrap();
|
||||
assert_eq!(neoforge_again.libraries.len(), neoforge_version.libraries.len());
|
||||
let neoforge_again = ensure_client_installed(
|
||||
&client,
|
||||
Path::new(&java_install.executable),
|
||||
&game_dir,
|
||||
&cache_dir,
|
||||
"21.1.248",
|
||||
&no_progress,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
neoforge_again.libraries.len(),
|
||||
neoforge_version.libraries.len()
|
||||
);
|
||||
|
||||
fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Process-local exclusion for operations that share installation/account files.
|
||||
//!
|
||||
//! 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,
|
||||
};
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct LauncherOperations {
|
||||
pub installation: Operation,
|
||||
pub account: Operation,
|
||||
pub shacraft_account: Operation,
|
||||
// Held from launch scheduling until Child::wait completes, not just spawn.
|
||||
pub game: Operation,
|
||||
}
|
||||
|
||||
impl LauncherOperations {
|
||||
/// Acquire every mutation gate without waiting. Partial acquisition is
|
||||
/// rolled back by RAII, so a failed update cannot strand an account gate.
|
||||
pub fn acquire_update(&self) -> Result<UpdatePermits, String> {
|
||||
let installation = self.installation.acquire("Установка игры")?;
|
||||
let account = self.account.acquire("Вход в аккаунт")?;
|
||||
let shacraft_account = self
|
||||
.shacraft_account
|
||||
.acquire("Операция с аккаунтом ShaCraft")?;
|
||||
let game = self
|
||||
.game
|
||||
.acquire("Игра")
|
||||
.map_err(|_| "Закройте Minecraft перед обновлением лаунчера.".to_string())?;
|
||||
Ok(UpdatePermits {
|
||||
_installation: installation,
|
||||
_account: account,
|
||||
_shacraft_account: shacraft_account,
|
||||
_game: game,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct UpdatePermits {
|
||||
_installation: Permit,
|
||||
_account: Permit,
|
||||
_shacraft_account: Permit,
|
||||
_game: Permit,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct Operation(Arc<AtomicBool>);
|
||||
|
||||
impl Operation {
|
||||
pub fn acquire(&self, label: &str) -> Result<Permit, String> {
|
||||
self.0
|
||||
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
|
||||
.map_err(|_| format!("{label} is already in progress; wait for it to finish"))?;
|
||||
Ok(Permit(self.0.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct Permit(Arc<AtomicBool>);
|
||||
|
||||
impl Drop for Permit {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(false, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{LauncherOperations, Operation};
|
||||
|
||||
#[test]
|
||||
fn rejects_overlap_and_releases_on_worker_error() {
|
||||
let operation = Operation::default();
|
||||
let worker = || -> Result<(), String> {
|
||||
let _permit = operation.acquire("Installation")?;
|
||||
assert!(operation.acquire("Installation").is_err());
|
||||
Err("simulated worker failure".into())
|
||||
};
|
||||
assert!(worker().is_err());
|
||||
assert!(operation.acquire("Installation").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_game_blocks_update_and_partial_locks_are_released() {
|
||||
let operations = LauncherOperations::default();
|
||||
let game = operations.game.acquire("game").unwrap();
|
||||
assert!(operations.acquire_update().is_err());
|
||||
assert!(operations.installation.acquire("install").is_ok());
|
||||
assert!(operations.account.acquire("account").is_ok());
|
||||
assert!(operations
|
||||
.shacraft_account
|
||||
.acquire("ShaCraft account")
|
||||
.is_ok());
|
||||
drop(game);
|
||||
assert!(operations.acquire_update().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_excludes_game_and_accounts_until_permit_drop() {
|
||||
let operations = LauncherOperations::default();
|
||||
let permit = operations.acquire_update().unwrap();
|
||||
assert!(operations.installation.acquire("install").is_err());
|
||||
assert!(operations.account.acquire("account").is_err());
|
||||
assert!(operations
|
||||
.shacraft_account
|
||||
.acquire("ShaCraft account")
|
||||
.is_err());
|
||||
assert!(operations.game.acquire("game").is_err());
|
||||
assert!(operations.acquire_update().is_err());
|
||||
drop(permit);
|
||||
assert!(operations.acquire_update().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_operation_blocks_update_without_stranding_installation() {
|
||||
let operations = LauncherOperations::default();
|
||||
let _account = operations.shacraft_account.acquire("account").unwrap();
|
||||
assert!(operations.acquire_update().is_err());
|
||||
assert!(operations.installation.acquire("install").is_ok());
|
||||
}
|
||||
}
|
||||
+162
-21
@@ -2,7 +2,11 @@ use crate::download::{self, Checksum, DownloadError};
|
||||
use crate::manifest::{is_allowed_download_url, FilePolicy, ManagedFile, Manifest};
|
||||
use reqwest::{blocking::Client, redirect::Policy};
|
||||
use serde::Serialize;
|
||||
use std::{fmt, io, path::Path};
|
||||
use std::{
|
||||
fmt, fs, io,
|
||||
path::{Path, PathBuf},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -28,14 +32,22 @@ pub enum ProfileError {
|
||||
Io(io::Error),
|
||||
Network(reqwest::Error),
|
||||
Download { path: String, source: DownloadError },
|
||||
UnsafePath(PathBuf),
|
||||
}
|
||||
|
||||
impl fmt::Display for ProfileError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Io(error) => write!(formatter, "Cannot inspect profile: {error}"),
|
||||
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::Download { path, source } => {
|
||||
write!(formatter, "Download failed for {path}: {source}")
|
||||
}
|
||||
Self::UnsafePath(path) => write!(
|
||||
formatter,
|
||||
"Profile path contains a symbolic link: {}",
|
||||
path.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,15 +57,12 @@ pub fn inspect(root: &Path, manifest: &Manifest) -> Result<ProfileInspection, Pr
|
||||
let mut mismatched_files = 0;
|
||||
|
||||
for expected in &manifest.files {
|
||||
let path = root.join(&expected.path);
|
||||
if !path.exists() {
|
||||
let path = managed_target(root, &expected.path)?;
|
||||
if !path.try_exists().map_err(ProfileError::Io)? {
|
||||
missing_files += 1;
|
||||
continue;
|
||||
}
|
||||
// Seed files are only supplied on the first install. Once present,
|
||||
// player changes are intentional and must not make the profile look
|
||||
// out of date: `sync` preserves them for the same reason.
|
||||
if matches!(expected.policy, FilePolicy::Seed) {
|
||||
if matches!(expected.policy, FilePolicy::Seed) && path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let checksum = Checksum::Sha256(expected.sha256.clone());
|
||||
@@ -73,8 +82,12 @@ pub fn inspect(root: &Path, manifest: &Manifest) -> Result<ProfileInspection, Pr
|
||||
|
||||
pub fn sync(root: &Path, manifest: &Manifest) -> Result<SyncResult, ProfileError> {
|
||||
let client = Client::builder()
|
||||
.connect_timeout(Duration::from_secs(15))
|
||||
.timeout(Duration::from_secs(10 * 60))
|
||||
.redirect(Policy::custom(|attempt| {
|
||||
if is_allowed_download_url(attempt.url().as_str()) {
|
||||
if attempt.previous().len() >= 10 {
|
||||
attempt.error("too many redirects")
|
||||
} else if is_allowed_download_url(attempt.url().as_str()) {
|
||||
attempt.follow()
|
||||
} else {
|
||||
attempt.stop()
|
||||
@@ -88,13 +101,15 @@ pub fn sync(root: &Path, manifest: &Manifest) -> Result<SyncResult, ProfileError
|
||||
let mut downloaded_bytes = 0;
|
||||
|
||||
for expected in &manifest.files {
|
||||
let target = root.join(&expected.path);
|
||||
if matches!(expected.policy, FilePolicy::Seed) && target.exists() {
|
||||
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)? {
|
||||
if download::is_current(&target, Some(expected.size), &checksum)
|
||||
.map_err(ProfileError::Io)?
|
||||
{
|
||||
reused_files += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -112,10 +127,51 @@ pub fn sync(root: &Path, manifest: &Manifest) -> Result<SyncResult, ProfileError
|
||||
})
|
||||
}
|
||||
|
||||
fn download_managed_file(client: &Client, expected: &ManagedFile, target: &Path) -> Result<u64, ProfileError> {
|
||||
/// 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)
|
||||
}
|
||||
|
||||
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()))
|
||||
}
|
||||
Ok(_) => Ok(()),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(ProfileError::Io(error)),
|
||||
}
|
||||
}
|
||||
|
||||
fn download_managed_file(
|
||||
client: &Client,
|
||||
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, |_, _| {}).map_err(|error| {
|
||||
ProfileError::Download { path: expected.path.clone(), source: error }
|
||||
download::download_verified(
|
||||
client,
|
||||
&expected.url,
|
||||
target,
|
||||
Some(expected.size),
|
||||
&checksum,
|
||||
|_, _| {},
|
||||
)
|
||||
.map_err(|error| ProfileError::Download {
|
||||
path: expected.path.clone(),
|
||||
source: error,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -124,7 +180,45 @@ mod tests {
|
||||
use super::inspect;
|
||||
use crate::manifest::{FilePolicy, Loader, ManagedFile, Manifest, Minecraft};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{fs, process, time::{SystemTime, UNIX_EPOCH}};
|
||||
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 manifest(hash: String, size: u64) -> Manifest {
|
||||
Manifest {
|
||||
@@ -133,7 +227,10 @@ mod tests {
|
||||
display_name: "Aeronautics".into(),
|
||||
minecraft: Minecraft {
|
||||
version: "1.21.1".into(),
|
||||
loader: Loader { kind: "neoforge".into(), version: "21.1.248".into() },
|
||||
loader: Loader {
|
||||
kind: "neoforge".into(),
|
||||
version: "21.1.248".into(),
|
||||
},
|
||||
java_major: 21,
|
||||
},
|
||||
files: vec![ManagedFile {
|
||||
@@ -151,7 +248,10 @@ mod tests {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"shacraft-launcher-test-{}-{}",
|
||||
process::id(),
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos()
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
let bytes = b"ShaCraft test file";
|
||||
let digest = format!("{:x}", Sha256::digest(bytes));
|
||||
@@ -171,23 +271,64 @@ mod tests {
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edited_seed_files_remain_up_to_date() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"shacraft-seed-test-{}-{}",
|
||||
process::id(),
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
fs::create_dir_all(root.join("mods")).unwrap();
|
||||
fs::write(root.join("mods/example.jar"), b"player's edits").unwrap();
|
||||
let mut expected = manifest("0".repeat(64), 42);
|
||||
expected.files[0].policy = FilePolicy::Seed;
|
||||
assert!(inspect(&root, &expected).unwrap().up_to_date);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_changed_seed_files_as_current() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"shacraft-launcher-seed-test-{}-{}",
|
||||
process::id(),
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos()
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
let mut expected = manifest("0".repeat(64), 42);
|
||||
expected.files[0].policy = FilePolicy::Seed;
|
||||
fs::create_dir_all(root.join("mods")).unwrap();
|
||||
fs::write(root.join("mods/example.jar"), b"player customization").unwrap();
|
||||
|
||||
let inspection = inspect(&root, &expected).unwrap();
|
||||
assert_eq!(inspection.missing_files, 0);
|
||||
assert_eq!(inspection.mismatched_files, 0);
|
||||
assert!(inspection.up_to_date);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn refuses_linked_profile_directories() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"shacraft-link-test-{}-{}",
|
||||
process::id(),
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
fs::create_dir_all(root.join("outside")).unwrap();
|
||||
fs::create_dir_all(root.join("profile")).unwrap();
|
||||
symlink(root.join("outside"), root.join("profile/mods")).unwrap();
|
||||
assert!(matches!(
|
||||
inspect(&root.join("profile"), &manifest("0".repeat(64), 42)),
|
||||
Err(super::ProfileError::UnsafePath(_))
|
||||
));
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
+237
-35
@@ -1,14 +1,34 @@
|
||||
use crate::manifest::{self, Manifest};
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
||||
use ed25519_dalek::{Signature, VerifyingKey};
|
||||
use reqwest::header::ACCEPT_ENCODING;
|
||||
use reqwest::{blocking::Client, redirect::Policy};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{fmt, time::Duration};
|
||||
use std::{
|
||||
fmt,
|
||||
io::{self, Read},
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
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";
|
||||
const MAX_ENVELOPE_BYTES: usize = 2 * 1024 * 1024;
|
||||
const MANIFEST_ATTEMPTS: u32 = 3;
|
||||
|
||||
/// Display-only status: never used to select executable files or versions.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServerStatus {
|
||||
pub online: Option<u32>,
|
||||
pub max: Option<u32>,
|
||||
pub reachable: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -19,23 +39,15 @@ struct SignedManifest {
|
||||
signature: String,
|
||||
}
|
||||
|
||||
/// Read-only player count for the profile currently supported by the launcher.
|
||||
/// This URL is deliberately fixed here rather than supplied by a manifest.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServerStatus {
|
||||
pub online: Option<u32>,
|
||||
pub max: Option<u32>,
|
||||
pub reachable: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RemoteError {
|
||||
UnknownProfile,
|
||||
Network(reqwest::Error),
|
||||
Status(reqwest::StatusCode),
|
||||
TooLarge,
|
||||
Read(io::Error),
|
||||
InvalidSignature,
|
||||
ProfileMismatch,
|
||||
InvalidManifest(manifest::ManifestError),
|
||||
}
|
||||
|
||||
@@ -46,8 +58,14 @@ impl fmt::Display for RemoteError {
|
||||
Self::Network(error) => write!(formatter, "Cannot load ShaCraft manifest: {error}"),
|
||||
Self::Status(status) => write!(formatter, "ShaCraft manifest request failed: {status}"),
|
||||
Self::TooLarge => formatter.write_str("ShaCraft manifest is too large"),
|
||||
Self::Read(error) => write!(formatter, "Cannot read ShaCraft manifest: {error}"),
|
||||
Self::InvalidSignature => formatter.write_str("ShaCraft manifest signature is invalid"),
|
||||
Self::InvalidManifest(error) => write!(formatter, "ShaCraft manifest is invalid: {error}"),
|
||||
Self::ProfileMismatch => {
|
||||
formatter.write_str("Signed manifest does not match the requested profile")
|
||||
}
|
||||
Self::InvalidManifest(error) => {
|
||||
write!(formatter, "ShaCraft manifest is invalid: {error}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,43 +73,66 @@ impl fmt::Display for RemoteError {
|
||||
pub fn fetch_manifest(profile_id: &str) -> Result<Manifest, RemoteError> {
|
||||
let url = match profile_id {
|
||||
"aeronautics" => AERONAUTICS_MANIFEST,
|
||||
"minigames" => MINIGAMES_MANIFEST,
|
||||
_ => return Err(RemoteError::UnknownProfile),
|
||||
};
|
||||
let client = Client::builder()
|
||||
.https_only(true)
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.timeout(Duration::from_secs(30))
|
||||
.redirect(Policy::none())
|
||||
.build()
|
||||
.map_err(RemoteError::Network)?;
|
||||
let response = client.get(url).send().map_err(RemoteError::Network)?;
|
||||
if !response.status().is_success() {
|
||||
return Err(RemoteError::Status(response.status()));
|
||||
}
|
||||
if response.content_length().is_some_and(|size| size > 2 * 1024 * 1024) {
|
||||
return Err(RemoteError::TooLarge);
|
||||
}
|
||||
let source = response.text().map_err(RemoteError::Network)?;
|
||||
let envelope = serde_json::from_str::<SignedManifest>(&source)
|
||||
.map_err(|_| RemoteError::InvalidSignature)?;
|
||||
if envelope.schema_version != 1 || envelope.key_id != "2026-09-06" {
|
||||
return Err(RemoteError::InvalidSignature);
|
||||
}
|
||||
let payload = STANDARD.decode(envelope.payload).map_err(|_| RemoteError::InvalidSignature)?;
|
||||
let signature_bytes = STANDARD.decode(envelope.signature).map_err(|_| RemoteError::InvalidSignature)?;
|
||||
let public_key_bytes = STANDARD.decode(MANIFEST_PUBLIC_KEY).expect("embedded public key must be valid");
|
||||
let public_key = VerifyingKey::from_bytes(&public_key_bytes.try_into().expect("embedded public key must be 32 bytes"))
|
||||
let source = fetch_manifest_bytes(&client, url)?;
|
||||
let public_key_bytes = STANDARD
|
||||
.decode(MANIFEST_PUBLIC_KEY)
|
||||
.expect("embedded public key must be valid");
|
||||
let signature = Signature::from_slice(&signature_bytes).map_err(|_| RemoteError::InvalidSignature)?;
|
||||
public_key.verify(&payload, &signature).map_err(|_| RemoteError::InvalidSignature)?;
|
||||
let payload = String::from_utf8(payload).map_err(|_| RemoteError::InvalidSignature)?;
|
||||
manifest::validate_json(&payload).map_err(RemoteError::InvalidManifest)
|
||||
let public_key = VerifyingKey::from_bytes(
|
||||
&public_key_bytes
|
||||
.try_into()
|
||||
.expect("embedded public key must be 32 bytes"),
|
||||
)
|
||||
.expect("embedded public key must be valid");
|
||||
verify_envelope(&source, profile_id, &public_key)
|
||||
}
|
||||
|
||||
fn fetch_manifest_bytes(client: &Client, url: &str) -> Result<Vec<u8>, RemoteError> {
|
||||
for attempt in 1..=MANIFEST_ATTEMPTS {
|
||||
let request = || {
|
||||
let response = client
|
||||
.get(url)
|
||||
.header(ACCEPT_ENCODING, "identity")
|
||||
.send()
|
||||
.map_err(RemoteError::Network)?;
|
||||
if !response.status().is_success() {
|
||||
return Err(RemoteError::Status(response.status()));
|
||||
}
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|size| size > MAX_ENVELOPE_BYTES as u64)
|
||||
{
|
||||
return Err(RemoteError::TooLarge);
|
||||
}
|
||||
read_envelope(response)
|
||||
};
|
||||
match request() {
|
||||
Err(RemoteError::Network(_) | RemoteError::Read(_)) if attempt < MANIFEST_ATTEMPTS => {
|
||||
thread::sleep(Duration::from_millis(250 * attempt as u64));
|
||||
}
|
||||
result => return result,
|
||||
}
|
||||
}
|
||||
unreachable!("the last attempt always returns")
|
||||
}
|
||||
|
||||
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()
|
||||
.https_only(true)
|
||||
.timeout(Duration::from_secs(10))
|
||||
.redirect(Policy::none())
|
||||
.build()
|
||||
@@ -100,5 +141,166 @@ pub fn fetch_server_status(profile_id: &str) -> Result<ServerStatus, RemoteError
|
||||
if !response.status().is_success() {
|
||||
return Err(RemoteError::Status(response.status()));
|
||||
}
|
||||
response.json::<ServerStatus>().map_err(RemoteError::Network)
|
||||
response
|
||||
.json::<ServerStatus>()
|
||||
.map_err(RemoteError::Network)
|
||||
}
|
||||
|
||||
fn read_envelope(source: impl Read) -> Result<Vec<u8>, RemoteError> {
|
||||
let mut bytes = Vec::new();
|
||||
source
|
||||
.take(MAX_ENVELOPE_BYTES as u64 + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(RemoteError::Read)?;
|
||||
if bytes.len() > MAX_ENVELOPE_BYTES {
|
||||
return Err(RemoteError::TooLarge);
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn verify_envelope(
|
||||
source: &[u8],
|
||||
profile_id: &str,
|
||||
public_key: &VerifyingKey,
|
||||
) -> Result<Manifest, RemoteError> {
|
||||
if source.len() > MAX_ENVELOPE_BYTES {
|
||||
return Err(RemoteError::TooLarge);
|
||||
}
|
||||
let envelope = serde_json::from_slice::<SignedManifest>(source)
|
||||
.map_err(|_| RemoteError::InvalidSignature)?;
|
||||
if envelope.schema_version != 1 || envelope.key_id != MANIFEST_KEY_ID {
|
||||
return Err(RemoteError::InvalidSignature);
|
||||
}
|
||||
let payload = STANDARD
|
||||
.decode(envelope.payload)
|
||||
.map_err(|_| RemoteError::InvalidSignature)?;
|
||||
let signature_bytes = STANDARD
|
||||
.decode(envelope.signature)
|
||||
.map_err(|_| RemoteError::InvalidSignature)?;
|
||||
let signature =
|
||||
Signature::from_slice(&signature_bytes).map_err(|_| RemoteError::InvalidSignature)?;
|
||||
public_key
|
||||
.verify_strict(&payload, &signature)
|
||||
.map_err(|_| RemoteError::InvalidSignature)?;
|
||||
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)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[test]
|
||||
#[ignore = "read-only check of the production signed manifest; requires network"]
|
||||
fn live_validates_production_aeronautics_manifest() {
|
||||
let manifest = fetch_manifest("aeronautics").unwrap();
|
||||
assert_eq!(manifest.id, "aeronautics");
|
||||
assert!(!manifest.files.is_empty());
|
||||
}
|
||||
|
||||
fn signed_fixture() -> (Value, VerifyingKey) {
|
||||
let key = SigningKey::from_bytes(&[17; 32]);
|
||||
let payload = serde_json::to_vec(&json!({
|
||||
"schemaVersion": 1, "id": "aeronautics", "displayName": "Aeronautics",
|
||||
"minecraft": {"version": "1.21.1", "loader": {"kind": "neoforge", "version": "21.1.248"}, "javaMajor": 21},
|
||||
"files": []
|
||||
})).unwrap();
|
||||
let envelope = json!({
|
||||
"schemaVersion": 1, "keyId": MANIFEST_KEY_ID,
|
||||
"payload": STANDARD.encode(&payload),
|
||||
"signature": STANDARD.encode(key.sign(&payload).to_bytes())
|
||||
});
|
||||
(envelope, key.verifying_key())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_valid_signature_and_binds_requested_profile() {
|
||||
let (envelope, key) = signed_fixture();
|
||||
let bytes = serde_json::to_vec(&envelope).unwrap();
|
||||
assert_eq!(
|
||||
verify_envelope(&bytes, "aeronautics", &key).unwrap().id,
|
||||
"aeronautics"
|
||||
);
|
||||
assert!(matches!(
|
||||
verify_envelope(&bytes, "another-profile", &key),
|
||||
Err(RemoteError::ProfileMismatch)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_modified_payload_signature_key_and_schema() {
|
||||
let (original, key) = signed_fixture();
|
||||
for (field, value) in [
|
||||
("payload", json!(STANDARD.encode(b"{}"))),
|
||||
("signature", json!(STANDARD.encode([0; 64]))),
|
||||
("keyId", json!("unknown")),
|
||||
("schemaVersion", json!(2)),
|
||||
] {
|
||||
let mut envelope = original.clone();
|
||||
envelope[field] = value;
|
||||
assert!(
|
||||
matches!(
|
||||
verify_envelope(&serde_json::to_vec(&envelope).unwrap(), "aeronautics", &key),
|
||||
Err(RemoteError::InvalidSignature)
|
||||
),
|
||||
"{field}"
|
||||
);
|
||||
}
|
||||
let other_key = SigningKey::from_bytes(&[18; 32]).verifying_key();
|
||||
assert!(matches!(
|
||||
verify_envelope(
|
||||
&serde_json::to_vec(&original).unwrap(),
|
||||
"aeronautics",
|
||||
&other_key
|
||||
),
|
||||
Err(RemoteError::InvalidSignature)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_stream_without_content_length() {
|
||||
assert!(matches!(
|
||||
read_envelope(io::repeat(b'x')),
|
||||
Err(RemoteError::TooLarge)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retries_truncated_manifest_transfers_and_requests_identity_encoding() {
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
net::TcpListener,
|
||||
};
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let url = format!("http://{}/manifest", listener.local_addr().unwrap());
|
||||
let server = std::thread::spawn(move || {
|
||||
for body in [
|
||||
b"HTTP/1.1 200 OK\r\nContent-Length: 10\r\nConnection: close\r\n\r\nbad".as_slice(),
|
||||
b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}".as_slice(),
|
||||
] {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||
.unwrap();
|
||||
let mut request = [0_u8; 4096];
|
||||
let length = stream.read(&mut request).unwrap();
|
||||
assert!(String::from_utf8_lossy(&request[..length])
|
||||
.to_ascii_lowercase()
|
||||
.contains("accept-encoding: identity"));
|
||||
stream.write_all(body).unwrap();
|
||||
}
|
||||
});
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(2))
|
||||
.build()
|
||||
.unwrap();
|
||||
assert_eq!(fetch_manifest_bytes(&client, &url).unwrap(), b"{}");
|
||||
server.join().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,12 +12,18 @@ use serde::Deserialize;
|
||||
use std::{fmt, fs, io, path::{Path, PathBuf}};
|
||||
|
||||
const ADOPTIUM_HOST: &str = "api.adoptium.net";
|
||||
const RUNTIME_HOSTS: [&str; 4] = [ADOPTIUM_HOST, "github.com", "objects.githubusercontent.com", "release-assets.githubusercontent.com"];
|
||||
|
||||
pub fn http_client() -> Result<Client, reqwest::Error> {
|
||||
crate::trusted_http::client(&RUNTIME_HOSTS, std::time::Duration::from_secs(10 * 60))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RuntimeError {
|
||||
Network(reqwest::Error),
|
||||
HttpStatus(reqwest::StatusCode),
|
||||
NoRelease,
|
||||
UntrustedPackage,
|
||||
UnexpectedArchiveLayout,
|
||||
Download(DownloadError),
|
||||
Io(io::Error),
|
||||
@@ -30,6 +36,7 @@ impl fmt::Display for RuntimeError {
|
||||
Self::Network(error) => write!(formatter, "network error: {error}"),
|
||||
Self::HttpStatus(status) => write!(formatter, "Adoptium returned {status}"),
|
||||
Self::NoRelease => formatter.write_str("Adoptium has no matching JRE release for this platform"),
|
||||
Self::UntrustedPackage => formatter.write_str("Adoptium package has an unsafe archive name, URL or checksum"),
|
||||
Self::UnexpectedArchiveLayout => formatter.write_str("Java archive did not contain a single top-level directory as expected"),
|
||||
Self::Download(error) => write!(formatter, "{error}"),
|
||||
Self::Io(error) => write!(formatter, "I/O error: {error}"),
|
||||
@@ -76,6 +83,18 @@ struct AdoptiumPackage {
|
||||
name: String,
|
||||
}
|
||||
|
||||
fn validate_package(package: &AdoptiumPackage) -> Result<(), RuntimeError> {
|
||||
if !crate::manifest::is_portable_component(&package.name)
|
||||
|| package.name.contains('/')
|
||||
|| !(package.name.ends_with(".tar.gz") || package.name.ends_with(".zip"))
|
||||
|| !crate::trusted_http::allows(&package.link, &RUNTIME_HOSTS)
|
||||
|| package.checksum.len() != 64
|
||||
|| !package.checksum.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||||
return Err(RuntimeError::UntrustedPackage);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn adoptium_os() -> &'static str {
|
||||
if cfg!(target_os = "windows") {
|
||||
"windows"
|
||||
@@ -138,6 +157,7 @@ pub fn ensure_runtime(client: &Client, runtime_root: &Path, major: u8, on_progre
|
||||
}
|
||||
let assets: Vec<AdoptiumAsset> = response.json()?;
|
||||
let package = assets.into_iter().next().map(|asset| asset.binary.package).ok_or(RuntimeError::NoRelease)?;
|
||||
validate_package(&package)?;
|
||||
|
||||
fs::create_dir_all(runtime_root)?;
|
||||
let archive_path = runtime_root.join(&package.name);
|
||||
@@ -210,6 +230,42 @@ mod tests {
|
||||
assert!(path.ends_with(java_executable_name()));
|
||||
}
|
||||
|
||||
fn package() -> AdoptiumPackage {
|
||||
AdoptiumPackage {
|
||||
name: "OpenJDK21U-jre_x64_linux_hotspot_21.0.8_9.tar.gz".into(),
|
||||
link: "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.8%2B9/runtime.tar.gz".into(),
|
||||
checksum: "a".repeat(64),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_only_portable_runtime_archive_names() {
|
||||
assert!(validate_package(&package()).is_ok());
|
||||
let mut windows = package();
|
||||
windows.name = "OpenJDK21U-jre_x64_windows_hotspot.zip".into();
|
||||
assert!(validate_package(&windows).is_ok());
|
||||
for name in ["../runtime.tar.gz", "/runtime.zip", "C:\\runtime.zip", "runtime.zip:stream", "CON.zip", "LPT1.zip", "runtime.zip.", "runtime.zip ", "runtime.exe"] {
|
||||
let mut malicious = package();
|
||||
malicious.name = name.into();
|
||||
assert!(matches!(validate_package(&malicious), Err(RuntimeError::UntrustedPackage)), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_untrusted_runtime_urls_and_invalid_hashes() {
|
||||
for link in ["http://github.com/runtime.zip", "https://evil.example/runtime.zip", "https://github.com.evil.example/runtime.zip", "https://user@github.com/runtime.zip"] {
|
||||
let mut malicious = package();
|
||||
malicious.link = link.into();
|
||||
assert!(validate_package(&malicious).is_err());
|
||||
}
|
||||
let mut malicious = package();
|
||||
malicious.checksum = "not-a-checksum".into();
|
||||
assert!(validate_package(&malicious).is_err());
|
||||
for host in RUNTIME_HOSTS {
|
||||
assert!(crate::trusted_http::allows(&format!("https://{host}/release.tar.gz"), &RUNTIME_HOSTS));
|
||||
}
|
||||
}
|
||||
|
||||
/// Live smoke test: resolves the current platform's latest Temurin 21
|
||||
/// JRE from Adoptium, downloads it, verifies the checksum, and extracts
|
||||
/// it. Not run by default; `cargo test -- --ignored ensure_runtime`.
|
||||
|
||||
+68
-16
@@ -38,7 +38,11 @@ fn default_nickname() -> String {
|
||||
|
||||
impl Default for LauncherSettings {
|
||||
fn default() -> Self {
|
||||
Self { memory_mb: DEFAULT_MEMORY_MB, nickname: DEFAULT_NICKNAME.into(), account_mode: AccountMode::Offline }
|
||||
Self {
|
||||
memory_mb: DEFAULT_MEMORY_MB,
|
||||
nickname: DEFAULT_NICKNAME.into(),
|
||||
account_mode: AccountMode::Offline,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,8 +59,13 @@ impl fmt::Display for SettingsError {
|
||||
match self {
|
||||
Self::Io(error) => write!(formatter, "Cannot access launcher settings: {error}"),
|
||||
Self::InvalidJson(error) => write!(formatter, "Cannot read launcher settings: {error}"),
|
||||
Self::InvalidMemory => write!(formatter, "Memory allocation must be between 3 and 12 GiB"),
|
||||
Self::InvalidNickname => write!(formatter, "Nickname must be 3-16 ASCII letters, numbers, or underscores"),
|
||||
Self::InvalidMemory => {
|
||||
write!(formatter, "Memory allocation must be between 3 and 12 GiB")
|
||||
}
|
||||
Self::InvalidNickname => write!(
|
||||
formatter,
|
||||
"Nickname must be 3-16 ASCII letters, numbers, or underscores"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,7 +74,9 @@ pub fn load(data_dir: &Path) -> Result<LauncherSettings, SettingsError> {
|
||||
let path = data_dir.join(SETTINGS_FILE);
|
||||
let source = match fs::read_to_string(path) {
|
||||
Ok(source) => source,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(LauncherSettings::default()),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {
|
||||
return Ok(LauncherSettings::default())
|
||||
}
|
||||
Err(error) => return Err(SettingsError::Io(error)),
|
||||
};
|
||||
let settings = serde_json::from_str(&source).map_err(SettingsError::InvalidJson)?;
|
||||
@@ -73,23 +84,31 @@ pub fn load(data_dir: &Path) -> Result<LauncherSettings, SettingsError> {
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
pub fn save(data_dir: &Path, settings: LauncherSettings) -> Result<LauncherSettings, SettingsError> {
|
||||
pub fn save(
|
||||
data_dir: &Path,
|
||||
settings: LauncherSettings,
|
||||
) -> Result<LauncherSettings, SettingsError> {
|
||||
validate(&settings)?;
|
||||
fs::create_dir_all(data_dir).map_err(SettingsError::Io)?;
|
||||
|
||||
let target = data_dir.join(SETTINGS_FILE);
|
||||
let temporary = data_dir.join(".settings.json.shacraft.part");
|
||||
let contents = serde_json::to_vec_pretty(&settings).expect("LauncherSettings is serializable");
|
||||
fs::write(&temporary, contents).map_err(SettingsError::Io)?;
|
||||
fs::rename(temporary, target).map_err(SettingsError::Io)?;
|
||||
crate::storage::write_atomic(&target, &contents).map_err(SettingsError::Io)?;
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
fn validate(settings: &LauncherSettings) -> Result<(), SettingsError> {
|
||||
if !(MIN_MEMORY_MB..=MAX_MEMORY_MB).contains(&settings.memory_mb) || settings.memory_mb % 1024 != 0 {
|
||||
if !(MIN_MEMORY_MB..=MAX_MEMORY_MB).contains(&settings.memory_mb)
|
||||
|| settings.memory_mb % 1024 != 0
|
||||
{
|
||||
return Err(SettingsError::InvalidMemory);
|
||||
}
|
||||
if !(3..=16).contains(&settings.nickname.len()) || !settings.nickname.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') {
|
||||
if !(3..=16).contains(&settings.nickname.len())
|
||||
|| !settings
|
||||
.nickname
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
|
||||
{
|
||||
return Err(SettingsError::InvalidNickname);
|
||||
}
|
||||
Ok(())
|
||||
@@ -98,13 +117,19 @@ fn validate(settings: &LauncherSettings) -> Result<(), SettingsError> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{load, save, AccountMode, LauncherSettings};
|
||||
use std::{fs, process, time::{SystemTime, UNIX_EPOCH}};
|
||||
use std::{
|
||||
fs, process,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
fn temporary_directory() -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"shacraft-settings-test-{}-{}",
|
||||
process::id(),
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos()
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
))
|
||||
}
|
||||
|
||||
@@ -116,9 +141,20 @@ mod tests {
|
||||
assert_eq!(default.nickname, "Emil");
|
||||
assert_eq!(default.account_mode, AccountMode::Offline);
|
||||
|
||||
let saved = save(&directory, LauncherSettings { memory_mb: 8 * 1024, nickname: "Emil".into(), account_mode: AccountMode::Microsoft }).unwrap();
|
||||
let saved = save(
|
||||
&directory,
|
||||
LauncherSettings {
|
||||
memory_mb: 8 * 1024,
|
||||
nickname: "Emil".into(),
|
||||
account_mode: AccountMode::Microsoft,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(saved.memory_mb, 8 * 1024);
|
||||
assert_eq!(load(&directory).unwrap().account_mode, AccountMode::Microsoft);
|
||||
assert_eq!(
|
||||
load(&directory).unwrap().account_mode,
|
||||
AccountMode::Microsoft
|
||||
);
|
||||
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
@@ -126,8 +162,24 @@ mod tests {
|
||||
#[test]
|
||||
fn rejects_unsafe_memory_values() {
|
||||
let directory = temporary_directory();
|
||||
assert!(save(&directory, LauncherSettings { memory_mb: 512, nickname: "Emil".into(), account_mode: AccountMode::Offline }).is_err());
|
||||
assert!(save(&directory, LauncherSettings { memory_mb: 6 * 1024, nickname: "невалидный".into(), account_mode: AccountMode::Offline }).is_err());
|
||||
assert!(save(
|
||||
&directory,
|
||||
LauncherSettings {
|
||||
memory_mb: 512,
|
||||
nickname: "Emil".into(),
|
||||
account_mode: AccountMode::Offline
|
||||
}
|
||||
)
|
||||
.is_err());
|
||||
assert!(save(
|
||||
&directory,
|
||||
LauncherSettings {
|
||||
memory_mb: 6 * 1024,
|
||||
nickname: "невалидный".into(),
|
||||
account_mode: AccountMode::Offline
|
||||
}
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
//! ShaCraft local-account client.
|
||||
//!
|
||||
//! The API origin is fixed in the binary. Passwords are sent only over HTTPS
|
||||
//! and are never persisted; only the random, revocable session token is kept.
|
||||
|
||||
use reqwest::blocking::{Client, Response};
|
||||
use reqwest::redirect::Policy;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
fmt, fs,
|
||||
io::{self, Read},
|
||||
path::Path,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
const API_ORIGIN: &str = "https://shacraft.ru";
|
||||
const SESSION_FILE: &str = "shacraft-session";
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct AccountLink {
|
||||
pub server_id: String,
|
||||
pub mc_username: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct Account {
|
||||
pub username: String,
|
||||
pub links: Vec<AccountLink>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AuthResponse {
|
||||
session_token: String,
|
||||
account: Account,
|
||||
recovery_codes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Credentials<'a> {
|
||||
username: &'a str,
|
||||
password: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LoginResult {
|
||||
pub account: Account,
|
||||
pub recovery_codes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
pub struct LinkStart {
|
||||
pub challenge_id: i64,
|
||||
pub expires_in_seconds: u64,
|
||||
pub registered_on_server: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
pub struct LinkStatus {
|
||||
pub status: String,
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum AccountError {
|
||||
Network(reqwest::Error),
|
||||
Api(String),
|
||||
Io(io::Error),
|
||||
InvalidSession,
|
||||
}
|
||||
|
||||
impl fmt::Display for AccountError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Network(error) => write!(formatter, "Нет связи с аккаунтами ShaCraft: {error}"),
|
||||
Self::Api(message) => formatter.write_str(message),
|
||||
Self::Io(error) => write!(formatter, "Не удалось сохранить сессию: {error}"),
|
||||
Self::InvalidSession => formatter.write_str("Сессия ShaCraft истекла — войдите снова"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn client() -> Result<Client, AccountError> {
|
||||
Client::builder()
|
||||
.https_only(true)
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.timeout(Duration::from_secs(20))
|
||||
.redirect(Policy::none())
|
||||
.build()
|
||||
.map_err(AccountError::Network)
|
||||
}
|
||||
|
||||
fn api_error(response: Response) -> AccountError {
|
||||
#[derive(Deserialize)]
|
||||
struct ErrorBody {
|
||||
detail: Option<String>,
|
||||
}
|
||||
let status = response.status();
|
||||
let detail = response
|
||||
.json::<ErrorBody>()
|
||||
.ok()
|
||||
.and_then(|body| body.detail);
|
||||
AccountError::Api(detail.unwrap_or_else(|| format!("ShaCraft API: HTTP {status}")))
|
||||
}
|
||||
|
||||
fn session_path(data_dir: &Path) -> std::path::PathBuf {
|
||||
data_dir.join(SESSION_FILE)
|
||||
}
|
||||
|
||||
fn save_session(data_dir: &Path, token: &str) -> Result<(), AccountError> {
|
||||
fs::create_dir_all(data_dir).map_err(AccountError::Io)?;
|
||||
let path = session_path(data_dir);
|
||||
crate::storage::write_atomic(&path, token.as_bytes()).map_err(AccountError::Io)
|
||||
}
|
||||
|
||||
fn load_session(data_dir: &Path) -> Result<String, AccountError> {
|
||||
let token = fs::read_to_string(session_path(data_dir)).map_err(|error| {
|
||||
if error.kind() == io::ErrorKind::NotFound {
|
||||
AccountError::InvalidSession
|
||||
} else {
|
||||
AccountError::Io(error)
|
||||
}
|
||||
})?;
|
||||
let token = token.trim();
|
||||
if token.len() < 32 || token.bytes().any(|byte| byte.is_ascii_whitespace()) {
|
||||
return Err(AccountError::InvalidSession);
|
||||
}
|
||||
Ok(token.to_owned())
|
||||
}
|
||||
|
||||
pub fn authenticate(
|
||||
data_dir: &Path,
|
||||
username: &str,
|
||||
password: &str,
|
||||
register: bool,
|
||||
) -> Result<LoginResult, AccountError> {
|
||||
let endpoint = if register {
|
||||
"/api/launcher/auth/register"
|
||||
} else {
|
||||
"/api/launcher/auth/login"
|
||||
};
|
||||
let response = client()?
|
||||
.post(format!("{API_ORIGIN}{endpoint}"))
|
||||
.json(&Credentials { username, password })
|
||||
.send()
|
||||
.map_err(AccountError::Network)?;
|
||||
if !response.status().is_success() {
|
||||
return Err(api_error(response));
|
||||
}
|
||||
let payload = response
|
||||
.json::<AuthResponse>()
|
||||
.map_err(AccountError::Network)?;
|
||||
save_session(data_dir, &payload.session_token)?;
|
||||
Ok(LoginResult {
|
||||
account: payload.account,
|
||||
recovery_codes: payload.recovery_codes,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_account(data_dir: &Path) -> Result<Account, AccountError> {
|
||||
let token = load_session(data_dir)?;
|
||||
let response = client()?
|
||||
.get(format!("{API_ORIGIN}/api/launcher/account"))
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.map_err(AccountError::Network)?;
|
||||
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
let _ = fs::remove_file(session_path(data_dir));
|
||||
return Err(AccountError::InvalidSession);
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
return Err(api_error(response));
|
||||
}
|
||||
response.json::<Account>().map_err(AccountError::Network)
|
||||
}
|
||||
|
||||
pub fn logout(data_dir: &Path) -> Result<(), AccountError> {
|
||||
if let Ok(token) = load_session(data_dir) {
|
||||
let _ = client()?
|
||||
.post(format!("{API_ORIGIN}/api/launcher/auth/logout"))
|
||||
.bearer_auth(token)
|
||||
.json(&serde_json::json!({}))
|
||||
.send();
|
||||
}
|
||||
match fs::remove_file(session_path(data_dir)) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(AccountError::Io(error)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_link(
|
||||
data_dir: &Path,
|
||||
server_id: &str,
|
||||
nickname: &str,
|
||||
) -> Result<LinkStart, AccountError> {
|
||||
let token = load_session(data_dir)?;
|
||||
let response = client()?
|
||||
.post(format!("{API_ORIGIN}/api/account/link/start"))
|
||||
.bearer_auth(token)
|
||||
.json(&serde_json::json!({"server_id": server_id, "mc_username": nickname}))
|
||||
.send()
|
||||
.map_err(AccountError::Network)?;
|
||||
if !response.status().is_success() {
|
||||
return Err(api_error(response));
|
||||
}
|
||||
response.json::<LinkStart>().map_err(AccountError::Network)
|
||||
}
|
||||
|
||||
pub fn link_status(data_dir: &Path, challenge_id: i64) -> Result<LinkStatus, AccountError> {
|
||||
let token = load_session(data_dir)?;
|
||||
let response = client()?
|
||||
.get(format!(
|
||||
"{API_ORIGIN}/api/account/link/status/{challenge_id}"
|
||||
))
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.map_err(AccountError::Network)?;
|
||||
if !response.status().is_success() {
|
||||
return Err(api_error(response));
|
||||
}
|
||||
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()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
admission_error, issue_admission, load_session, save_session, session_path, AccountError,
|
||||
};
|
||||
use std::{
|
||||
fs, process,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
fn temporary_directory() -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"shacraft-account-test-{}-{}",
|
||||
process::id(),
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_round_trips_without_password_storage() {
|
||||
let directory = temporary_directory();
|
||||
let token = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG";
|
||||
save_session(&directory, token).unwrap();
|
||||
assert_eq!(load_session(&directory).unwrap(), token);
|
||||
assert_eq!(fs::read_to_string(session_path(&directory)).unwrap(), token);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn session_is_private_on_unix() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let directory = temporary_directory();
|
||||
save_session(&directory, "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG").unwrap();
|
||||
assert_eq!(
|
||||
fs::metadata(session_path(&directory))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o077,
|
||||
0
|
||||
);
|
||||
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,190 @@
|
||||
//! Same-directory atomic replacement shared by downloads and durable settings.
|
||||
use std::{
|
||||
ffi::OsString,
|
||||
fs::{self, File, OpenOptions},
|
||||
io::{self, Write},
|
||||
path::{Path, PathBuf},
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
static NEXT_TEMPORARY: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Owns a unique file until commit. Failed writes never replace the destination,
|
||||
/// and dropping the transaction removes only the temporary file it created.
|
||||
pub(crate) struct AtomicFile {
|
||||
temporary: PathBuf,
|
||||
target: PathBuf,
|
||||
file: Option<File>,
|
||||
committed: bool,
|
||||
}
|
||||
|
||||
impl AtomicFile {
|
||||
pub fn new(target: &Path) -> io::Result<Self> {
|
||||
let parent = target
|
||||
.parent()
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "target has no parent"))?;
|
||||
let name = target
|
||||
.file_name()
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "target has no filename"))?;
|
||||
fs::create_dir_all(parent)?;
|
||||
for _ in 0..128 {
|
||||
let sequence = NEXT_TEMPORARY.fetch_add(1, Ordering::Relaxed);
|
||||
let mut temporary_name = OsString::from(".");
|
||||
temporary_name.push(name);
|
||||
temporary_name.push(format!(".shacraft-{}-{sequence}.part", std::process::id()));
|
||||
let temporary = parent.join(temporary_name);
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
match options.open(&temporary) {
|
||||
Ok(file) => {
|
||||
return Ok(Self {
|
||||
temporary,
|
||||
target: target.to_path_buf(),
|
||||
file: Some(file),
|
||||
committed: false,
|
||||
})
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
"cannot allocate a unique temporary file",
|
||||
))
|
||||
}
|
||||
|
||||
pub fn writer(&mut self) -> &mut File {
|
||||
self.file
|
||||
.as_mut()
|
||||
.expect("atomic file is open until commit")
|
||||
}
|
||||
|
||||
pub fn commit(mut self) -> io::Result<()> {
|
||||
self.writer().sync_all()?;
|
||||
drop(self.file.take());
|
||||
replace_file(&self.temporary, &self.target)?;
|
||||
self.committed = true;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AtomicFile {
|
||||
fn drop(&mut self) {
|
||||
drop(self.file.take());
|
||||
if !self.committed {
|
||||
let _ = fs::remove_file(&self.temporary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn write_atomic(target: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
let mut output = AtomicFile::new(target)?;
|
||||
output.writer().write_all(bytes)?;
|
||||
output.commit()
|
||||
}
|
||||
|
||||
/// Prefer the platform's atomic replacement. If Windows refuses an existing
|
||||
/// destination, retain the upstream recoverable replacement fallback, using
|
||||
/// this transaction's unique temporary name instead of a shared backup path.
|
||||
fn replace_file(temporary: &Path, target: &Path) -> io::Result<()> {
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
fs::rename(temporary, target)
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
match fs::rename(temporary, target) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(error) if !target.is_file() => return Err(error),
|
||||
Err(_) => {}
|
||||
}
|
||||
let mut backup_name = temporary.as_os_str().to_os_string();
|
||||
backup_name.push(".backup");
|
||||
let backup = PathBuf::from(backup_name);
|
||||
// Never overwrite a previous failed transaction's recovery file.
|
||||
let reservation = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&backup)?;
|
||||
drop(reservation);
|
||||
fs::remove_file(&backup)?;
|
||||
fs::rename(target, &backup)?;
|
||||
if let Err(error) = fs::rename(temporary, target) {
|
||||
if let Err(restore_error) = fs::rename(&backup, target) {
|
||||
return Err(io::Error::new(error.kind(), format!("Cannot replace file: {error}; cannot restore it: {restore_error}; previous file is recoverable at {}", backup.display())));
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
let _ = fs::remove_file(backup);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn competing_writers_do_not_share_temporary_files() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"shacraft-storage-test-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT_TEMPORARY.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
let target = root.join("settings.json");
|
||||
write_atomic(&target, b"original").unwrap();
|
||||
let mut first = AtomicFile::new(&target).unwrap();
|
||||
let mut second = AtomicFile::new(&target).unwrap();
|
||||
assert_ne!(first.temporary, second.temporary);
|
||||
first.writer().write_all(b"first").unwrap();
|
||||
second.writer().write_all(b"second").unwrap();
|
||||
first.commit().unwrap();
|
||||
assert_eq!(fs::read(&target).unwrap(), b"first");
|
||||
drop(second);
|
||||
assert_eq!(fs::read(&target).unwrap(), b"first");
|
||||
assert_eq!(fs::read_dir(&root).unwrap().count(), 1);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn persisted_secrets_are_owner_only() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"shacraft-secret-test-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT_TEMPORARY.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
let target = root.join("account.json");
|
||||
write_atomic(&target, b"token").unwrap();
|
||||
assert_eq!(
|
||||
target.metadata().unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaces_an_existing_file() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"shacraft-replacement-test-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT_TEMPORARY.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
let target = root.join("old.txt");
|
||||
let temporary = root.join("new.part");
|
||||
fs::write(&target, b"old").unwrap();
|
||||
fs::write(&temporary, b"new").unwrap();
|
||||
replace_file(&temporary, &target).unwrap();
|
||||
assert_eq!(fs::read(&target).unwrap(), b"new");
|
||||
assert!(!temporary.exists());
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//! The same origin policy applies to initial artifact URLs and every redirect.
|
||||
use reqwest::{blocking::Client, redirect::Policy};
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
|
||||
pub(crate) fn allows(value: &str, hosts: &[&str]) -> bool {
|
||||
let Ok(url) = Url::parse(value) else {
|
||||
return false;
|
||||
};
|
||||
url.scheme() == "https"
|
||||
&& url.username().is_empty()
|
||||
&& url.password().is_none()
|
||||
&& url.port_or_known_default() == Some(443)
|
||||
&& url.host_str().is_some_and(|host| hosts.contains(&host))
|
||||
}
|
||||
|
||||
pub(crate) fn client(
|
||||
hosts: &'static [&'static str],
|
||||
timeout: Duration,
|
||||
) -> Result<Client, reqwest::Error> {
|
||||
Client::builder()
|
||||
.https_only(true)
|
||||
.connect_timeout(Duration::from_secs(15))
|
||||
.timeout(timeout)
|
||||
.redirect(Policy::custom(move |attempt| {
|
||||
if attempt.previous().len() >= 10 {
|
||||
attempt.error("too many redirects")
|
||||
} else if allows(attempt.url().as_str(), hosts) {
|
||||
attempt.follow()
|
||||
} else {
|
||||
attempt.error("redirect leaves the trusted download hosts")
|
||||
}
|
||||
}))
|
||||
.build()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exact_https_origins_reject_authority_ambiguity_and_cross_domain_redirects() {
|
||||
let hosts = ["piston-meta.mojang.com"];
|
||||
assert!(allows("https://piston-meta.mojang.com/game.json", &hosts));
|
||||
for url in [
|
||||
"http://piston-meta.mojang.com/game.json",
|
||||
"https://piston-meta.mojang.com.attacker.test/game.json",
|
||||
"https://user@piston-meta.mojang.com/game.json",
|
||||
"https://piston-meta.mojang.com:444/game.json",
|
||||
"https://maven.neoforged.net/game.json",
|
||||
] {
|
||||
assert!(!allows(url, &hosts), "{url}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,853 @@
|
||||
//! Launcher releases form a separate trust boundary from modpack manifests.
|
||||
//! Only this module chooses update URLs. The webview receives display data and
|
||||
//! progress, never an updater resource, destination, signature or public key.
|
||||
use crate::operations::{Operation, UpdatePermits};
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use minisign_verify::{PublicKey, Signature};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
use tauri::{AppHandle, Manager, Runtime};
|
||||
use tauri_plugin_updater::{Update, UpdaterBuilder, UpdaterExt};
|
||||
use url::Url;
|
||||
|
||||
pub(crate) const UPDATE_ENDPOINT: &str = "https://shacraft.ru/launcher/updates/stable-v2.json";
|
||||
pub(crate) const MAX_METADATA_BYTES: usize = 192 * 1024;
|
||||
const MAX_PAYLOAD_BYTES: usize = 64 * 1024;
|
||||
pub(crate) const MAX_ARTIFACT_BYTES: usize = 256 * 1024 * 1024;
|
||||
const BAD_METADATA: &str =
|
||||
"Не удалось подтвердить подлинность сведений об обновлении. Повторите проверку позже.";
|
||||
const BAD_SIGNATURE: &str = "Подпись обновления не прошла проверку. Установка отменена.";
|
||||
|
||||
#[derive(Clone, Copy, Serialize, PartialEq, Eq, Debug)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub(crate) enum InstallationKind {
|
||||
Appimage,
|
||||
Deb,
|
||||
Other,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub(crate) enum Stage {
|
||||
#[default]
|
||||
Idle,
|
||||
Checking,
|
||||
Available,
|
||||
Downloading,
|
||||
Installing,
|
||||
Ready,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct UpdateState {
|
||||
pub stage: Stage,
|
||||
pub candidate: Option<Update>,
|
||||
// Hold all mutation gates until the user restarts into the installed app.
|
||||
pub restart_permits: Option<UpdatePermits>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct LauncherUpdater {
|
||||
pub operation: Operation,
|
||||
pub state: Arc<Mutex<UpdateState>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct UpdateStatus {
|
||||
pub current_version: String,
|
||||
pub supported: bool,
|
||||
pub installation_kind: InstallationKind,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<String>,
|
||||
pub stage: Stage,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub version: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct UpdateProgress {
|
||||
pub stage: Stage,
|
||||
pub downloaded_bytes: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub total_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
impl LauncherUpdater {
|
||||
pub fn status<R: Runtime>(&self, app: &AppHandle<R>) -> Result<UpdateStatus, String> {
|
||||
let reason = unsupported_reason(app);
|
||||
let state = self
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| "Состояние обновления недоступно.".to_string())?;
|
||||
Ok(UpdateStatus {
|
||||
current_version: app.package_info().version.to_string(),
|
||||
supported: reason.is_none(),
|
||||
installation_kind: installation_kind(app),
|
||||
reason,
|
||||
stage: state.stage,
|
||||
version: state
|
||||
.candidate
|
||||
.as_ref()
|
||||
.map(|update| update.version.clone()),
|
||||
notes: state
|
||||
.candidate
|
||||
.as_ref()
|
||||
.and_then(|update| update.body.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_stage(&self, stage: Stage) -> Result<(), String> {
|
||||
self.state
|
||||
.lock()
|
||||
.map_err(|_| "Состояние обновления недоступно.".to_string())?
|
||||
.stage = stage;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn installation_kind<R: Runtime>(app: &AppHandle<R>) -> InstallationKind {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let env = app.env();
|
||||
if let (Some(image), Some(directory), Ok(executable)) = (
|
||||
env.appimage.as_ref(),
|
||||
env.appdir.as_ref(),
|
||||
std::env::current_exe(),
|
||||
) {
|
||||
if linux_appimage_supported(image.as_ref(), directory.as_ref(), &executable) {
|
||||
return InstallationKind::Appimage;
|
||||
}
|
||||
}
|
||||
if crate::deb_updater::installed_binary_supported() {
|
||||
return InstallationKind::Deb;
|
||||
}
|
||||
}
|
||||
let _ = app;
|
||||
InstallationKind::Other
|
||||
}
|
||||
|
||||
pub(crate) fn unsupported_reason<R: Runtime>(app: &AppHandle<R>) -> Option<String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
match installation_kind(app) {
|
||||
InstallationKind::Appimage => return None,
|
||||
InstallationKind::Deb => return crate::deb_updater::unsupported_reason(),
|
||||
InstallationKind::Other => return Some("Для автообновления установите deb-пакет или запустите AppImage с shacraft.ru/help#launcher.".into()),
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let _ = app;
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
|
||||
return Some("Для этой платформы доступна только ручная установка обновлений.".into());
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_appimage_supported(
|
||||
image: &std::path::Path,
|
||||
directory: &std::path::Path,
|
||||
executable: &std::path::Path,
|
||||
) -> bool {
|
||||
use std::io::Read;
|
||||
if !image.is_absolute()
|
||||
|| !directory.is_absolute()
|
||||
|| !executable.starts_with(directory)
|
||||
|| !image.is_file()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mut header = [0_u8; 11];
|
||||
std::fs::File::open(image)
|
||||
.and_then(|mut file| file.read_exact(&mut header))
|
||||
.is_ok()
|
||||
&& &header[..4] == b"\x7fELF"
|
||||
&& &header[8..11] == b"AI\x02"
|
||||
}
|
||||
|
||||
pub(crate) fn public_key<R: Runtime>(app: &AppHandle<R>) -> Result<String, String> {
|
||||
app.config()
|
||||
.plugins
|
||||
.0
|
||||
.get("updater")
|
||||
.and_then(|value| value.get("pubkey"))
|
||||
.and_then(Value::as_str)
|
||||
.filter(|key| !key.is_empty())
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| "В этой сборке отсутствует ключ проверки обновлений.".into())
|
||||
}
|
||||
|
||||
/// Resolve the startup AppImage path once. Release metadata and IPC never
|
||||
/// select an installation destination; symlink launch shortcuts remain usable.
|
||||
pub(crate) fn installation_path<R: Runtime>(
|
||||
app: &AppHandle<R>,
|
||||
) -> Result<Option<std::path::PathBuf>, String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if installation_kind(app) == InstallationKind::Deb {
|
||||
return Ok(None);
|
||||
}
|
||||
let image = app
|
||||
.env()
|
||||
.appimage
|
||||
.ok_or("Запустите AppImage, чтобы обновить лаунчер.")?;
|
||||
return std::fs::canonicalize(image)
|
||||
.map(Some)
|
||||
.map_err(|_| "Файл AppImage перемещён или недоступен. Запустите его снова.".into());
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = app;
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn trusted_builder<R: Runtime>(app: &AppHandle<R>) -> Result<UpdaterBuilder, String> {
|
||||
app.updater_builder()
|
||||
.endpoints(vec![Url::parse(UPDATE_ENDPOINT).expect("fixed update URL")])
|
||||
.map_err(|_| BAD_METADATA.to_string())
|
||||
.map(|builder| {
|
||||
builder
|
||||
.timeout(Duration::from_secs(20))
|
||||
.configure_client(|client| {
|
||||
client
|
||||
.https_only(true)
|
||||
.redirect(reqwest_updater::redirect::Policy::none())
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.timeout(Duration::from_secs(20))
|
||||
.danger_accept_invalid_certs(false)
|
||||
.danger_accept_invalid_hostnames(false)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn http_client(timeout: Duration) -> Result<reqwest::Client, String> {
|
||||
reqwest::Client::builder()
|
||||
.https_only(true)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.timeout(timeout)
|
||||
.user_agent(concat!("ShaCraft-Launcher/", env!("CARGO_PKG_VERSION")))
|
||||
.build()
|
||||
.map_err(|_| "Не удалось подключиться к серверу обновлений.".into())
|
||||
}
|
||||
|
||||
fn checked_length(current: usize, next: usize, maximum: usize) -> Result<usize, String> {
|
||||
current
|
||||
.checked_add(next)
|
||||
.filter(|size| *size <= maximum)
|
||||
.ok_or_else(|| "Размер ответа сервера обновлений превышает допустимый.".into())
|
||||
}
|
||||
|
||||
async fn bounded_response(
|
||||
mut response: reqwest::Response,
|
||||
maximum: usize,
|
||||
mut progress: impl FnMut(u64, Option<u64>),
|
||||
) -> Result<Vec<u8>, String> {
|
||||
if !response.status().is_success() || response.status() == reqwest::StatusCode::NO_CONTENT {
|
||||
return Err("Сервер обновлений временно недоступен. Повторите попытку позже.".into());
|
||||
}
|
||||
let total = response.content_length();
|
||||
if total.is_some_and(|length| length > maximum as u64) {
|
||||
return Err("Размер ответа сервера обновлений превышает допустимый.".into());
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(chunk) = response.chunk().await.map_err(|_| {
|
||||
"Загрузка обновления прервалась. Проверьте соединение и повторите попытку.".to_string()
|
||||
})? {
|
||||
checked_length(bytes.len(), chunk.len(), maximum)?;
|
||||
bytes.extend_from_slice(&chunk);
|
||||
progress(bytes.len() as u64, total);
|
||||
}
|
||||
if total.is_some_and(|length| length != bytes.len() as u64) {
|
||||
return Err("Обновление загружено не полностью. Повторите попытку.".into());
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Same Minisign format and verification semantics as Tauri's updater. The
|
||||
/// signed metadata and artifact each need a valid signature under the embedded
|
||||
/// release key. A signed old artifact cannot be labelled as a new version.
|
||||
pub(crate) fn verify_signature(
|
||||
bytes: &[u8],
|
||||
encoded_signature: &str,
|
||||
encoded_key: &str,
|
||||
) -> Result<(), String> {
|
||||
if encoded_signature.len() > 4096 || encoded_key.len() > 4096 {
|
||||
return Err(BAD_SIGNATURE.into());
|
||||
}
|
||||
let key_bytes = STANDARD.decode(encoded_key).map_err(|_| BAD_SIGNATURE)?;
|
||||
let signature_bytes = STANDARD
|
||||
.decode(encoded_signature)
|
||||
.map_err(|_| BAD_SIGNATURE)?;
|
||||
let key = PublicKey::decode(std::str::from_utf8(&key_bytes).map_err(|_| BAD_SIGNATURE)?)
|
||||
.map_err(|_| BAD_SIGNATURE)?;
|
||||
let signature =
|
||||
Signature::decode(std::str::from_utf8(&signature_bytes).map_err(|_| BAD_SIGNATURE)?)
|
||||
.map_err(|_| BAD_SIGNATURE)?;
|
||||
key.verify(bytes, &signature, true)
|
||||
.map_err(|_| BAD_SIGNATURE.into())
|
||||
}
|
||||
|
||||
pub(crate) fn verified_metadata(raw: &Value, key: &str) -> Result<Value, String> {
|
||||
let object = raw.as_object().ok_or(BAD_METADATA)?;
|
||||
if object.len() != 6 {
|
||||
return Err(BAD_METADATA.into());
|
||||
}
|
||||
let encoded = raw
|
||||
.get("signedPayload")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or(BAD_METADATA)?;
|
||||
if encoded.len() > MAX_PAYLOAD_BYTES * 4 / 3 + 4 {
|
||||
return Err(BAD_METADATA.into());
|
||||
}
|
||||
let payload = STANDARD.decode(encoded).map_err(|_| BAD_METADATA)?;
|
||||
if payload.len() > MAX_PAYLOAD_BYTES {
|
||||
return Err(BAD_METADATA.into());
|
||||
}
|
||||
let signature = raw
|
||||
.get("metadataSignature")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or(BAD_METADATA)?;
|
||||
verify_signature(&payload, signature, key).map_err(|_| BAD_METADATA)?;
|
||||
let parsed: Value = serde_json::from_slice(&payload).map_err(|_| BAD_METADATA)?;
|
||||
let signed = parsed.as_object().ok_or(BAD_METADATA)?;
|
||||
if signed.len() != 4 {
|
||||
return Err(BAD_METADATA.into());
|
||||
}
|
||||
for field in ["version", "notes", "pub_date", "platforms"] {
|
||||
if !signed.contains_key(field) || signed.get(field) != object.get(field) {
|
||||
return Err(BAD_METADATA.into());
|
||||
}
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
pub(crate) fn newer_version(metadata: &Value, current: &str) -> Result<bool, String> {
|
||||
let announced = metadata
|
||||
.get("version")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or(BAD_METADATA)?;
|
||||
let version = semver::Version::parse(announced).map_err(|_| BAD_METADATA)?;
|
||||
// Stable channel rejects prerelease/build aliases and noncanonical spellings.
|
||||
if !version.pre.is_empty() || !version.build.is_empty() || version.to_string() != announced {
|
||||
return Err(BAD_METADATA.into());
|
||||
}
|
||||
let current = semver::Version::parse(current).map_err(|_| BAD_METADATA)?;
|
||||
Ok(version > current)
|
||||
}
|
||||
|
||||
fn require_platform(metadata: &Value, kind: InstallationKind) -> Result<String, String> {
|
||||
let os = if cfg!(target_os = "macos") {
|
||||
"darwin"
|
||||
} else {
|
||||
std::env::consts::OS
|
||||
};
|
||||
let target = format!("{os}-{}", std::env::consts::ARCH);
|
||||
let platforms = metadata
|
||||
.get("platforms")
|
||||
.and_then(Value::as_object)
|
||||
.ok_or(BAD_METADATA)?;
|
||||
#[cfg(target_os = "linux")]
|
||||
let targets = match kind {
|
||||
InstallationKind::Deb => vec![format!("{target}-deb")],
|
||||
InstallationKind::Appimage => vec![format!("{target}-appimage"), target],
|
||||
InstallationKind::Other => {
|
||||
return Err("Формат установленного лаунчера не поддерживает обновление.".into())
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let targets = {
|
||||
let _ = kind;
|
||||
["nsis", "msi", "app"]
|
||||
.iter()
|
||||
.map(|bundle| format!("{target}-{bundle}"))
|
||||
.chain(std::iter::once(target.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
targets
|
||||
.into_iter()
|
||||
.find(|target| platforms.contains_key(target))
|
||||
.ok_or_else(|| "Обновление для вашего формата установки пока не опубликовано.".into())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_download_url(
|
||||
url: &Url,
|
||||
version: &str,
|
||||
kind: InstallationKind,
|
||||
) -> Result<(), String> {
|
||||
let prefix = format!("/downloads/shacraft-launcher/{version}/");
|
||||
let filename = url.path().strip_prefix(&prefix).ok_or(BAD_METADATA)?;
|
||||
if url.scheme() != "https"
|
||||
|| url.host_str() != Some("shacraft.ru")
|
||||
|| url.port().is_some()
|
||||
|| !url.username().is_empty()
|
||||
|| url.password().is_some()
|
||||
|| url.query().is_some()
|
||||
|| url.fragment().is_some()
|
||||
|| filename.is_empty()
|
||||
|| !filename.as_bytes()[0].is_ascii_alphanumeric()
|
||||
|| !filename
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte))
|
||||
{
|
||||
return Err(BAD_METADATA.into());
|
||||
}
|
||||
let correct_extension = if cfg!(target_os = "linux") {
|
||||
match kind {
|
||||
InstallationKind::Appimage => filename.ends_with(".AppImage"),
|
||||
InstallationKind::Deb => filename.ends_with(".deb"),
|
||||
InstallationKind::Other => false,
|
||||
}
|
||||
} else if cfg!(target_os = "macos") {
|
||||
filename.ends_with(".app.tar.gz")
|
||||
} else if cfg!(target_os = "windows") {
|
||||
filename.ends_with(".exe") || filename.ends_with(".msi")
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if !correct_extension {
|
||||
return Err(BAD_METADATA.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn candidate_kind(update: &Update) -> InstallationKind {
|
||||
if cfg!(target_os = "linux") {
|
||||
if update.target == format!("linux-{}-deb", std::env::consts::ARCH) {
|
||||
InstallationKind::Deb
|
||||
} else {
|
||||
InstallationKind::Appimage
|
||||
}
|
||||
} else {
|
||||
InstallationKind::Other
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch and authenticate a bounded static manifest before asking the vendored
|
||||
/// upstream plugin's small offline constructor to create an Update. Its normal
|
||||
/// HTTP check is intentionally unused because it buffers unbounded JSON.
|
||||
pub(crate) async fn check_candidate(
|
||||
builder: UpdaterBuilder,
|
||||
key: &str,
|
||||
current: &str,
|
||||
kind: InstallationKind,
|
||||
) -> Result<Option<Update>, String> {
|
||||
let response = http_client(Duration::from_secs(20))?
|
||||
.get(UPDATE_ENDPOINT)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| {
|
||||
"Не удалось проверить обновления. Проверьте подключение к интернету.".to_string()
|
||||
})?;
|
||||
let bytes = bounded_response(response, MAX_METADATA_BYTES, |_, _| {}).await?;
|
||||
let raw: Value = serde_json::from_slice(&bytes).map_err(|_| BAD_METADATA)?;
|
||||
let metadata = verified_metadata(&raw, key)?;
|
||||
let target = require_platform(&metadata, kind)?;
|
||||
if !newer_version(&metadata, current)? {
|
||||
return Ok(None);
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
let builder = builder.target(target);
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let _ = target;
|
||||
let update = builder
|
||||
.build()
|
||||
.map_err(updater_error)?
|
||||
.check_metadata(raw.clone())
|
||||
.map_err(updater_error)?
|
||||
.ok_or(BAD_METADATA)?;
|
||||
if update.raw_json != raw
|
||||
|| update.current_version != current
|
||||
|| Some(update.version.as_str()) != metadata.get("version").and_then(Value::as_str)
|
||||
{
|
||||
return Err(BAD_METADATA.into());
|
||||
}
|
||||
// Retain the exact signed envelope with the native-only candidate.
|
||||
verified_metadata(&update.raw_json, key)?;
|
||||
validate_download_url(
|
||||
&update.download_url,
|
||||
&update.version,
|
||||
candidate_kind(&update),
|
||||
)?;
|
||||
Ok(Some(update))
|
||||
}
|
||||
|
||||
pub(crate) async fn download_verified(
|
||||
update: &Update,
|
||||
key: &str,
|
||||
progress: impl FnMut(u64, Option<u64>),
|
||||
) -> Result<Vec<u8>, String> {
|
||||
validate_download_url(
|
||||
&update.download_url,
|
||||
&update.version,
|
||||
candidate_kind(update),
|
||||
)?;
|
||||
verified_metadata(&update.raw_json, key)?;
|
||||
let response = http_client(Duration::from_secs(600))?
|
||||
.get(update.download_url.clone())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| {
|
||||
"Не удалось загрузить обновление. Проверьте подключение и повторите попытку."
|
||||
.to_string()
|
||||
})?;
|
||||
let bytes = bounded_response(response, MAX_ARTIFACT_BYTES, progress).await?;
|
||||
verify_signature(&bytes, &update.signature, key)?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Keep verification adjacent to the only call that can replace the app. This
|
||||
/// also protects against an accidental mutation of bytes after downloading.
|
||||
pub(crate) fn install_verified(
|
||||
update: &Update,
|
||||
bytes: &[u8],
|
||||
key: &str,
|
||||
destination: Option<&std::path::Path>,
|
||||
) -> Result<(), String> {
|
||||
validate_download_url(
|
||||
&update.download_url,
|
||||
&update.version,
|
||||
candidate_kind(update),
|
||||
)?;
|
||||
verify_signature(bytes, &update.signature, key)?;
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if candidate_kind(update) == InstallationKind::Deb {
|
||||
return crate::deb_updater::install(update, bytes);
|
||||
}
|
||||
let destination = destination.ok_or("Файл AppImage недоступен.")?;
|
||||
install_appimage_atomic(destination, bytes).map_err(|_| {
|
||||
"Не удалось заменить AppImage. Проверьте свободное место и права на папку лаунчера."
|
||||
.into()
|
||||
})
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = destination;
|
||||
update.install(bytes).map_err(updater_error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Tauri 2.11 moves the old AppImage away before writing the new one. Use our
|
||||
/// atomic-file primitive on Linux so interruption during writing leaves the
|
||||
/// old executable intact. Other OS installers stay with the official plugin.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn install_appimage_atomic(destination: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
use std::{
|
||||
fs,
|
||||
io::{self, Write},
|
||||
os::unix::fs::PermissionsExt,
|
||||
};
|
||||
if bytes.len() < 11 || &bytes[..4] != b"\x7fELF" || &bytes[8..11] != b"AI\x02" {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"release is not a type-2 AppImage",
|
||||
));
|
||||
}
|
||||
let metadata = fs::symlink_metadata(destination)?;
|
||||
if !metadata.is_file() || metadata.file_type().is_symlink() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"AppImage must be a regular file",
|
||||
));
|
||||
}
|
||||
let parent =
|
||||
fs::File::open(destination.parent().ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::InvalidInput, "AppImage has no parent")
|
||||
})?)?;
|
||||
let mut output = crate::storage::AtomicFile::new(destination)?;
|
||||
output.writer().write_all(bytes)?;
|
||||
output.writer().set_permissions(fs::Permissions::from_mode(
|
||||
metadata.permissions().mode() & 0o777,
|
||||
))?;
|
||||
output.commit()?;
|
||||
parent.sync_all()
|
||||
}
|
||||
|
||||
pub(crate) fn updater_error(error: tauri_plugin_updater::Error) -> String {
|
||||
match error {
|
||||
tauri_plugin_updater::Error::TargetNotFound(_) | tauri_plugin_updater::Error::TargetsNotFound(_) =>
|
||||
"Обновление для вашей платформы пока не опубликовано.".into(),
|
||||
tauri_plugin_updater::Error::Io(_) | tauri_plugin_updater::Error::TempDirNotOnSameMountPoint =>
|
||||
"Не удалось заменить файл лаунчера. Проверьте свободное место и права на папку приложения.".into(),
|
||||
tauri_plugin_updater::Error::Minisign(_) | tauri_plugin_updater::Error::Base64(_) |
|
||||
tauri_plugin_updater::Error::SignatureUtf8(_) => BAD_SIGNATURE.into(),
|
||||
_ => "Не удалось установить обновление. Повторите попытку или скачайте лаунчер с shacraft.ru.".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn artifact_url() -> &'static str {
|
||||
if cfg!(target_os = "linux") {
|
||||
"https://shacraft.ru/downloads/shacraft-launcher/0.2.0/ShaCraft_0.2.0.AppImage"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"https://shacraft.ru/downloads/shacraft-launcher/0.2.0/ShaCraft_0.2.0.app.tar.gz"
|
||||
} else {
|
||||
"https://shacraft.ru/downloads/shacraft-launcher/0.2.0/ShaCraft_0.2.0.exe"
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_policy_pins_origin_version_plain_path_and_package_type() {
|
||||
assert!(validate_download_url(
|
||||
&Url::parse(artifact_url()).unwrap(),
|
||||
"0.2.0",
|
||||
InstallationKind::Appimage
|
||||
)
|
||||
.is_ok());
|
||||
for value in [
|
||||
artifact_url().replace("https:", "http:"),
|
||||
artifact_url().replace("shacraft.ru/", "evil.example/"),
|
||||
artifact_url().replace("shacraft.ru/", "shacraft.ru:8443/"),
|
||||
artifact_url().replace("https://", "https://user@"),
|
||||
format!("{}?url=x", artifact_url()),
|
||||
format!("{}#x", artifact_url()),
|
||||
artifact_url().replace("/0.2.0/", "/0.1.0/"),
|
||||
artifact_url().replace("ShaCraft_", "%53haCraft_"),
|
||||
artifact_url().replace("ShaCraft_", "nested/ShaCraft_"),
|
||||
format!("{}.sh", artifact_url()),
|
||||
] {
|
||||
assert!(
|
||||
validate_download_url(
|
||||
&Url::parse(&value).unwrap(),
|
||||
"0.2.0",
|
||||
InstallationKind::Appimage
|
||||
)
|
||||
.is_err(),
|
||||
"{value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn linux_selects_package_family_without_deb_fallback() {
|
||||
let base = format!("linux-{}", std::env::consts::ARCH);
|
||||
let legacy = serde_json::json!({"platforms": {base.clone(): {}}});
|
||||
assert_eq!(
|
||||
require_platform(&legacy, InstallationKind::Appimage).unwrap(),
|
||||
base
|
||||
);
|
||||
assert!(require_platform(&legacy, InstallationKind::Deb).is_err());
|
||||
let exact_image = format!("{base}-appimage");
|
||||
let exact_deb = format!("{base}-deb");
|
||||
let all = serde_json::json!({"platforms": {base.clone(): {}, exact_image.clone(): {}, exact_deb.clone(): {}}});
|
||||
assert_eq!(
|
||||
require_platform(&all, InstallationKind::Appimage).unwrap(),
|
||||
exact_image
|
||||
);
|
||||
assert_eq!(
|
||||
require_platform(&all, InstallationKind::Deb).unwrap(),
|
||||
exact_deb
|
||||
);
|
||||
let deb = Url::parse(
|
||||
"https://shacraft.ru/downloads/shacraft-launcher/0.2.0/ShaCraft_0.2.0_amd64.deb",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(validate_download_url(&deb, "0.2.0", InstallationKind::Deb).is_ok());
|
||||
assert!(validate_download_url(&deb, "0.2.0", InstallationKind::Appimage).is_err());
|
||||
assert!(validate_download_url(
|
||||
&Url::parse(artifact_url()).unwrap(),
|
||||
"0.2.0",
|
||||
InstallationKind::Deb
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_channel_never_downgrades_or_installs_equal_aliases() {
|
||||
assert!(newer_version(&serde_json::json!({"version":"0.2.0"}), "0.1.3").unwrap());
|
||||
for version in ["0.1.2", "0.1.3"] {
|
||||
assert!(!newer_version(&serde_json::json!({"version":version}), "0.1.3").unwrap());
|
||||
}
|
||||
for version in ["v0.2.0", "0.2.0-test", "0.2.0+extra", "00.2.0", "../0.2.0"] {
|
||||
assert!(newer_version(&serde_json::json!({"version":version}), "0.1.3").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_size_limit_handles_missing_length_and_overflow() {
|
||||
assert_eq!(checked_length(3, 5, 8).unwrap(), 8);
|
||||
assert!(checked_length(3, 6, 8).is_err());
|
||||
assert!(checked_length(usize::MAX, 1, usize::MAX).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_or_oversized_metadata_proof_is_rejected() {
|
||||
assert!(verified_metadata(&serde_json::json!({"version":"0.2.0"}), "").is_err());
|
||||
let raw = serde_json::json!({"version":"0.2.0","notes":"","pub_date":"","platforms":{},
|
||||
"signedPayload":"A".repeat(MAX_PAYLOAD_BYTES * 2),"metadataSignature":""});
|
||||
assert!(verified_metadata(&raw, "").is_err());
|
||||
}
|
||||
|
||||
fn fixture() -> Value {
|
||||
// Public test key/signatures only. The ephemeral private key was
|
||||
// discarded by the publisher tests and never enters this repository.
|
||||
serde_json::from_str(include_str!("../tests/fixtures/updater-signed.json")).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn genuine_metadata_and_artifact_signatures_pass_and_tampering_fails() {
|
||||
let fixture = fixture();
|
||||
let key = fixture["publicKey"].as_str().unwrap();
|
||||
let raw = &fixture["metadata"];
|
||||
assert!(verified_metadata(raw, key).is_ok());
|
||||
let signature = raw["platforms"]["linux-x86_64"]["signature"]
|
||||
.as_str()
|
||||
.unwrap();
|
||||
let bytes = fixture["artifactText"].as_str().unwrap().as_bytes();
|
||||
assert!(verify_signature(bytes, signature, key).is_ok());
|
||||
let mut corrupt = bytes.to_vec();
|
||||
corrupt[0] ^= 1;
|
||||
assert!(verify_signature(&corrupt, signature, key).is_err());
|
||||
assert!(
|
||||
verify_signature(bytes, &STANDARD.encode("invalid minisign signature"), key).is_err()
|
||||
);
|
||||
assert!(
|
||||
verify_signature(bytes, signature, &STANDARD.encode("invalid public key")).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relabelling_a_signed_old_artifact_or_changing_signed_fields_fails() {
|
||||
let fixture = fixture();
|
||||
let key = fixture["publicKey"].as_str().unwrap();
|
||||
for field in ["version", "notes", "pub_date", "platforms"] {
|
||||
let mut raw = fixture["metadata"].clone();
|
||||
raw[field] = Value::String("tampered".into());
|
||||
assert!(verified_metadata(&raw, key).is_err(), "{field}");
|
||||
}
|
||||
let mut raw = fixture["metadata"].clone();
|
||||
let mut payload: Value = serde_json::from_slice(
|
||||
&STANDARD
|
||||
.decode(raw["signedPayload"].as_str().unwrap())
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
raw["version"] = Value::String("99.0.0".into());
|
||||
payload["version"] = Value::String("99.0.0".into());
|
||||
raw["signedPayload"] =
|
||||
Value::String(STANDARD.encode(serde_json::to_vec(&payload).unwrap()));
|
||||
assert!(verified_metadata(&raw, key).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_platform_is_not_reported_as_latest() {
|
||||
assert!(require_platform(
|
||||
&serde_json::json!({"platforms":{}}),
|
||||
InstallationKind::Appimage
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn linux_support_requires_actual_appimage_and_matching_appdir() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let image = directory.path().join("launcher.AppImage");
|
||||
let appdir = directory.path().join(".mount_test");
|
||||
let binary = appdir.join("usr/bin/shacraft-launcher");
|
||||
std::fs::write(&image, b"\x7fELF\x02\x01\x01\0AI\x02rest").unwrap();
|
||||
assert!(linux_appimage_supported(&image, &appdir, &binary));
|
||||
assert!(!linux_appimage_supported(
|
||||
&image,
|
||||
&appdir,
|
||||
&directory.path().join("raw-binary")
|
||||
));
|
||||
std::fs::write(&image, b"not a valid AppImage").unwrap();
|
||||
assert!(!linux_appimage_supported(&image, &appdir, &binary));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn appimage_replacement_preserves_old_on_error_and_retains_executable_mode() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let image = directory.path().join("launcher.AppImage");
|
||||
let old = b"\x7fELF\x02\x01\x01\0AI\x02old";
|
||||
let new = b"\x7fELF\x02\x01\x01\0AI\x02new";
|
||||
std::fs::write(&image, old).unwrap();
|
||||
std::fs::set_permissions(&image, std::fs::Permissions::from_mode(0o751)).unwrap();
|
||||
assert!(install_appimage_atomic(&image, b"wrong package").is_err());
|
||||
assert_eq!(std::fs::read(&image).unwrap(), old);
|
||||
assert!(install_appimage_atomic(&directory.path().join("missing"), new).is_err());
|
||||
assert_eq!(std::fs::read(&image).unwrap(), old);
|
||||
install_appimage_atomic(&image, new).unwrap();
|
||||
assert_eq!(std::fs::read(&image).unwrap(), new);
|
||||
assert_eq!(
|
||||
std::fs::metadata(&image).unwrap().permissions().mode() & 0o777,
|
||||
0o751
|
||||
);
|
||||
assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
/// Read-only production HTTPS requests; replacement occurs ONLY in a new
|
||||
/// temporary copy of the path supplied by the operator. No QA switches or
|
||||
/// alternative endpoints are compiled into a distributed launcher.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
#[ignore = "requires published signed update and SHACRAFT_UPDATER_TEST_IMAGE pointing to an old AppImage"]
|
||||
fn live_signed_update_replaces_only_temporary_copy() {
|
||||
use sha2::{Digest, Sha256};
|
||||
let source = std::path::PathBuf::from(
|
||||
std::env::var_os("SHACRAFT_UPDATER_TEST_IMAGE").expect("old AppImage path"),
|
||||
);
|
||||
assert!(source.is_file());
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let destination = directory.path().join("isolated-old.AppImage");
|
||||
std::fs::copy(&source, &destination).unwrap();
|
||||
let old_hash = Sha256::digest(std::fs::read(&destination).unwrap());
|
||||
let config: Value = serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
|
||||
let mut context = tauri::test::mock_context(tauri::test::noop_assets());
|
||||
context
|
||||
.config_mut()
|
||||
.plugins
|
||||
.0
|
||||
.insert("updater".into(), config["plugins"]["updater"].clone());
|
||||
context.package_info_mut().version = "0.1.2".parse().unwrap();
|
||||
let app = tauri::test::mock_builder()
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.build(context)
|
||||
.unwrap();
|
||||
let key = public_key(app.handle()).unwrap();
|
||||
let builder = trusted_builder(app.handle())
|
||||
.unwrap()
|
||||
.executable_path(&destination);
|
||||
tauri::async_runtime::block_on(async {
|
||||
let update = check_candidate(builder, &key, "0.1.2", InstallationKind::Appimage)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("newer published version");
|
||||
let mut bytes = download_verified(&update, &key, |_, _| {}).await.unwrap();
|
||||
let new_hash = Sha256::digest(&bytes);
|
||||
assert_ne!(old_hash, new_hash);
|
||||
bytes[0] ^= 1;
|
||||
assert!(install_verified(&update, &bytes, &key, Some(&destination)).is_err());
|
||||
assert_eq!(
|
||||
old_hash,
|
||||
Sha256::digest(std::fs::read(&destination).unwrap())
|
||||
);
|
||||
bytes[0] ^= 1;
|
||||
install_verified(&update, &bytes, &key, Some(&destination)).unwrap();
|
||||
assert_eq!(
|
||||
new_hash,
|
||||
Sha256::digest(std::fs::read(&destination).unwrap())
|
||||
);
|
||||
assert_eq!(old_hash, Sha256::digest(std::fs::read(&source).unwrap()));
|
||||
println!(
|
||||
"Verified signed update {} and tamper rejection; replaced only isolated copy",
|
||||
update.version
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ShaCraft Launcher",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.7",
|
||||
"identifier": "ru.shacraft.launcher",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -28,6 +28,7 @@
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"createUpdaterArtifacts": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
@@ -35,6 +36,26 @@
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
],
|
||||
"linux": {
|
||||
"deb": {
|
||||
"depends": [
|
||||
"libwebkit2gtk-4.1-0",
|
||||
"libgtk-3-0",
|
||||
"pkexec"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEY3NUEyMDIwOUM3QTFFRTYKUldUbUhucWNJQ0JhOTFnUUo4d0Rmc1JxOVdyRElCYTRranJKRzZEYzloRGNXQ09NL1kvN042OEsK",
|
||||
"endpoints": [
|
||||
"https://shacraft.ru/launcher/updates/stable-v2.json"
|
||||
],
|
||||
"windows": {
|
||||
"installMode": "passive"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"publicKey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXkgM0FGNTgxMjY4QTBERUU5NgpSV1NXN2cyS0pvSDFPczhocEIzTmp1bjM1TnRRWWE1QnIyck00bDRmZW1sQlphQXY2MTZvTWcwZgo=",
|
||||
"metadata": {
|
||||
"notes": "Проверка обновления",
|
||||
"platforms": {
|
||||
"linux-x86_64": {
|
||||
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIG1pbmlzaWduIHNlY3JldCBrZXkKUlVTVzdnMktKb0gxT2dZZmwrT0V0b0pYbDdYU3dSang1TXJNZXdtTDZvUVNvU1FMU2ZsajJkL2d4bXlOVDdQNmt4eEExeUtGcG1zckNWcEhPVXd4TnBJOU9ublZWbHk0NFFjPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTk1ODU1CWZpbGU6Zml4dHVyZS5BcHBJbWFnZQloYXNoZWQKdWxLSklQR3pabTcxdkNaUmM3d3FHbTRRSDI5dzY4UEU0QXY3NE9MazVWdGRPallpOTQ0a2RSK1AyRDBaeEZ5eGdERFgvbmVtZEluUXc4UFdUOWV6Qnc9PQo=",
|
||||
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.3/fixture.AppImage"
|
||||
}
|
||||
},
|
||||
"pub_date": "2026-09-10T00:00:00Z",
|
||||
"version": "0.1.3",
|
||||
"signedPayload": "eyJub3RlcyI6ItCf0YDQvtCy0LXRgNC60LAg0L7QsdC90L7QstC70LXQvdC40Y8iLCJwbGF0Zm9ybXMiOnsibGludXgteDg2XzY0Ijp7InNpZ25hdHVyZSI6ImRXNTBjblZ6ZEdWa0lHTnZiVzFsYm5RNklITnBaMjVoZEhWeVpTQm1jbTl0SUcxcGJtbHphV2R1SUhObFkzSmxkQ0JyWlhrS1VsVlRWemRuTWt0S2IwZ3hUMmRaWm13clQwVjBiMHBZYkRkWVUzZFNhbmcxVFhKTlpYZHRURFp2VVZOdlUxRk1VMlpzYWpKa0wyZDRiWGxPVkRkUU5tdDRlRUV4ZVV0R2NHMXpja05XY0VoUFZYZDRUbkJKT1U5dWJsWldiSGswTkZGalBRcDBjblZ6ZEdWa0lHTnZiVzFsYm5RNklIUnBiV1Z6ZEdGdGNEb3hOemc0T1RrMU9EVTFDV1pwYkdVNlptbDRkSFZ5WlM1QmNIQkpiV0ZuWlFsb1lYTm9aV1FLZFd4TFNrbFFSM3BhYlRjeGRrTmFVbU0zZDNGSGJUUlJTREk1ZHpZNFVFVTBRWFkzTkU5TWF6VldkR1JQYWxscE9UUTBhMlJTSzFBeVJEQmFlRVo1ZUdkRVJGZ3ZibVZ0WkVsdVVYYzRVRmRVT1dWNlFuYzlQUW89IiwidXJsIjoiaHR0cHM6Ly9zaGFjcmFmdC5ydS9kb3dubG9hZHMvc2hhY3JhZnQtbGF1bmNoZXIvMC4xLjMvZml4dHVyZS5BcHBJbWFnZSJ9fSwicHViX2RhdGUiOiIyMDI2LTA5LTEwVDAwOjAwOjAwWiIsInZlcnNpb24iOiIwLjEuMyJ9",
|
||||
"metadataSignature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIG1pbmlzaWduIHNlY3JldCBrZXkKUlVTVzdnMktKb0gxT3YrcXkzN0haeGZoeHBML0pMdXJYSXRjSE1vQ2VPZkg3bFpHZjRHbWVzOG1wdlJLcWRxUlJIaW11NElydkcxMk5jWStEMGtZSEI5UXAwak1TLzdjRXdnPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTk1ODU1CWZpbGU6cGF5bG9hZC5qc29uCWhhc2hlZApEMGVQWFJCQW9RVTFaRnNDbG8xMnZOekpHcy9Pb0xEN0hHaDFiMkxJQ294WDBkeEszY0s2aGl3QWMzWEtFdmtIakRxOEx4VlF5UHJoZnpYSDJ1ZEdBQT09Cg=="
|
||||
},
|
||||
"artifactText": "isolated ShaCraft updater fixture; not an executable"
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { FeedbackDialog, type Feedback } from './components/FeedbackDialog'
|
||||
import { Library } from './components/Library'
|
||||
import { RecoveryCodesModal } from './components/RecoveryCodesModal'
|
||||
import { PlayDock } from './components/PlayDock'
|
||||
import { ServerStage } from './components/ServerStage'
|
||||
import { SettingsDrawer } from './components/SettingsDrawer'
|
||||
import { Titlebar } from './components/Titlebar'
|
||||
import { servers } from './data/servers'
|
||||
import { useAccount } from './hooks/useAccount'
|
||||
import { useLauncher } from './hooks/useLauncher'
|
||||
import { useLauncherUpdate } from './hooks/useLauncherUpdate'
|
||||
import { useServerStatus } from './hooks/useServerStatus'
|
||||
import { useSettings } from './hooks/useSettings'
|
||||
import { isNative } from './services/native'
|
||||
import { launchAccess } from './state/account'
|
||||
import { installStageLabels } from './state/game'
|
||||
|
||||
export function App() {
|
||||
const [selected, setSelected] = useState(servers[0])
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [windowError, setWindowError] = useState<string | null>(null)
|
||||
const [errorFeedback, setErrorFeedback] = useState<Feedback | null>(null)
|
||||
const preferences = useSettings()
|
||||
const session = useAccount()
|
||||
const launcher = useLauncher()
|
||||
const serverStatus = useServerStatus(selected.profileId)
|
||||
const closeSettings = useCallback(() => setSettingsOpen(false), [])
|
||||
const desktop = isNative()
|
||||
const profile = launcher.profiles[selected.profileId]
|
||||
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 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 (operation.phase === 'running') label = 'Игра запущена'
|
||||
else if (operation.phase === 'launching') label = 'Запускаем…'
|
||||
else if (operation.phase === 'installing') label = operation.progress ? `${installStageLabels[operation.progress.stage]}…` : 'Подготовка…'
|
||||
else if (operation.phase === 'syncing') label = 'Обновление'
|
||||
else if (access === 'loading') label = 'Загрузка…'
|
||||
else if (access === 'login') label = 'Войти в ShaCraft'
|
||||
else if (access === 'link') label = 'Привязать ник'
|
||||
else if (preferences.saving) label = 'Сохраняем…'
|
||||
else if (!preferences.loaded) label = 'Загрузка…'
|
||||
else if (checking) label = 'Проверяем…'
|
||||
else if (!ready) label = 'Проверить'
|
||||
|
||||
const primary = () => {
|
||||
if (disabled) return
|
||||
if (access !== 'ready') setSettingsOpen(true)
|
||||
else void launcher.launch(selected.profileId)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<Titlebar host={launcher.host} onError={setWindowError} />
|
||||
<div className="workspace" inert={session.recoveryCodes.length > 0}>
|
||||
<Library selected={selected} profiles={launcher.profiles} account={session.account}
|
||||
native={desktop} locked={busy || updateLocked || session.busy} onSelect={setSelected} onSettings={() => setSettingsOpen(true)} />
|
||||
<ServerStage server={selected} status={serverStatus}>
|
||||
<PlayDock server={selected} operation={operation} profile={profile}
|
||||
memoryGb={preferences.settings.memoryMb / 1024} native={desktop}
|
||||
needsLogin={access === 'login'} needsLink={access === 'link'}
|
||||
error={error} label={label} primaryDisabled={disabled} repairDisabled={repairDisabled}
|
||||
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} />
|
||||
<RecoveryCodesModal codes={session.recoveryCodes} onAcknowledge={session.acknowledgeRecoveryCodes} />
|
||||
<FeedbackDialog feedback={session.recoveryCodes.length ? null : session.feedback ?? errorFeedback}
|
||||
onDismiss={() => { session.dismissFeedback(); setErrorFeedback(null) }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useState } from 'react'
|
||||
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 }) {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [registering, setRegistering] = useState(false)
|
||||
const [nickname, setNickname] = useState('')
|
||||
const { account, linkedNickname, busy } = session
|
||||
const disabled = locked || busy || account === undefined
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="setting-row static"><span><Users />Аккаунт</span><small>{account === undefined ? 'Проверяем…' : account?.username ?? 'Не авторизован'}</small></div>
|
||||
{!account && (
|
||||
<form onSubmit={async (event) => {
|
||||
event.preventDefault()
|
||||
if (disabled) return
|
||||
if (await session.authenticate(username, password, registering)) setPassword('')
|
||||
}}>
|
||||
<label className="text-setting">
|
||||
<span><strong>Логин ShaCraft</strong><small>3–32 символа</small></span>
|
||||
<input value={username} minLength={3} maxLength={32} pattern="[A-Za-z0-9_]{3,32}" required autoComplete="username"
|
||||
disabled={disabled} onChange={(event) => setUsername(event.target.value)} placeholder="Логин" />
|
||||
</label>
|
||||
<label className="text-setting">
|
||||
<span><strong>Пароль</strong><small>Минимум 3 символа</small></span>
|
||||
<input type="password" value={password} minLength={3} maxLength={128} required
|
||||
autoComplete={registering ? 'new-password' : 'current-password'} disabled={disabled}
|
||||
onChange={(event) => setPassword(event.target.value)} placeholder="Пароль" />
|
||||
</label>
|
||||
<button className="setting-row" type="submit" disabled={disabled || !isNative()}>
|
||||
<span>{busy ? 'Подождите…' : registering ? 'Создать аккаунт' : 'Войти'}</span>
|
||||
</button>
|
||||
<button className="setting-row" type="button" disabled={disabled} onClick={() => { setRegistering(!registering); session.clearError() }}>
|
||||
<span>{registering ? 'Уже есть аккаунт' : 'Нет аккаунта — регистрация'}</span>
|
||||
</button>
|
||||
{!isNative() && <p className="account-hint">Вход и регистрация доступны в приложении лаунчера.</p>}
|
||||
</form>
|
||||
)}
|
||||
{account && !linkedNickname && (
|
||||
<form noValidate onSubmit={(event) => { event.preventDefault(); if (!disabled) void session.startLink(nickname) }}>
|
||||
<label className="text-setting">
|
||||
<span><strong>Игровой ник</strong><small>Общий ник ShaCraft</small></span>
|
||||
<input value={nickname} minLength={3} maxLength={16} pattern="[A-Za-z0-9_]{3,16}" required
|
||||
disabled={disabled || session.linking} onChange={(event) => setNickname(event.target.value)} placeholder="Player" />
|
||||
<small>Свободный ник закрепляется за аккаунтом. Для старого игрового ника обратитесь к администратору.</small>
|
||||
</label>
|
||||
<button className="setting-row" type="submit" disabled={disabled || session.linking}>
|
||||
<span>{session.linking ? 'Ожидаем подтверждения…' : busy ? 'Создаём проверку…' : 'Привязать ник'}</span>
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{account && linkedNickname && <div className="setting-row static"><span>Игровой ник</span><small>{linkedNickname}</small></div>}
|
||||
{session.linkMessage && <p className="account-hint" role="status">{session.linkMessage}</p>}
|
||||
{session.error && <p className="status-error account-hint" role="alert">{session.error}</p>}
|
||||
{account && <button className="setting-row" disabled={disabled} onClick={session.logout}><span><LogOut />Выйти из ShaCraft</span></button>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
export interface Feedback {
|
||||
kind: 'info' | 'success' | 'error'
|
||||
title: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export function FeedbackDialog({ feedback, onDismiss }: { feedback: Feedback | null; onDismiss: () => void }) {
|
||||
const dialog = useRef<HTMLDialogElement>(null)
|
||||
const visible = feedback !== null
|
||||
useEffect(() => {
|
||||
const element = dialog.current
|
||||
if (!visible || !element) return
|
||||
const previous = document.activeElement
|
||||
element.showModal()
|
||||
return () => {
|
||||
element.close()
|
||||
if (previous instanceof HTMLElement && previous.isConnected) previous.focus()
|
||||
}
|
||||
}, [visible])
|
||||
if (!feedback) return null
|
||||
return (
|
||||
<dialog ref={dialog} className={`feedback-dialog ${feedback.kind}`} aria-labelledby="feedback-title"
|
||||
aria-describedby="feedback-message" onCancel={(event) => { event.preventDefault(); onDismiss() }}
|
||||
onKeyDown={(event) => event.stopPropagation()}>
|
||||
<div aria-live={feedback.kind === 'error' ? 'assertive' : 'polite'} aria-atomic="true">
|
||||
<h2 id="feedback-title">{feedback.title}</h2>
|
||||
<p id="feedback-message">{feedback.message}</p>
|
||||
</div>
|
||||
<button type="button" autoFocus onClick={onDismiss}>Понятно</button>
|
||||
</dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ArrowDownToLine, RefreshCw } from 'lucide-react'
|
||||
import type { useLauncherUpdate } from '../hooks/useLauncherUpdate'
|
||||
import { isNative } from '../services/native'
|
||||
import { updatePercent } from '../state/updater'
|
||||
|
||||
export function LauncherUpdateSettings({ updater }: { updater: ReturnType<typeof useLauncherUpdate> }) {
|
||||
const { state, blocked, eventsReady, eventError } = updater
|
||||
const { phase, status, error } = state
|
||||
const percent = updatePercent(state.progress)
|
||||
const working = phase === 'downloading' || phase === 'installing'
|
||||
const ready = phase === 'ready' || phase === 'restarting'
|
||||
const checking = phase === 'loading' || phase === 'checking'
|
||||
const deb = status?.installationKind === 'deb'
|
||||
|
||||
return <section className="launcher-update" aria-labelledby="launcher-update-title">
|
||||
<div className="launcher-update-heading">
|
||||
<h3 id="launcher-update-title">ShaCraft Launcher</h3>
|
||||
{status?.currentVersion && <span>{status.currentVersion}</span>}
|
||||
</div>
|
||||
<div className="launcher-update-status" aria-live="polite">
|
||||
{!isNative() ? <p>Обновления доступны в приложении лаунчера.</p>
|
||||
: ready ? <p className="update-success">Обновление установлено. Перезапустите лаунчер.</p>
|
||||
: working ? <p>{phase === 'installing' ? deb
|
||||
? 'Подтвердите установку в системном окне и дождитесь завершения.'
|
||||
: 'Проверяем подпись и устанавливаем…'
|
||||
: `Скачиваем обновление${percent === null ? '…' : ` · ${percent}%`}`}</p>
|
||||
: checking ? <p>Проверяем обновления…</p>
|
||||
: status?.supported === false ? <p>{status.reason || 'Для этой установки обновление доступно вручную на shacraft.ru/help#launcher.'}</p>
|
||||
: status?.version ? <p className="update-success">Доступна версия {status.version}</p>
|
||||
: state.checked && !error ? <p>У вас последняя версия.</p>
|
||||
: <p>Проверка новой версии лаунчера.</p>}
|
||||
{working && <progress aria-label={phase === 'installing' ? 'Установка обновления лаунчера' : 'Загрузка обновления лаунчера'} max={100} value={phase === 'installing' ? undefined : percent ?? undefined} />}
|
||||
</div>
|
||||
{status?.notes && status.version && !working && !ready && <details className="update-notes">
|
||||
<summary>Что нового</summary><p>{status.notes.slice(0, 1600)}</p>
|
||||
</details>}
|
||||
{error && <p className="status-error update-error" role="status">{error}</p>}
|
||||
{eventError && <p className="status-error update-error" role="status">{eventError} Перезапустите лаунчер, чтобы включить установку обновлений.</p>}
|
||||
{deb && status?.supported && status.version && !working && !ready &&
|
||||
<p className="update-hint">Для обновления deb потребуется подтверждение администратора в системном окне.</p>}
|
||||
{isNative() && <div className="update-actions">
|
||||
{ready ? <button className="update-primary" disabled={blocked || phase === 'restarting'} onClick={() => void updater.restart()}>
|
||||
<RefreshCw />{phase === 'restarting' ? 'Перезапускаем…' : 'Перезапустить лаунчер'}
|
||||
</button> : <>
|
||||
{status?.supported && status.version && <button className="update-primary"
|
||||
disabled={blocked || working || checking || !eventsReady} onClick={() => void updater.install()}>
|
||||
<ArrowDownToLine />{working ? 'Обновляем…' : 'Обновить'}
|
||||
</button>}
|
||||
{status?.supported !== false && <button className="update-check" disabled={checking || working} onClick={() => void updater.check()}>
|
||||
{checking ? 'Проверяем…' : error ? 'Повторить проверку' : 'Проверить обновления'}
|
||||
</button>}
|
||||
</>}
|
||||
</div>}
|
||||
{blocked && status?.supported && status.version && !working && <p className="update-hint">Завершите игру и текущие операции, чтобы обновить лаунчер.</p>}
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { ChevronRight, Settings } from 'lucide-react'
|
||||
import { servers } from '../data/servers'
|
||||
import { linkedNickname } from '../state/account'
|
||||
import type { ProfileState } from '../state/profiles'
|
||||
import type { ShaCraftAccount, Server } from '../types/launcher'
|
||||
|
||||
interface LibraryProps {
|
||||
selected: Server
|
||||
profiles: Record<string, ProfileState>
|
||||
account: ShaCraftAccount | null | undefined
|
||||
locked: boolean
|
||||
native: boolean
|
||||
onSelect: (server: Server) => void
|
||||
onSettings: () => void
|
||||
}
|
||||
|
||||
export function Library({ selected, profiles, account, locked, native, onSelect, onSettings }: LibraryProps) {
|
||||
const nickname = linkedNickname(account)
|
||||
const name = account === undefined ? 'Проверяем…' : account === null ? 'Не авторизован' : nickname ?? account.username
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="rail" aria-label="Настройки лаунчера">
|
||||
<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="server-list">
|
||||
{servers.map((server) => {
|
||||
const profile = profiles[server.profileId]
|
||||
const status = !native ? 'Доступна' : !profile || profile.status === 'checking'
|
||||
? 'Проверяем…' : profile.inspection?.upToDate ? 'Файлы проверены' : 'Требуется проверка'
|
||||
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-copy"><strong>{server.name}</strong><small>{status}</small></span>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<button className="account-chip" onClick={onSettings} aria-label="Аккаунт ShaCraft">
|
||||
<span className="avatar">{account ? (nickname ?? account.username).slice(0, 2).toUpperCase() : '?'}</span>
|
||||
<span><strong>{name}</strong><small>{account ? `ShaCraft · ${account.username}` : 'Войдите, чтобы играть'}</small></span>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Download, Gauge, Globe2, Play, RotateCcw, ShieldCheck } from 'lucide-react'
|
||||
import type { ProfileState } from '../state/profiles'
|
||||
import { installPercent, installStageLabels } from '../state/game'
|
||||
import type { GameOperation } from '../state/game'
|
||||
import type { Server } from '../types/launcher'
|
||||
|
||||
interface PlayDockProps {
|
||||
server: Server
|
||||
operation: GameOperation
|
||||
profile: ProfileState | undefined
|
||||
memoryGb: number
|
||||
native: boolean
|
||||
needsLogin: boolean
|
||||
needsLink: boolean
|
||||
error: string | null
|
||||
label: string
|
||||
primaryDisabled: boolean
|
||||
repairDisabled: boolean
|
||||
onPrimary: () => void
|
||||
onRepair: () => void
|
||||
}
|
||||
|
||||
export function PlayDock(props: PlayDockProps) {
|
||||
const { server, operation, profile, memoryGb, needsLogin, error } = props
|
||||
const progress = operation.phase === 'installing' ? operation.progress : null
|
||||
const percent = installPercent(progress)
|
||||
const working = operation.phase === 'syncing' || operation.phase === 'installing' || operation.phase === 'launching'
|
||||
let title: string
|
||||
let detail: string
|
||||
if (operation.phase === 'installing') {
|
||||
title = progress ? installStageLabels[progress.stage] : 'Готовим установку'
|
||||
detail = percent === null ? 'Проверяем файлы…' : `${percent}%`
|
||||
} else if (operation.phase === 'launching') {
|
||||
title = 'Запускаем игру'; detail = 'Подготавливаем игровой процесс…'
|
||||
} else if (operation.phase === 'running') {
|
||||
title = 'Игра запущена'; detail = 'Вернитесь после завершения игры'
|
||||
} else if (operation.phase === 'syncing') {
|
||||
title = 'Синхронизируем сборку'; detail = 'Скачиваем и проверяем файлы'
|
||||
} else if (!props.native) {
|
||||
title = 'Предпросмотр интерфейса'; detail = 'Установка и запуск доступны в приложении'
|
||||
} else {
|
||||
title = needsLogin ? 'Нужен вход ShaCraft' : props.needsLink ? 'Нужно привязать ник' : profile?.status === 'checking' ? 'Проверяем сборку'
|
||||
: profile?.inspection?.upToDate ? 'Сборка готова' : 'Требуется проверка'
|
||||
detail = profile?.inspection ? `${profile.inspection.managedFiles} файлов под контролем` : 'Проверяем локальные файлы'
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="play-dock">
|
||||
<div className="build-state" aria-live="polite">
|
||||
<span className={`state-icon ${working ? 'downloading' : ''}`}>
|
||||
{working ? <Download size={19} /> : <ShieldCheck size={19} />}
|
||||
</span>
|
||||
<span><strong>{title}</strong><small className={error ? 'status-error' : undefined} title={error ?? undefined}>{error ?? detail}</small></span>
|
||||
{percent !== null && <div className="progress-track" role="progressbar" aria-label={title}
|
||||
aria-valuemin={0} aria-valuemax={100} aria-valuenow={percent}><i style={{ width: `${percent}%` }} /></div>}
|
||||
</div>
|
||||
<div className="build-facts">
|
||||
<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="play-button" disabled={props.primaryDisabled} onClick={props.onPrimary}>
|
||||
<Play size={21} fill="currentColor" /><span>{props.label}</span>
|
||||
</button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
export function RecoveryCodesModal({ codes, onAcknowledge }: { codes: string[]; onAcknowledge: () => void }) {
|
||||
const button = useRef<HTMLButtonElement>(null)
|
||||
useEffect(() => {
|
||||
if (!codes.length) return
|
||||
const previous = document.activeElement
|
||||
button.current?.focus()
|
||||
return () => { if (previous instanceof HTMLElement) previous.focus() }
|
||||
}, [codes])
|
||||
if (!codes.length) return null
|
||||
return (
|
||||
<>
|
||||
<div className="drawer-backdrop visible" />
|
||||
<div className="login-modal" role="dialog" aria-modal="true" aria-labelledby="recovery-title"
|
||||
onKeyDown={(event) => { if (event.key === 'Tab') { event.preventDefault(); button.current?.focus() } }}>
|
||||
<h2 id="recovery-title">Коды восстановления</h2>
|
||||
<p>Сохраните их сейчас. Каждый код можно использовать один раз для восстановления пароля.</p>
|
||||
<div className="login-code recovery-codes">{codes.join('\n')}</div>
|
||||
<button ref={button} className="setting-row" onClick={onAcknowledge}><span>Я сохранил коды</span></button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ReactNode } from 'react'
|
||||
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 }) {
|
||||
const online = status?.reachable && status.online !== null && status.max !== null
|
||||
? `${status.online} / ${status.max}` : !isNative() ? 'В приложении' : status === null ? 'Проверяем…' : 'Нет связи'
|
||||
const label = !isNative() ? 'Статус в приложении' : status === null ? 'Проверяем сервер' : status.reachable ? 'Сервер доступен' : 'Сервер недоступен'
|
||||
return (
|
||||
<main className={`stage stage-${server.id}`}>
|
||||
<div className="stage-top">
|
||||
<div className={`live-pill ${status?.reachable ? '' : 'offline'}`}><span /> {label}</div>
|
||||
<div className="players"><Users size={16} /> {online}</div>
|
||||
</div>
|
||||
<section className="hero-copy">
|
||||
<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>
|
||||
</dl>
|
||||
</section>
|
||||
{children}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { FolderOpen, Wrench, X } from 'lucide-react'
|
||||
import { AccountSettings } from './AccountSettings'
|
||||
import { LauncherUpdateSettings } from './LauncherUpdateSettings'
|
||||
import type { useAccount } from '../hooks/useAccount'
|
||||
import type { useSettings } from '../hooks/useSettings'
|
||||
import type { useLauncherUpdate } from '../hooks/useLauncherUpdate'
|
||||
import type { JavaInstallation, NativeHost } from '../types/launcher'
|
||||
|
||||
interface SettingsDrawerProps {
|
||||
open: boolean
|
||||
locked: boolean
|
||||
host: NativeHost | null
|
||||
java: JavaInstallation | null | undefined
|
||||
preferences: ReturnType<typeof useSettings>
|
||||
session: ReturnType<typeof useAccount>
|
||||
updater: ReturnType<typeof useLauncherUpdate>
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function SettingsDrawer({ open, locked, host, java, preferences, session, updater, onClose }: SettingsDrawerProps) {
|
||||
const { settings, loaded, saving, error } = preferences
|
||||
const closeButton = useRef<HTMLButtonElement>(null)
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const previous = document.activeElement
|
||||
closeButton.current?.focus()
|
||||
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 first = elements?.[0]
|
||||
const last = elements?.[elements.length - 1]
|
||||
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus() }
|
||||
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus() }
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey)
|
||||
if (previous instanceof HTMLElement) previous.focus()
|
||||
}
|
||||
}, [open, onClose])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`drawer-backdrop ${open ? 'visible' : ''}`} onClick={onClose} />
|
||||
<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>
|
||||
<button ref={closeButton} onClick={onClose} aria-label="Закрыть настройки"><X /></button>
|
||||
</div>
|
||||
<LauncherUpdateSettings updater={updater} />
|
||||
<label className="range-setting">
|
||||
<span><strong>Оперативная память</strong><b>{settings.memoryMb / 1024} ГБ</b></span>
|
||||
<input type="range" min="3" max="12" step="1" value={settings.memoryMb / 1024} disabled={!loaded || locked}
|
||||
onChange={(event) => preferences.updateRam(Number(event.target.value))} />
|
||||
<small>Для Aeronautics рекомендуется 6 ГБ</small>
|
||||
</label>
|
||||
<AccountSettings session={session} locked={locked || saving} />
|
||||
<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>
|
||||
</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>
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Minus, Square, X } from 'lucide-react'
|
||||
import logo from '../assets/shacraft-logo.png'
|
||||
import { errorMessage } from '../services/async'
|
||||
import { isNative, windowControls } from '../services/native'
|
||||
import type { NativeHost } from '../types/launcher'
|
||||
|
||||
export function Titlebar({ host, onError }: { host: NativeHost | null; onError: (message: string) => void }) {
|
||||
const control = (action: () => Promise<void>) => {
|
||||
if (isNative()) void action().catch((reason: unknown) => onError(errorMessage(reason, 'Не удалось изменить окно')))
|
||||
}
|
||||
return (
|
||||
<header className="titlebar" data-tauri-drag-region>
|
||||
<div className="brand" data-tauri-drag-region><img src={logo} alt="" data-tauri-drag-region /><span data-tauri-drag-region>ShaCraft</span></div>
|
||||
<div className="titlebar-drag" data-tauri-drag-region>{host ? `Лаунчер · ${host.platform}` : 'Лаунчер'}</div>
|
||||
<div className="window-actions" aria-label="Управление окном">
|
||||
<button aria-label="Свернуть" disabled={!isNative()} onClick={() => control(windowControls.minimize)}><Minus size={15} /></button>
|
||||
<button aria-label="Развернуть" disabled={!isNative()} onClick={() => control(windowControls.toggleMaximize)}><Square size={12} /></button>
|
||||
<button className="close" aria-label="Закрыть" disabled={!isNative()} onClick={() => control(windowControls.close)}><X size={15} /></button>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { equal, match, doesNotMatch } from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { AccountSettings } from './AccountSettings'
|
||||
import { RecoveryCodesModal } from './RecoveryCodesModal'
|
||||
import { ServerStage } from './ServerStage'
|
||||
import { Library } from './Library'
|
||||
import { servers } from '../data/servers'
|
||||
import type { useAccount } from '../hooks/useAccount'
|
||||
|
||||
function session(): ReturnType<typeof useAccount> {
|
||||
return {
|
||||
account: null, error: null, busy: false, recoveryCodes: [], linkMessage: null,
|
||||
feedback: null, dismissFeedback: () => {},
|
||||
linking: false, linkedNickname: null, authenticate: async () => true,
|
||||
logout: async () => {}, startLink: async () => {}, clearError: () => {},
|
||||
acknowledgeRecoveryCodes: () => {},
|
||||
}
|
||||
}
|
||||
|
||||
test('signed-out settings expose ShaCraft login/registration and the current credential limits', () => {
|
||||
const html = renderToStaticMarkup(<AccountSettings session={session()} locked={false} />)
|
||||
match(html, /Логин ShaCraft/)
|
||||
match(html, /Нет аккаунта — регистрация/)
|
||||
match(html, /minLength="3" maxLength="32"/)
|
||||
match(html, /minLength="3" maxLength="128"/)
|
||||
doesNotMatch(html, /Microsoft|Offline|Тип аккаунта|Игровой ник/)
|
||||
})
|
||||
|
||||
test('linked identity is displayed read-only while an unlinked account offers verification', () => {
|
||||
const account = { username: 'website_login', links: [{ server_id: 'aoc', mc_username: 'Bound_Name' }] }
|
||||
const linked = renderToStaticMarkup(<AccountSettings session={{ ...session(), account, linkedNickname: 'Bound_Name' }} locked={false} />)
|
||||
match(linked, /Bound_Name/)
|
||||
doesNotMatch(linked, /<input|Привязать ник/)
|
||||
const unlinked = renderToStaticMarkup(<AccountSettings session={{ ...session(), account: { username: 'website_login', links: [] } }} locked={false} />)
|
||||
match(unlinked, /Привязать ник/)
|
||||
match(unlinked, /Общий ник ShaCraft/)
|
||||
})
|
||||
|
||||
test('recovery codes render only until explicitly acknowledged', () => {
|
||||
const codes = ['TEST-RECOVERY-ONE', 'TEST-RECOVERY-TWO']
|
||||
const html = renderToStaticMarkup(<RecoveryCodesModal codes={codes} onAcknowledge={() => {}} />)
|
||||
match(html, /Коды восстановления/)
|
||||
match(html, /TEST-RECOVERY-ONE/)
|
||||
match(html, /TEST-RECOVERY-TWO/)
|
||||
match(html, /Я сохранил коды/)
|
||||
equal(renderToStaticMarkup(<RecoveryCodesModal codes={[]} onAcknowledge={() => {}} />), '')
|
||||
})
|
||||
|
||||
test('both profiles remain selectable and display their own runtime requirements', () => {
|
||||
const library = renderToStaticMarkup(<Library selected={servers[1]!} profiles={{}} account={null}
|
||||
locked={false} native={false} onSelect={() => {}} onSettings={() => {}} />)
|
||||
match(library, /Aeronautics/)
|
||||
match(library, /Minigames/)
|
||||
equal((library.match(/class="server-row/g) ?? []).length, 2)
|
||||
const stage = (index: number) => renderToStaticMarkup(<ServerStage server={servers[index]!} status={null}>{null}</ServerStage>)
|
||||
match(stage(0), /Версия 21/)
|
||||
match(stage(1), /Версия 25/)
|
||||
match(stage(1), /Fabric 0.19.5/)
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import { doesNotMatch, match } from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { LauncherUpdateSettings } from './LauncherUpdateSettings'
|
||||
import type { useLauncherUpdate } from '../hooks/useLauncherUpdate'
|
||||
import { initialUpdaterState, updaterReducer, updateBlocksOperations, type UpdaterState } from '../state/updater'
|
||||
|
||||
function render(state: UpdaterState): string {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window')
|
||||
const previousTauri = Object.getOwnPropertyDescriptor(globalThis, 'isTauri')
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: { isTauri: true } })
|
||||
Object.defineProperty(globalThis, 'isTauri', { configurable: true, value: true })
|
||||
try {
|
||||
const updater: ReturnType<typeof useLauncherUpdate> = {
|
||||
state, blocked: false, eventsReady: true, eventError: null,
|
||||
locksOperations: updateBlocksOperations(state), check: async () => {},
|
||||
install: async () => {}, restart: async () => {},
|
||||
}
|
||||
return renderToStaticMarkup(<LauncherUpdateSettings updater={updater} />)
|
||||
} finally {
|
||||
if (previous) Object.defineProperty(globalThis, 'window', previous)
|
||||
else Reflect.deleteProperty(globalThis, 'window')
|
||||
if (previousTauri) Object.defineProperty(globalThis, 'isTauri', previousTauri)
|
||||
else Reflect.deleteProperty(globalThis, 'isTauri')
|
||||
}
|
||||
}
|
||||
|
||||
const available = updaterReducer(initialUpdaterState, { type: 'loaded', checked: true,
|
||||
status: { currentVersion: '0.1.4', supported: true, installationKind: 'deb', version: '0.1.5' } })
|
||||
|
||||
test('deb announces system authorization before installation without collecting credentials', () => {
|
||||
const html = render(available)
|
||||
match(html, /Для обновления deb потребуется подтверждение администратора в системном окне/)
|
||||
match(html, /class="update-primary"><[^>]+.*Обновить<\/button>/)
|
||||
doesNotMatch(html, /<input|type="password"|Перезапустить лаунчер/)
|
||||
})
|
||||
|
||||
test('deb installation requests system confirmation and prevents duplicate install or check', () => {
|
||||
const downloading = updaterReducer(available, { type: 'install' })
|
||||
const installing = updaterReducer(downloading, { type: 'progress',
|
||||
progress: { stage: 'installing', downloadedBytes: 10, totalBytes: 10 } })
|
||||
const html = render(installing)
|
||||
match(html, /Подтвердите установку в системном окне и дождитесь завершения/)
|
||||
match(html, /aria-label="Установка обновления лаунчера"/)
|
||||
match(html, /class="update-primary" disabled=""/)
|
||||
match(html, /class="update-check" disabled=""/)
|
||||
doesNotMatch(html, /Для обновления deb потребуется|<input|type="password"/)
|
||||
})
|
||||
|
||||
test('cancelled deb authorization remains visible and permits explicit retry without claiming success', () => {
|
||||
const downloading = updaterReducer(available, { type: 'install' })
|
||||
const installing = updaterReducer(downloading, { type: 'progress',
|
||||
progress: { stage: 'installing', downloadedBytes: 10 } })
|
||||
const cancelled = updaterReducer(installing, { type: 'failed', error: 'Установка отменена в системном окне.' })
|
||||
const html = render(cancelled)
|
||||
match(html, /role="status">Установка отменена в системном окне/)
|
||||
match(html, /class="update-primary">/)
|
||||
match(html, /Повторить проверку/)
|
||||
doesNotMatch(html, /disabled=""|Обновление установлено|Перезапустить лаунчер/)
|
||||
const retry = render(updaterReducer(cancelled, { type: 'install' }))
|
||||
match(retry, /Скачиваем обновление/)
|
||||
doesNotMatch(retry, /Установка отменена/)
|
||||
})
|
||||
|
||||
test('AppImage and older native status retain their installation copy without administrator hints', () => {
|
||||
for (const installationKind of ['appimage', undefined] as const) {
|
||||
const status = { ...available.status!, installationKind }
|
||||
const downloading = updaterReducer({ ...available, status }, { type: 'install' })
|
||||
const installing = updaterReducer(downloading, { type: 'progress',
|
||||
progress: { stage: 'installing', downloadedBytes: 10 } })
|
||||
const html = render(installing)
|
||||
match(html, /Проверяем подпись и устанавливаем/)
|
||||
doesNotMatch(html, /администратора|системном окне/)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Server } from '../types/launcher'
|
||||
|
||||
// Presentation metadata only. Rust reads install versions and managed files
|
||||
// from the signed manifest; this list cannot control downloads or launch args.
|
||||
export const servers: readonly [Server, ...Server[]] = [
|
||||
{
|
||||
id: 'aoc',
|
||||
kicker: 'Основная сборка',
|
||||
name: 'Aeronautics',
|
||||
subtitle: 'Строй корабли. Поднимай города в небо.',
|
||||
version: '1.21.1 · NeoForge 21.1.248',
|
||||
loader: 'NeoForge 21.1.248',
|
||||
profileId: 'aeronautics',
|
||||
},
|
||||
{
|
||||
id: 'minigames', kicker: 'Лобби и арены', name: 'Minigames',
|
||||
subtitle: 'Небесные острова. Сражения на аренах SMASH.',
|
||||
version: '26.2 · Fabric 0.19.5', loader: 'Fabric 0.19.5', profileId: 'minigames',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { Feedback } from '../components/FeedbackDialog'
|
||||
import { createRequestScope, errorMessage } from '../services/async'
|
||||
import { isNative, native } from '../services/native'
|
||||
import { linkedNickname, validCredentials } from '../state/account'
|
||||
import { isValidNickname } from '../state/settings'
|
||||
import type { ShaCraftAccount } from '../types/launcher'
|
||||
|
||||
export function useAccount() {
|
||||
// undefined = restoring saved account; null = signed out.
|
||||
const [account, setAccount] = useState<ShaCraftAccount | null | undefined>(isNative() ? undefined : null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([])
|
||||
const [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())
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNative()) return
|
||||
let active = true
|
||||
void native.getAccount().then((value) => {
|
||||
if (active) setAccount(value)
|
||||
}).catch((reason: unknown) => {
|
||||
if (!active) return
|
||||
setAccount(null)
|
||||
setError(errorMessage(reason, 'Не удалось восстановить аккаунт ShaCraft'))
|
||||
})
|
||||
return () => { active = false; requests.current.invalidate() }
|
||||
}, [])
|
||||
|
||||
const authenticate = async (username: string, password: string, register: boolean) => {
|
||||
if (pending.current || account === undefined) return false
|
||||
if (!validCredentials(username, password)) {
|
||||
setError('Логин: 3–32 латинских буквы, цифры или _; пароль: 3–128 символов')
|
||||
return false
|
||||
}
|
||||
if (!isNative()) {
|
||||
setError('Вход в ShaCraft доступен в приложении лаунчера')
|
||||
return false
|
||||
}
|
||||
pending.current = true
|
||||
requests.current.invalidate()
|
||||
const currentRequest = requests.current.capture()
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await native.authenticate(username, password, register)
|
||||
if (!currentRequest()) return false
|
||||
setAccount(result.account)
|
||||
setLinkMessage(null)
|
||||
setRecoveryCodes(result.recoveryCodes)
|
||||
return true
|
||||
} catch (reason) {
|
||||
setError(errorMessage(reason, register ? 'Не удалось зарегистрироваться' : 'Не удалось войти'))
|
||||
return false
|
||||
} finally {
|
||||
pending.current = false
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
if (!isNative() || pending.current) return
|
||||
pending.current = true
|
||||
requests.current.invalidate()
|
||||
setLinkMessage(null)
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await native.logout()
|
||||
setAccount(null)
|
||||
setRecoveryCodes([])
|
||||
} catch (reason) {
|
||||
setError(errorMessage(reason, 'Не удалось выйти из ShaCraft'))
|
||||
} finally {
|
||||
pending.current = false
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const startLink = async (nickname: string) => {
|
||||
if (pending.current) return
|
||||
if (!isNative()) {
|
||||
reportLink('Привязка доступна в приложении лаунчера.', 'error')
|
||||
return
|
||||
}
|
||||
if (!account) {
|
||||
reportLink('Войдите в аккаунт ShaCraft, затем повторите привязку.', 'error')
|
||||
return
|
||||
}
|
||||
nickname = nickname.trim()
|
||||
if (!isValidNickname(nickname)) {
|
||||
reportLink('Ник: 3–16 латинских букв, цифр или _', 'error')
|
||||
return
|
||||
}
|
||||
pending.current = true
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
reportLink('Проверяем аккаунт и закрепляем ник…')
|
||||
requests.current.invalidate()
|
||||
const currentRequest = requests.current.capture()
|
||||
try {
|
||||
const refreshed = await native.getAccount()
|
||||
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')
|
||||
} catch (reason) {
|
||||
if (currentRequest()) reportLink(errorMessage(reason, 'Не удалось начать привязку'), 'error')
|
||||
} finally {
|
||||
pending.current = false
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
account, error, busy, recoveryCodes, linkMessage, linking: false,
|
||||
feedback, dismissFeedback: () => setFeedback(null),
|
||||
linkedNickname: linkedNickname(account), authenticate, logout, startLink,
|
||||
clearError: () => setError(null),
|
||||
acknowledgeRecoveryCodes: () => setRecoveryCodes([]),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useEffect, useReducer, useRef, useState } from 'react'
|
||||
import { servers } from '../data/servers'
|
||||
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'
|
||||
|
||||
export function useLauncher() {
|
||||
const [host, setHost] = useState<NativeHost | null>(null)
|
||||
const [java, setJava] = useState<JavaInstallation | null | undefined>(undefined)
|
||||
const [environmentError, setEnvironmentError] = useState<string | null>(null)
|
||||
const [profiles, updateProfile] = useReducer(profilesReducer, {})
|
||||
const [game, dispatch] = useReducer(gameReducer, initialGameState)
|
||||
const [eventsReady, setEventsReady] = useState(false)
|
||||
const busy = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNative()) return
|
||||
let active = true
|
||||
const subscription = watchGame({
|
||||
progress: (progress) => { if (active) dispatch({ type: 'progress', progress }) },
|
||||
exited: (result) => { if (active) dispatch({ type: 'exited', result }) },
|
||||
})
|
||||
void subscription.ready.then(() => {
|
||||
if (active) setEventsReady(true)
|
||||
}).catch((reason: unknown) => {
|
||||
if (active) setEnvironmentError(errorMessage(reason, 'Не удалось подключить события игры'))
|
||||
})
|
||||
void native.host().then((value) => {
|
||||
if (active) setHost(value)
|
||||
}).catch((reason: unknown) => {
|
||||
if (active) setEnvironmentError(errorMessage(reason, 'Не удалось определить каталог лаунчера'))
|
||||
})
|
||||
void native.detectJava().then((value) => {
|
||||
if (active) setJava(value)
|
||||
}).catch((reason: unknown) => {
|
||||
if (!active) return
|
||||
setJava(null)
|
||||
setEnvironmentError(errorMessage(reason, 'Не удалось проверить Java'))
|
||||
})
|
||||
for (const server of servers) {
|
||||
const profileId = server.profileId
|
||||
updateProfile({ type: 'check', profileId })
|
||||
void native.inspectProfile(profileId).then((inspection) => {
|
||||
if (active) updateProfile({ type: 'checked', profileId, inspection })
|
||||
}).catch((reason: unknown) => {
|
||||
if (active) updateProfile({ type: 'failed', profileId, error: errorMessage(reason, 'Не удалось проверить сборку') })
|
||||
})
|
||||
}
|
||||
return () => { active = false; subscription.dispose() }
|
||||
}, [])
|
||||
|
||||
const repair = async (profileId: string) => {
|
||||
if (!isNative() || busy.current || game.operation.phase !== 'idle' || profiles[profileId]?.status === 'checking') return
|
||||
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.
|
||||
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 })
|
||||
} catch (reason) {
|
||||
const error = errorMessage(reason, 'Не удалось синхронизировать сборку')
|
||||
updateProfile({ type: 'failed', profileId, error })
|
||||
dispatch({ type: 'failed', error })
|
||||
} 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 })
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
return { host, java, environmentError, profiles, game, eventsReady, repair, launch }
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useReducer, useRef, useState } from 'react'
|
||||
import { errorMessage, singleFlight } from '../services/async'
|
||||
import { isNative, native, watchLauncherUpdate } from '../services/native'
|
||||
import { initialUpdaterState, updateBlocksOperations, updaterReducer } from '../state/updater'
|
||||
|
||||
// StrictMode re-runs effects; share the pending native check without installing
|
||||
// anything. Manual checks always make a fresh request.
|
||||
const startupCheck = singleFlight(async () => {
|
||||
const status = await native.updateStatus()
|
||||
if (!status.supported || (status.stage && ['downloading', 'installing', 'ready'].includes(status.stage))) return { status, checked: false, error: null }
|
||||
try {
|
||||
return { status: await native.checkUpdate(), checked: true, error: null }
|
||||
} catch (error) {
|
||||
return { status, checked: false, error: errorMessage(error, 'Не удалось проверить обновления. Повторите попытку.') }
|
||||
}
|
||||
})
|
||||
|
||||
export function useLauncherUpdate(blocked: boolean) {
|
||||
const [state, dispatch] = useReducer(updaterReducer, initialUpdaterState)
|
||||
const [eventsReady, setEventsReady] = useState(false)
|
||||
const [eventError, setEventError] = useState<string | null>(null)
|
||||
const pending = useRef(false)
|
||||
const mounted = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNative()) return
|
||||
let active = true
|
||||
mounted.current = true
|
||||
const subscription = watchLauncherUpdate((progress) => {
|
||||
if (active) dispatch({ type: 'progress', progress })
|
||||
})
|
||||
void subscription.ready.then(() => {
|
||||
if (active) { setEventsReady(true); setEventError(null) }
|
||||
}).catch((reason: unknown) => {
|
||||
if (active) setEventError(errorMessage(reason, 'Не удалось подключить события обновления. Перезапустите лаунчер.'))
|
||||
})
|
||||
pending.current = true
|
||||
void startupCheck().then((result) => {
|
||||
if (active) dispatch({ type: 'loaded', ...result })
|
||||
}).catch((reason: unknown) => {
|
||||
if (active) dispatch({ type: 'failed', error: errorMessage(reason, 'Не удалось проверить обновления. Повторите попытку.') })
|
||||
}).finally(() => { if (active) pending.current = false })
|
||||
return () => { active = false; mounted.current = false; subscription.dispose() }
|
||||
}, [])
|
||||
|
||||
const check = async () => {
|
||||
if (!isNative() || pending.current || updateBlocksOperations(state)) return
|
||||
pending.current = true
|
||||
dispatch({ type: 'check' })
|
||||
try {
|
||||
const status = await native.checkUpdate()
|
||||
if (mounted.current) dispatch({ type: 'loaded', status, checked: true })
|
||||
} catch (reason) {
|
||||
if (mounted.current) dispatch({ type: 'failed', error: errorMessage(reason, 'Не удалось проверить обновления. Повторите попытку.') })
|
||||
} finally { pending.current = false }
|
||||
}
|
||||
|
||||
const install = async () => {
|
||||
if (!isNative() || pending.current || blocked || !eventsReady || state.phase !== 'idle' ||
|
||||
!state.status?.supported || !state.status.version) return
|
||||
pending.current = true
|
||||
dispatch({ type: 'install' })
|
||||
try {
|
||||
await native.installUpdate()
|
||||
if (mounted.current) dispatch({ type: 'ready' })
|
||||
} catch (reason) {
|
||||
if (mounted.current) dispatch({ type: 'failed', error: errorMessage(reason, 'Не удалось установить обновление. Повторите попытку.') })
|
||||
} finally { pending.current = false }
|
||||
}
|
||||
|
||||
const restart = async () => {
|
||||
if (!isNative() || pending.current || blocked || state.phase !== 'ready') return
|
||||
pending.current = true
|
||||
dispatch({ type: 'restart' })
|
||||
try {
|
||||
await native.restartAfterUpdate()
|
||||
} catch (reason) {
|
||||
if (mounted.current) dispatch({ type: 'failed', error: errorMessage(reason, 'Не удалось перезапустить лаунчер. Закройте его и откройте снова.') })
|
||||
} finally { pending.current = false }
|
||||
}
|
||||
|
||||
return { state, eventsReady, eventError, blocked, locksOperations: updateBlocksOperations(state), check, install, restart }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { isNative, native } from '../services/native'
|
||||
import type { ServerStatus } from '../types/launcher'
|
||||
|
||||
export function useServerStatus(profileId: string) {
|
||||
const [status, setStatus] = useState<ServerStatus | null>(null)
|
||||
useEffect(() => {
|
||||
setStatus(null)
|
||||
if (!isNative()) return
|
||||
let active = true
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const next = await native.serverStatus(profileId)
|
||||
if (active) setStatus(next)
|
||||
} catch {
|
||||
if (active) setStatus({ online: null, max: null, reachable: false })
|
||||
} finally {
|
||||
// Schedule after completion; slow network calls never overlap.
|
||||
if (active) timer = setTimeout(() => { void refresh() }, 30_000)
|
||||
}
|
||||
}
|
||||
void refresh()
|
||||
return () => { active = false; clearTimeout(timer) }
|
||||
}, [profileId])
|
||||
return status
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createSerialQueue, errorMessage } from '../services/async'
|
||||
import { isNative, native } from '../services/native'
|
||||
import { defaultSettings } from '../state/settings'
|
||||
import type { LauncherSettings } from '../types/launcher'
|
||||
|
||||
export function useSettings() {
|
||||
const [settings, setSettings] = useState(defaultSettings)
|
||||
const [loaded, setLoaded] = useState(!isNative())
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loadAttempt, setLoadAttempt] = useState(0)
|
||||
const current = useRef(settings)
|
||||
const durable = useRef(settings)
|
||||
const revision = useRef(0)
|
||||
const queue = useRef(createSerialQueue())
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNative()) return
|
||||
let active = true
|
||||
setError(null)
|
||||
native.loadSettings().then((value) => {
|
||||
if (!active) return
|
||||
current.current = value
|
||||
durable.current = value
|
||||
setSettings(value)
|
||||
setLoaded(true)
|
||||
}).catch((reason: unknown) => {
|
||||
if (active) setError(errorMessage(reason, 'Не удалось прочитать настройки'))
|
||||
})
|
||||
return () => { active = false }
|
||||
}, [loadAttempt])
|
||||
|
||||
const save = (patch: Partial<LauncherSettings>) => {
|
||||
if (!loaded) return
|
||||
const next = { ...current.current, ...patch }
|
||||
current.current = next
|
||||
setSettings(next)
|
||||
setError(null)
|
||||
if (!isNative()) return
|
||||
const requestRevision = ++revision.current
|
||||
setSaving(true)
|
||||
void queue.current.enqueue(() => native.saveSettings(next)).then((value) => {
|
||||
durable.current = value
|
||||
if (revision.current === requestRevision) {
|
||||
current.current = value
|
||||
setSettings(value)
|
||||
}
|
||||
}).catch((reason: unknown) => {
|
||||
if (revision.current !== requestRevision) return
|
||||
current.current = durable.current
|
||||
setSettings(durable.current)
|
||||
setError(errorMessage(reason, 'Не удалось сохранить настройки'))
|
||||
}).finally(() => {
|
||||
if (revision.current === requestRevision) setSaving(false)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
settings, loaded, saving, error,
|
||||
updateRam: (memoryGb: number) => save({ memoryMb: memoryGb * 1024 }),
|
||||
retry: () => {
|
||||
if (!loaded) setLoadAttempt((attempt) => attempt + 1)
|
||||
else save({})
|
||||
},
|
||||
}
|
||||
}
|
||||
+5
-578
@@ -1,582 +1,9 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||
import {
|
||||
ChevronRight,
|
||||
Download,
|
||||
FolderOpen,
|
||||
Gauge,
|
||||
Globe2,
|
||||
LogOut,
|
||||
Minus,
|
||||
Play,
|
||||
RotateCcw,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Square,
|
||||
Users,
|
||||
Wrench,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import logo from './assets/shacraft-logo.png'
|
||||
import { App } from './App'
|
||||
import './styles.css'
|
||||
|
||||
type Server = {
|
||||
id: string
|
||||
kicker: string
|
||||
name: string
|
||||
subtitle: string
|
||||
version: string
|
||||
profileId: string
|
||||
}
|
||||
const root = document.getElementById('root')
|
||||
if (!root) throw new Error('Launcher root element is missing')
|
||||
|
||||
type NativeHost = {
|
||||
platform: string
|
||||
dataDir: string
|
||||
launcherVersion: string
|
||||
}
|
||||
|
||||
type NativeSettings = {
|
||||
memoryMb: number
|
||||
nickname: string
|
||||
accountMode: 'microsoft' | 'offline'
|
||||
}
|
||||
|
||||
type JavaInstallation = {
|
||||
executable: string
|
||||
major: number
|
||||
version: string
|
||||
}
|
||||
|
||||
type ProfileInspection = {
|
||||
managedFiles: number
|
||||
missingFiles: number
|
||||
mismatchedFiles: number
|
||||
upToDate: boolean
|
||||
}
|
||||
|
||||
type SyncResult = {
|
||||
downloadedFiles: number
|
||||
reusedFiles: number
|
||||
downloadedBytes: number
|
||||
}
|
||||
|
||||
type MinecraftProfile = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
type DeviceCodePayload = {
|
||||
verificationUri: string
|
||||
userCode: string
|
||||
expiresInSeconds: number
|
||||
}
|
||||
|
||||
type LoginResultPayload = {
|
||||
ok: boolean
|
||||
profile?: MinecraftProfile
|
||||
error?: string
|
||||
}
|
||||
|
||||
type ServerStatus = {
|
||||
online: number | null
|
||||
max: number | null
|
||||
reachable: boolean
|
||||
}
|
||||
|
||||
type GameExitedPayload = {
|
||||
profileId: string
|
||||
exitCode: number | null
|
||||
}
|
||||
|
||||
type InstallProgressPayload = {
|
||||
stage: 'java' | 'neoforge' | 'libraries' | 'assets'
|
||||
currentBytes: number
|
||||
totalBytes: number
|
||||
}
|
||||
|
||||
const INSTALL_STAGE_LABEL: Record<InstallProgressPayload['stage'], string> = {
|
||||
java: 'Готовим Java',
|
||||
neoforge: 'Устанавливаем NeoForge',
|
||||
libraries: 'Скачиваем библиотеки',
|
||||
assets: 'Скачиваем ресурсы игры',
|
||||
}
|
||||
|
||||
const servers: Server[] = [
|
||||
{
|
||||
id: 'aoc',
|
||||
kicker: 'Основная сборка',
|
||||
name: 'Aeronautics',
|
||||
subtitle: 'Строй корабли. Поднимай города в небо.',
|
||||
version: '1.21.1 · NeoForge 21.1.248',
|
||||
profileId: 'aeronautics',
|
||||
},
|
||||
]
|
||||
|
||||
function isTauri() {
|
||||
return '__TAURI_INTERNALS__' in window
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown, fallback: string) {
|
||||
if (typeof error === 'string' && error.trim()) return error
|
||||
if (error instanceof Error && error.message) return error.message
|
||||
return fallback
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [selected, setSelected] = useState(servers[0])
|
||||
const [progress, setProgress] = useState<number | null>(null)
|
||||
const [ready, setReady] = useState(true)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [ram, setRam] = useState(6)
|
||||
const [nickname, setNickname] = useState('Emil')
|
||||
const [accountMode, setAccountMode] = useState<'microsoft' | 'offline'>('offline')
|
||||
const [nativeHost, setNativeHost] = useState<NativeHost | null>(null)
|
||||
const [java, setJava] = useState<JavaInstallation | null | undefined>(undefined)
|
||||
const [profile, setProfile] = useState<ProfileInspection | null>(null)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [syncError, setSyncError] = useState<string | null>(null)
|
||||
|
||||
// undefined = still checking for a saved session; null = signed out.
|
||||
const [account, setAccount] = useState<MinecraftProfile | null | undefined>(undefined)
|
||||
const [loginCode, setLoginCode] = useState<DeviceCodePayload | null>(null)
|
||||
const [loginError, setLoginError] = useState<string | null>(null)
|
||||
const [loggingIn, setLoggingIn] = useState(false)
|
||||
const [installing, setInstalling] = useState(false)
|
||||
const [gameRunning, setGameRunning] = useState(false)
|
||||
const [installProgress, setInstallProgress] = useState<InstallProgressPayload | null>(null)
|
||||
const [launchError, setLaunchError] = useState<string | null>(null)
|
||||
const [serverStatus, setServerStatus] = useState<ServerStatus | null>(null)
|
||||
const [microsoftLoginAvailable, setMicrosoftLoginAvailable] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (progress === null) return
|
||||
if (progress >= 100) {
|
||||
const done = window.setTimeout(() => {
|
||||
setProgress(null)
|
||||
setReady(true)
|
||||
}, 650)
|
||||
return () => window.clearTimeout(done)
|
||||
}
|
||||
const timer = window.setTimeout(() => setProgress(Math.min(100, progress + 2)), 55)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [progress])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri()) return
|
||||
invoke<NativeHost>('native_host').then(setNativeHost).catch(() => setNativeHost(null))
|
||||
invoke<NativeSettings>('load_settings')
|
||||
.then((settings) => {
|
||||
setRam(settings.memoryMb / 1024)
|
||||
setNickname(settings.nickname)
|
||||
setAccountMode(settings.accountMode)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
invoke<JavaInstallation | null>('detect_java')
|
||||
.then(setJava)
|
||||
.catch(() => setJava(null))
|
||||
invoke<boolean>('microsoft_login_available')
|
||||
.then(setMicrosoftLoginAvailable)
|
||||
.catch(() => setMicrosoftLoginAvailable(false))
|
||||
invoke<ProfileInspection>('inspect_remote_profile', { profileId: 'aeronautics' })
|
||||
.then((inspection) => {
|
||||
setProfile(inspection)
|
||||
setReady(inspection.upToDate)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
invoke<MinecraftProfile | null>('get_account')
|
||||
.then(setAccount)
|
||||
.catch(() => setAccount(null))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri()) return
|
||||
let disposed = false
|
||||
const refresh = () => {
|
||||
invoke<ServerStatus>('get_server_status', { profileId: selected.profileId })
|
||||
.then((status) => {
|
||||
if (!disposed) setServerStatus(status)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!disposed) setServerStatus({ online: null, max: null, reachable: false })
|
||||
})
|
||||
}
|
||||
refresh()
|
||||
const timer = window.setInterval(refresh, 30_000)
|
||||
return () => {
|
||||
disposed = true
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
}, [selected.profileId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri()) return
|
||||
const unlisten = [
|
||||
listen<DeviceCodePayload>('msa-login-code', (event) => setLoginCode(event.payload)),
|
||||
listen<LoginResultPayload>('msa-login-result', (event) => {
|
||||
setLoggingIn(false)
|
||||
setLoginCode(null)
|
||||
if (event.payload.ok && event.payload.profile) {
|
||||
setAccount(event.payload.profile)
|
||||
setLoginError(null)
|
||||
} else {
|
||||
setLoginError(event.payload.error ?? 'Не удалось войти через Microsoft')
|
||||
}
|
||||
}),
|
||||
listen<InstallProgressPayload>('game-install-progress', (event) => setInstallProgress(event.payload)),
|
||||
listen<GameExitedPayload>('game-exited', (event) => {
|
||||
setInstalling(false)
|
||||
setGameRunning(false)
|
||||
setInstallProgress(null)
|
||||
if (event.payload.exitCode !== 0) {
|
||||
const suffix = event.payload.exitCode === null ? '' : ` (код ${event.payload.exitCode})`
|
||||
setLaunchError(`Игра завершилась с ошибкой${suffix}. Подробности сохранены в журнале лаунчера.`)
|
||||
}
|
||||
}),
|
||||
]
|
||||
return () => {
|
||||
unlisten.forEach((promise) => promise.then((off) => off()))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const saveSettings = (memoryGb = ram, nick = nickname, mode = accountMode) => {
|
||||
if (isTauri()) {
|
||||
invoke<NativeSettings>('save_settings', { settings: { memoryMb: memoryGb * 1024, nickname: nick, accountMode: mode } }).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const updateRam = (memoryGb: number) => {
|
||||
setRam(memoryGb)
|
||||
saveSettings(memoryGb)
|
||||
}
|
||||
|
||||
const saveNickname = () => {
|
||||
if (/^[A-Za-z0-9_]{3,16}$/.test(nickname)) {
|
||||
saveSettings(ram, nickname)
|
||||
}
|
||||
}
|
||||
|
||||
const setMode = (mode: 'microsoft' | 'offline') => {
|
||||
setAccountMode(mode)
|
||||
saveSettings(ram, nickname, mode)
|
||||
}
|
||||
|
||||
const repair = async () => {
|
||||
if (isTauri()) {
|
||||
setSyncError(null)
|
||||
setSyncing(true)
|
||||
setReady(false)
|
||||
try {
|
||||
const result = await invoke<SyncResult>('sync_remote_profile', { profileId: selected.profileId })
|
||||
setProfile({ managedFiles: result.downloadedFiles + result.reusedFiles, missingFiles: 0, mismatchedFiles: 0, upToDate: true })
|
||||
setReady(true)
|
||||
} catch (error) {
|
||||
setSyncError(errorMessage(error, 'Не удалось синхронизировать сборку'))
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
setReady(false)
|
||||
setProgress(0)
|
||||
}
|
||||
|
||||
const startLogin = async () => {
|
||||
if (!isTauri() || !microsoftLoginAvailable) {
|
||||
setLoginError('Вход через Microsoft пока не настроен для этой версии лаунчера')
|
||||
return
|
||||
}
|
||||
setLoginError(null)
|
||||
setLoggingIn(true)
|
||||
try {
|
||||
await invoke('start_microsoft_login')
|
||||
} catch (error) {
|
||||
setLoggingIn(false)
|
||||
setLoginError(errorMessage(error, 'Не удалось начать вход через Microsoft'))
|
||||
}
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
if (!isTauri()) return
|
||||
await invoke('logout').catch(() => undefined)
|
||||
setAccount(null)
|
||||
}
|
||||
|
||||
const playOrLogin = async () => {
|
||||
if (!isTauri()) return
|
||||
if (accountMode === 'microsoft' && !microsoftLoginAvailable) {
|
||||
setLaunchError('Вход Microsoft пока недоступен. Выберите Offline-аккаунт в настройках.')
|
||||
return
|
||||
}
|
||||
// In offline mode we can launch without any Microsoft session. In
|
||||
// Microsoft mode a signed-in account is still required first.
|
||||
if (accountMode === 'microsoft' && (account === null || account === undefined)) {
|
||||
await startLogin()
|
||||
return
|
||||
}
|
||||
setLaunchError(null)
|
||||
setSyncError(null)
|
||||
setSyncing(true)
|
||||
setInstallProgress(null)
|
||||
try {
|
||||
// A launch must always reconcile the signed ShaCraft profile first.
|
||||
// Installing Minecraft/NeoForge alone produces a valid but unmodded
|
||||
// game, so profile sync is deliberately part of the Play path.
|
||||
const syncResult = await invoke<SyncResult>('sync_remote_profile', { profileId: selected.profileId })
|
||||
setProfile({ managedFiles: syncResult.downloadedFiles + syncResult.reusedFiles, missingFiles: 0, mismatchedFiles: 0, upToDate: true })
|
||||
setReady(true)
|
||||
setSyncing(false)
|
||||
setInstalling(true)
|
||||
await invoke('ensure_game_installed', { profileId: selected.profileId })
|
||||
setInstallProgress(null)
|
||||
setInstalling(false)
|
||||
setGameRunning(true)
|
||||
await invoke('launch_game', { profileId: selected.profileId })
|
||||
} catch (error) {
|
||||
setLaunchError(errorMessage(error, 'Не удалось запустить игру'))
|
||||
setSyncing(false)
|
||||
setInstalling(false)
|
||||
setGameRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const playLabel = () => {
|
||||
if (accountMode === 'microsoft' && !microsoftLoginAvailable) return 'Microsoft недоступен'
|
||||
if (accountMode === 'microsoft' && account === undefined) return 'Загрузка…'
|
||||
if (accountMode === 'microsoft' && account === null) return loggingIn ? 'Ждём вход…' : 'Войти через Microsoft'
|
||||
if (gameRunning) return 'Игра запущена'
|
||||
if (installing) return installProgress ? `${INSTALL_STAGE_LABEL[installProgress.stage]}…` : 'Подготовка…'
|
||||
if (syncing || progress !== null) return 'Обновление'
|
||||
return ready ? 'Играть' : 'Проверить'
|
||||
}
|
||||
|
||||
const installPercent = installProgress && installProgress.totalBytes > 0 ? Math.min(100, Math.round((installProgress.currentBytes / installProgress.totalBytes) * 100)) : null
|
||||
const onlineLabel = serverStatus?.reachable && serverStatus.online !== null && serverStatus.max !== null
|
||||
? `${serverStatus.online} / ${serverStatus.max}`
|
||||
: serverStatus === null ? 'Проверяем…' : 'Нет связи'
|
||||
|
||||
const minimizeWindow = () => { if (isTauri()) void getCurrentWindow().minimize() }
|
||||
const toggleMaximizeWindow = () => { if (isTauri()) void getCurrentWindow().toggleMaximize() }
|
||||
const closeWindow = () => { if (isTauri()) void getCurrentWindow().close() }
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="titlebar" data-tauri-drag-region>
|
||||
<div className="brand" data-tauri-drag-region>
|
||||
<img src={logo} alt="" data-tauri-drag-region />
|
||||
<span data-tauri-drag-region>ShaCraft</span>
|
||||
</div>
|
||||
<div className="titlebar-drag" data-tauri-drag-region>{nativeHost ? `Лаунчер · ${nativeHost.platform}` : 'Лаунчер'}</div>
|
||||
<div className="window-actions" aria-label="Управление окном">
|
||||
<button aria-label="Свернуть" onClick={minimizeWindow}><Minus size={15} /></button>
|
||||
<button aria-label="Развернуть" onClick={toggleMaximizeWindow}><Square size={12} /></button>
|
||||
<button className="close" aria-label="Закрыть" onClick={closeWindow}><X size={15} /></button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="workspace">
|
||||
<nav className="rail" aria-label="Настройки лаунчера">
|
||||
<button className="rail-button active" aria-label="Настройки" onClick={() => setSettingsOpen(true)}>
|
||||
<Settings />
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<aside className="library-panel">
|
||||
<div className="library-heading">
|
||||
<p>Сборки</p>
|
||||
<span>1 доступна</span>
|
||||
</div>
|
||||
<div className="server-list">
|
||||
{servers.map((server) => (
|
||||
<button
|
||||
key={server.id}
|
||||
className={`server-row ${selected.id === server.id ? 'selected' : ''}`}
|
||||
onClick={() => {
|
||||
setSelected(server)
|
||||
setReady(profile?.upToDate ?? false)
|
||||
setProgress(null)
|
||||
}}
|
||||
>
|
||||
<span className={`server-glyph ${server.id}`} aria-hidden="true">
|
||||
{server.id === 'aoc' ? 'A' : 'C'}
|
||||
</span>
|
||||
<span className="server-copy">
|
||||
<strong>{server.name}</strong>
|
||||
<small>{profile?.upToDate ? 'Файлы проверены' : 'Требуется проверка'}</small>
|
||||
</span>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button className="account-chip" onClick={() => setSettingsOpen(true)}>
|
||||
<span className="avatar">{accountMode === 'offline' ? nickname.slice(0, 2).toUpperCase() : (account ? account.name.slice(0, 2).toUpperCase() : '?')}</span>
|
||||
<span>
|
||||
<strong>{accountMode === 'offline' ? nickname : (account === undefined ? 'Проверяем…' : account === null ? 'Не авторизован' : account.name)}</strong>
|
||||
<small>{accountMode === 'offline' ? 'Offline-аккаунт' : (account ? 'Microsoft-аккаунт' : 'Войдите, чтобы играть')}</small>
|
||||
</span>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<main className={`stage stage-${selected.id}`}>
|
||||
<div className="stage-top">
|
||||
<div className={`live-pill ${serverStatus === null || serverStatus.reachable ? '' : 'offline'}`}>
|
||||
<span /> {serverStatus === null ? 'Проверяем сервер' : serverStatus.reachable ? 'Сервер доступен' : 'Сервер недоступен'}
|
||||
</div>
|
||||
<div className="players"><Users size={16} /> {onlineLabel}</div>
|
||||
</div>
|
||||
|
||||
<section className="hero-copy">
|
||||
<p>{selected.kicker}</p>
|
||||
<h1>{selected.name}</h1>
|
||||
<h2>{selected.subtitle}</h2>
|
||||
<dl className="hero-meta">
|
||||
<div><dt>Загрузчик</dt><dd>NeoForge 21.1.248</dd></div>
|
||||
<div><dt>Java</dt><dd>Версия 21</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="play-dock">
|
||||
<div className="build-state">
|
||||
{gameRunning ? (
|
||||
<>
|
||||
<span className="state-icon"><Play size={19} /></span>
|
||||
<span><strong>Игра запущена</strong><small>Лаунчер готов к работе после выхода</small></span>
|
||||
</>
|
||||
) : installing ? (
|
||||
<>
|
||||
<span className="state-icon downloading"><Download size={19} /></span>
|
||||
<span>
|
||||
<strong>{installProgress ? INSTALL_STAGE_LABEL[installProgress.stage] : 'Готовим установку'}</strong>
|
||||
<small>{installPercent !== null ? `${installPercent}%` : 'Проверяем файлы…'}</small>
|
||||
</span>
|
||||
</>
|
||||
) : syncing ? (
|
||||
<>
|
||||
<span className="state-icon downloading"><Download size={19} /></span>
|
||||
<span><strong>Синхронизируем сборку</strong><small>Скачиваем и проверяем файлы</small></span>
|
||||
</>
|
||||
) : progress !== null ? (
|
||||
<>
|
||||
<span className="state-icon downloading"><Download size={19} /></span>
|
||||
<span>
|
||||
<strong>Проверяем сборку</strong>
|
||||
<small>Файлы и обновления · {progress}%</small>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="state-icon"><ShieldCheck size={19} /></span>
|
||||
<span>
|
||||
<strong>{accountMode === 'microsoft' && !microsoftLoginAvailable ? 'Microsoft пока недоступен' : accountMode === 'microsoft' && account === null ? 'Нужен вход' : ready ? 'Файлы сборки готовы' : 'Требуется проверка'}</strong>
|
||||
<small>{launchError || syncError || loginError || (profile ? `${profile.managedFiles} файлов сборки` : 'Проверяем локальные файлы')}</small>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{progress !== null && <div className="progress-track"><i style={{ width: `${progress}%` }} /></div>}
|
||||
{installPercent !== null && <div className="progress-track"><i style={{ width: `${installPercent}%` }} /></div>}
|
||||
</div>
|
||||
|
||||
<div className="build-facts">
|
||||
<span><Globe2 size={15} /> {selected.version}</span>
|
||||
<span><Gauge size={15} /> {ram} ГБ памяти</span>
|
||||
</div>
|
||||
|
||||
<button className="repair-button" onClick={repair} disabled={progress !== null || syncing || installing || gameRunning} aria-label="Проверить файлы">
|
||||
<RotateCcw size={19} />
|
||||
</button>
|
||||
<button
|
||||
className="play-button"
|
||||
disabled={progress !== null || syncing || installing || gameRunning || (accountMode === 'microsoft' && (account === undefined || !microsoftLoginAvailable)) || loggingIn}
|
||||
onClick={playOrLogin}
|
||||
>
|
||||
<Play size={21} fill="currentColor" />
|
||||
<span>{playLabel()}</span>
|
||||
</button>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div className={`drawer-backdrop ${loginCode ? 'visible' : ''}`} />
|
||||
{loginCode && (
|
||||
<div className="login-modal" role="dialog" aria-modal="true">
|
||||
<h2>Вход через Microsoft</h2>
|
||||
<p>Откройте страницу и введите код, чтобы подтвердить вход в аккаунт с лицензией Minecraft.</p>
|
||||
<div className="login-code">{loginCode.userCode}</div>
|
||||
<p className="login-url">{loginCode.verificationUri}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`drawer-backdrop ${settingsOpen ? 'visible' : ''}`} onClick={() => setSettingsOpen(false)} />
|
||||
<aside className={`settings-drawer ${settingsOpen ? 'open' : ''}`} aria-hidden={!settingsOpen}>
|
||||
<div className="drawer-title">
|
||||
<div><p>Настройки</p><h2>Игра</h2></div>
|
||||
<button onClick={() => setSettingsOpen(false)} aria-label="Закрыть настройки"><X /></button>
|
||||
</div>
|
||||
<label className="range-setting">
|
||||
<span><strong>Оперативная память</strong><b>{ram} ГБ</b></span>
|
||||
<input type="range" min="3" max="12" value={ram} onChange={(e) => updateRam(Number(e.target.value))} />
|
||||
<small>Для Aeronautics рекомендуется 6 ГБ</small>
|
||||
</label>
|
||||
<div className="setting-row static">
|
||||
<span><Users />Аккаунт</span>
|
||||
<small>{accountMode === 'offline' ? 'Offline' : (microsoftLoginAvailable ? (account ? account.name : 'Не авторизован') : 'Временно недоступен')}</small>
|
||||
</div>
|
||||
{accountMode === 'offline' && (
|
||||
<label className="text-setting">
|
||||
<span><strong>Игровой ник</strong><small>Offline-профиль</small></span>
|
||||
<input value={nickname} maxLength={16} onChange={(event) => setNickname(event.target.value)} onBlur={saveNickname} placeholder="Player" />
|
||||
<small>Латинские буквы, цифры и _ · от 3 до 16 символов</small>
|
||||
</label>
|
||||
)}
|
||||
<div className="setting-row">
|
||||
<span>Тип аккаунта</span>
|
||||
<select
|
||||
value={accountMode}
|
||||
onChange={(e) => setMode(e.target.value as 'microsoft' | 'offline')}
|
||||
style={{ background: 'transparent', border: 0, color: 'inherit', textAlign: 'right' }}
|
||||
>
|
||||
<option value="offline">Offline</option>
|
||||
<option value="microsoft" disabled={!microsoftLoginAvailable}>Microsoft (скоро)</option>
|
||||
</select>
|
||||
</div>
|
||||
{accountMode === 'microsoft' && account && (
|
||||
<button className="setting-row" onClick={logout}>
|
||||
<span><LogOut />Выйти из Microsoft</span>
|
||||
</button>
|
||||
)}
|
||||
{accountMode === 'microsoft' && !account && microsoftLoginAvailable && (
|
||||
<button className="setting-row" onClick={startLogin}>
|
||||
<span><LogOut />Войти через Microsoft</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="setting-row static">
|
||||
<span><FolderOpen />Папка игры</span>
|
||||
<small>{nativeHost ? 'В каталоге лаунчера' : 'Определяется…'}</small>
|
||||
</div>
|
||||
<div className="setting-row static">
|
||||
<span><Wrench />Java</span>
|
||||
<small>
|
||||
{java === undefined
|
||||
? 'Проверяем…'
|
||||
: java && java.major >= 21
|
||||
? `Java ${java.major} найдена`
|
||||
: java
|
||||
? `Нужна Java 21 · найдена ${java.major}`
|
||||
: 'Лаунчер установит Java 21 автоматически'}
|
||||
</small>
|
||||
</div>
|
||||
<div className="drawer-note">
|
||||
{nativeHost ? `Данные лаунчера: ${nativeHost.dataDir}` : 'Java 21 будет управляться лаунчером автоматически.'}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode><App /></React.StrictMode>,
|
||||
)
|
||||
createRoot(root).render(<StrictMode><App /></StrictMode>)
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { deepStrictEqual, equal, rejects } from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { createRequestScope, createSerialQueue, createSubscription, errorMessage, singleFlight } from './async.ts'
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason: unknown) => void
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise; reject = rejectPromise
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
test('settings writes are serialized even while earlier requests are pending', async () => {
|
||||
const queue = createSerialQueue()
|
||||
const first = deferred<number>()
|
||||
const order: number[] = []
|
||||
const savedFirst = queue.enqueue(async () => { order.push(1); return first.promise })
|
||||
const savedSecond = queue.enqueue(async () => { order.push(2); return 2 })
|
||||
await Promise.resolve()
|
||||
deepStrictEqual(order, [1])
|
||||
first.resolve(1)
|
||||
equal(await savedFirst, 1)
|
||||
equal(await savedSecond, 2)
|
||||
deepStrictEqual(order, [1, 2])
|
||||
})
|
||||
|
||||
test('a failed settings write does not poison later saves', async () => {
|
||||
const queue = createSerialQueue()
|
||||
const failed = queue.enqueue(async () => { throw new Error('disk full') })
|
||||
const retried = queue.enqueue(async () => 'saved')
|
||||
await rejects(failed, /disk full/)
|
||||
equal(await retried, 'saved')
|
||||
await queue.settled()
|
||||
})
|
||||
|
||||
test('unmount before native registration still unregisters the late listener once', async () => {
|
||||
const registration = deferred<() => void>()
|
||||
let cleanupCount = 0
|
||||
const subscription = createSubscription([registration.promise])
|
||||
subscription.dispose()
|
||||
registration.resolve(() => { cleanupCount += 1 })
|
||||
await subscription.ready
|
||||
subscription.dispose()
|
||||
equal(cleanupCount, 1)
|
||||
})
|
||||
|
||||
test('partial listener failure cleans up successful and late registrations', async () => {
|
||||
const failed = deferred<() => void>()
|
||||
const late = deferred<() => void>()
|
||||
let cleanupCount = 0
|
||||
const subscription = createSubscription([
|
||||
Promise.resolve(() => { cleanupCount += 1 }), failed.promise, late.promise,
|
||||
])
|
||||
failed.reject(new Error('listen failed'))
|
||||
await rejects(subscription.ready, /listen failed/)
|
||||
equal(cleanupCount, 1)
|
||||
late.resolve(() => { cleanupCount += 1 })
|
||||
await Promise.resolve()
|
||||
equal(cleanupCount, 2)
|
||||
subscription.dispose()
|
||||
equal(cleanupCount, 2)
|
||||
})
|
||||
|
||||
test('Rust string errors remain visible instead of being replaced with generic copy', () => {
|
||||
equal(errorMessage('Invalid signature', 'fallback'), 'Invalid signature')
|
||||
equal(errorMessage(new Error('Disk full'), 'fallback'), 'Disk full')
|
||||
equal(errorMessage(null, 'fallback'), 'fallback')
|
||||
equal(errorMessage('', 'fallback'), 'fallback')
|
||||
})
|
||||
|
||||
test('overlapping account restores share one native request and do not cache the session', async () => {
|
||||
const first = deferred<string>()
|
||||
let calls = 0
|
||||
const restore = singleFlight(() => { calls += 1; return first.promise })
|
||||
const firstMount = restore()
|
||||
const strictModeRemount = restore()
|
||||
equal(firstMount, strictModeRemount)
|
||||
await Promise.resolve()
|
||||
equal(calls, 1)
|
||||
first.resolve('profile')
|
||||
equal(await strictModeRemount, 'profile')
|
||||
await restore()
|
||||
equal(calls, 2)
|
||||
})
|
||||
|
||||
test('failed account restore can be retried', async () => {
|
||||
let calls = 0
|
||||
const restore = singleFlight(async () => {
|
||||
calls += 1
|
||||
if (calls === 1) throw new Error('network unavailable')
|
||||
return 'profile'
|
||||
})
|
||||
await rejects(restore(), /network unavailable/)
|
||||
equal(await restore(), 'profile')
|
||||
equal(calls, 2)
|
||||
})
|
||||
|
||||
test('logout or a newer challenge invalidates a delayed account/link response', async () => {
|
||||
const requests = createRequestScope()
|
||||
const response = deferred<string>()
|
||||
const belongsToAccount = requests.capture()
|
||||
let displayedAccount: string | null = 'signed in'
|
||||
const polling = response.promise.then((account) => { if (belongsToAccount()) displayedAccount = account })
|
||||
requests.invalidate()
|
||||
displayedAccount = null
|
||||
response.resolve('old linked account')
|
||||
await polling
|
||||
equal(displayedAccount, null)
|
||||
const belongsToNewChallenge = requests.capture()
|
||||
equal(belongsToNewChallenge(), true)
|
||||
equal(belongsToAccount(), false)
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
export function errorMessage(error: unknown, fallback: string): string {
|
||||
if (error instanceof Error && error.message) return error.message
|
||||
// Tauri rejects commands with the Rust error string, not an Error instance.
|
||||
if (typeof error === 'string' && error.trim()) return error
|
||||
return fallback
|
||||
}
|
||||
|
||||
/** Share a pending restore across React StrictMode's effect restart. */
|
||||
export function singleFlight<T>(operation: () => Promise<T>): () => Promise<T> {
|
||||
let pending: Promise<T> | null = null
|
||||
return () => {
|
||||
if (pending) return pending
|
||||
const request = Promise.resolve().then(operation)
|
||||
pending = request
|
||||
const clear = () => { if (pending === request) pending = null }
|
||||
void request.then(clear, clear)
|
||||
return request
|
||||
}
|
||||
}
|
||||
|
||||
/** Invalidate in-flight responses when an account or link challenge changes. */
|
||||
export function createRequestScope() {
|
||||
let revision = 0
|
||||
return {
|
||||
invalidate: () => { revision += 1 },
|
||||
capture: () => {
|
||||
const captured = revision
|
||||
return () => captured === revision
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Serializes writes so a slow older save cannot overwrite a newer choice. */
|
||||
export function createSerialQueue() {
|
||||
let tail: Promise<unknown> = Promise.resolve()
|
||||
return {
|
||||
enqueue<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = tail.then(operation)
|
||||
// A failed write must not poison all subsequent retries.
|
||||
tail = result.catch(() => undefined)
|
||||
return result
|
||||
},
|
||||
settled: () => tail,
|
||||
}
|
||||
}
|
||||
|
||||
/** Handles unmount before asynchronous native listener registration finishes. */
|
||||
export function createSubscription(
|
||||
registrations: readonly Promise<() => void>[],
|
||||
) {
|
||||
let disposed = false
|
||||
const cleanups = new Set<() => void>()
|
||||
const dispose = () => {
|
||||
disposed = true
|
||||
cleanups.forEach((cleanup) => cleanup())
|
||||
cleanups.clear()
|
||||
}
|
||||
const ready = Promise.all(registrations.map(async (registration) => {
|
||||
const cleanup = await registration
|
||||
if (disposed) cleanup()
|
||||
else cleanups.add(cleanup)
|
||||
})).then(() => undefined).catch((error: unknown) => {
|
||||
dispose()
|
||||
throw error
|
||||
})
|
||||
return { ready, dispose }
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { invoke, isTauri } from '@tauri-apps/api/core'
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||
import { createSerialQueue, createSubscription, singleFlight } from './async'
|
||||
import type {
|
||||
GameExitedPayload, InstallProgressPayload, JavaInstallation, LauncherSettings,
|
||||
LinkChallenge, LinkStatus, NativeHost, ProfileInspection, ServerStatus,
|
||||
ShaCraftAccount, ShaCraftLoginResult, SyncResult,
|
||||
LauncherUpdateStatus, LauncherUpdateProgress,
|
||||
} from '../types/launcher'
|
||||
|
||||
export const isNative = () => typeof window !== 'undefined' && isTauri()
|
||||
const accountRequests = createSerialQueue()
|
||||
const restoreAccount = singleFlight(() => accountRequests.enqueue(() => invoke<ShaCraftAccount | null>('get_shacraft_account')))
|
||||
|
||||
// Keep the IPC contract in one place. UI components never invoke native
|
||||
// commands directly and cannot pass arbitrary URLs or filesystem paths.
|
||||
export const native = {
|
||||
host: () => invoke<NativeHost>('native_host'),
|
||||
loadSettings: () => invoke<LauncherSettings>('load_settings'),
|
||||
saveSettings: (settings: LauncherSettings) => invoke<LauncherSettings>('save_settings', { settings }),
|
||||
detectJava: () => invoke<JavaInstallation | null>('detect_java'),
|
||||
inspectProfile: (profileId: string) => invoke<ProfileInspection>('inspect_remote_profile', { profileId }),
|
||||
syncProfile: (profileId: string) => invoke<SyncResult>('sync_remote_profile', { profileId }),
|
||||
getAccount: restoreAccount,
|
||||
authenticate: (username: string, password: string, register: boolean) =>
|
||||
accountRequests.enqueue(() => invoke<ShaCraftLoginResult>('shacraft_authenticate', { username, password, register })),
|
||||
logout: () => accountRequests.enqueue(() => invoke<void>('shacraft_logout')),
|
||||
startLink: (nickname: string) => accountRequests.enqueue(() => invoke<LinkChallenge>('shacraft_start_link', { nickname })),
|
||||
claimNickname: (nickname: string) => accountRequests.enqueue(() => invoke<ShaCraftAccount>('shacraft_claim_nickname', { nickname })),
|
||||
linkStatus: (challengeId: number) => accountRequests.enqueue(() => invoke<LinkStatus>('shacraft_link_status', { challengeId })),
|
||||
serverStatus: (profileId: string) => invoke<ServerStatus>('get_server_status', { profileId }),
|
||||
installGame: (profileId: string) => invoke<void>('ensure_game_installed', { profileId }),
|
||||
launchGame: (profileId: string) => invoke<void>('launch_game', { profileId }),
|
||||
updateStatus: () => invoke<LauncherUpdateStatus>('get_launcher_update_status'),
|
||||
checkUpdate: () => invoke<LauncherUpdateStatus>('check_launcher_update'),
|
||||
installUpdate: () => invoke<void>('install_launcher_update'),
|
||||
restartAfterUpdate: () => invoke<void>('restart_launcher_after_update'),
|
||||
}
|
||||
|
||||
export const windowControls = {
|
||||
minimize: () => getCurrentWindow().minimize(),
|
||||
toggleMaximize: () => getCurrentWindow().toggleMaximize(),
|
||||
close: () => getCurrentWindow().close(),
|
||||
}
|
||||
|
||||
export function watchGame(handlers: {
|
||||
progress: (payload: InstallProgressPayload) => void
|
||||
exited: (payload: GameExitedPayload) => void
|
||||
}) {
|
||||
return createSubscription([
|
||||
listen<InstallProgressPayload>('game-install-progress', ({ payload }) => handlers.progress(payload)),
|
||||
listen<GameExitedPayload>('game-exited', ({ payload }) => handlers.exited(payload)),
|
||||
])
|
||||
}
|
||||
|
||||
export function watchLauncherUpdate(progress: (payload: LauncherUpdateProgress) => void) {
|
||||
return createSubscription([
|
||||
listen<LauncherUpdateProgress>('launcher-update-progress', ({ payload }) => progress(payload)),
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { equal } from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { launchAccess, linkedNickname, validCredentials } from './account.ts'
|
||||
|
||||
test('launch requires a restored ShaCraft account and its verified Aeronautics link', () => {
|
||||
equal(launchAccess(undefined), 'loading')
|
||||
equal(launchAccess(null), 'login')
|
||||
equal(launchAccess({ username: 'account', links: [] }), 'link')
|
||||
equal(launchAccess({ username: 'account', links: [{ server_id: 'create', mc_username: 'Other_Name' }] }), 'link')
|
||||
equal(launchAccess({ username: 'account', links: [{ server_id: 'aoc', mc_username: 'Verified_Name' }] }), 'ready')
|
||||
})
|
||||
|
||||
test('identity uses snake-case account link payload and never the account login', () => {
|
||||
const account = { username: 'Local_Login', links: [
|
||||
{ server_id: 'create', mc_username: 'Other_Name' },
|
||||
{ server_id: 'aoc', mc_username: 'Verified_Name' },
|
||||
] }
|
||||
equal(linkedNickname(account), 'Verified_Name')
|
||||
equal(linkedNickname({ username: 'Valid_Login', links: [] }), null)
|
||||
equal(linkedNickname({ username: 'Valid_Login', links: [{ server_id: 'aoc', mc_username: '../unsafe' }] }), null)
|
||||
})
|
||||
|
||||
test('ShaCraft credentials preserve the server 3–32 login and 3–128 password contract', () => {
|
||||
equal(validCredentials('abc', '123'), true)
|
||||
equal(validCredentials('a'.repeat(32), 'p'.repeat(128)), true)
|
||||
equal(validCredentials('ab', '123'), false)
|
||||
equal(validCredentials('a'.repeat(33), '123'), false)
|
||||
equal(validCredentials('неверно', '123'), false)
|
||||
equal(validCredentials('account', '12'), false)
|
||||
equal(validCredentials('account', 'p'.repeat(129)), false)
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ShaCraftAccount } from '../types/launcher'
|
||||
import { isValidNickname } from './settings'
|
||||
|
||||
/** Only the authenticated account's verified aoc link selects the shared network nickname. */
|
||||
export function linkedNickname(account: ShaCraftAccount | null | undefined): string | null {
|
||||
const nickname = account?.links.find((link) => link.server_id === 'aoc')?.mc_username
|
||||
return nickname && isValidNickname(nickname) ? nickname : null
|
||||
}
|
||||
|
||||
export function launchAccess(account: ShaCraftAccount | null | undefined) {
|
||||
if (account === undefined) return 'loading'
|
||||
if (account === null) return 'login'
|
||||
return linkedNickname(account) ? 'ready' : 'link'
|
||||
}
|
||||
|
||||
export function validCredentials(username: string, password: string): boolean {
|
||||
return /^[A-Za-z0-9_]{3,32}$/.test(username) && password.length >= 3 && password.length <= 128
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { deepStrictEqual, equal, match } from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { gameReducer, initialGameState, installPercent } from './game.ts'
|
||||
|
||||
const profileId = 'aeronautics'
|
||||
const installing = gameReducer(initialGameState, { type: 'install', profileId })
|
||||
const launching = gameReducer(installing, { type: 'launch', profileId })
|
||||
|
||||
test('installation, running game and exit have distinct states', () => {
|
||||
const progressed = gameReducer(installing, { type: 'progress', progress: { stage: 'assets', currentBytes: 12, totalBytes: 24 } })
|
||||
equal(progressed.operation.phase, 'installing')
|
||||
const running = gameReducer(launching, { type: 'started', profileId })
|
||||
equal(running.operation.phase, 'running')
|
||||
deepStrictEqual(gameReducer(running, { type: 'exited', result: { profileId, exitCode: 0 } }), initialGameState)
|
||||
})
|
||||
|
||||
test('every launch can reconcile the modpack before installation and process spawn', () => {
|
||||
const syncing = gameReducer(initialGameState, { type: 'sync', profileId })
|
||||
equal(syncing.operation.phase, 'syncing')
|
||||
const synced = gameReducer(syncing, { type: 'synced', profileId })
|
||||
const installingAfterSync = gameReducer(synced, { type: 'install', profileId })
|
||||
equal(installingAfterSync.operation.phase, 'installing')
|
||||
const launchingAfterInstall = gameReducer(installingAfterSync, { type: 'launch', profileId })
|
||||
equal(launchingAfterInstall.operation.phase, 'launching')
|
||||
equal(gameReducer(launchingAfterInstall, { type: 'started', profileId }).operation.phase, 'running')
|
||||
})
|
||||
|
||||
test('a fast child exit cannot be overwritten by a late launch acknowledgement', () => {
|
||||
const exited = gameReducer(launching, { type: 'exited', result: { profileId, exitCode: 1 } })
|
||||
const lateAcknowledgement = gameReducer(exited, { type: 'started', profileId })
|
||||
equal(lateAcknowledgement.operation.phase, 'idle')
|
||||
match(lateAcknowledgement.error ?? '', /кодом 1/)
|
||||
})
|
||||
|
||||
test('foreign exit events and late install progress cannot unlock a running game', () => {
|
||||
const running = gameReducer(launching, { type: 'started', profileId })
|
||||
equal(gameReducer(running, { type: 'exited', result: { profileId: 'other', exitCode: 0 } }), running)
|
||||
equal(gameReducer(running, { type: 'progress', progress: { stage: 'assets', currentBytes: 1, totalBytes: 1 } }), running)
|
||||
equal(gameReducer(running, { type: 'sync', profileId }), running)
|
||||
})
|
||||
|
||||
test('sync completion does not complete a different operation', () => {
|
||||
equal(gameReducer(installing, { type: 'synced', profileId }), installing)
|
||||
const syncing = gameReducer(initialGameState, { type: 'sync', profileId })
|
||||
equal(gameReducer(syncing, { type: 'synced', profileId: 'other' }), syncing)
|
||||
equal(gameReducer(syncing, { type: 'synced', profileId }), initialGameState)
|
||||
})
|
||||
|
||||
test('an operation failure releases the UI and a retry clears the error', () => {
|
||||
const failed = gameReducer(installing, { type: 'failed', error: 'Network failed' })
|
||||
equal(failed.operation.phase, 'idle')
|
||||
equal(failed.error, 'Network failed')
|
||||
equal(gameReducer(failed, { type: 'install', profileId }).error, null)
|
||||
})
|
||||
|
||||
test('percent is bounded and unknown or invalid totals stay indeterminate', () => {
|
||||
equal(installPercent(null), null)
|
||||
equal(installPercent({ stage: 'java', currentBytes: 1, totalBytes: 0 }), null)
|
||||
equal(installPercent({ stage: 'assets', currentBytes: NaN, totalBytes: 10 }), null)
|
||||
equal(installPercent({ stage: 'assets', currentBytes: 15, totalBytes: 10 }), 100)
|
||||
equal(installPercent({ stage: 'assets', currentBytes: -5, totalBytes: 10 }), 0)
|
||||
equal(installPercent({ stage: 'assets', currentBytes: 5, totalBytes: 20 }), 25)
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { GameExitedPayload, InstallProgressPayload } from '../types/launcher'
|
||||
|
||||
export type GameOperation =
|
||||
| { phase: 'idle' }
|
||||
| { phase: 'syncing' | 'launching' | 'running'; profileId: string }
|
||||
| { phase: 'installing'; profileId: string; progress: InstallProgressPayload | null }
|
||||
|
||||
export interface GameState {
|
||||
operation: GameOperation
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export type GameAction =
|
||||
| { type: 'sync'; profileId: string }
|
||||
| { type: 'synced'; profileId: string }
|
||||
| { type: 'install'; profileId: string }
|
||||
| { type: 'progress'; progress: InstallProgressPayload }
|
||||
| { type: 'launch'; profileId: string }
|
||||
| { type: 'started'; profileId: string }
|
||||
| { type: 'exited'; result: GameExitedPayload }
|
||||
| { type: 'failed'; error: string }
|
||||
|
||||
export const initialGameState: GameState = { operation: { phase: 'idle' }, error: null }
|
||||
|
||||
export function gameReducer(state: GameState, action: GameAction): GameState {
|
||||
const operation = state.operation
|
||||
switch (action.type) {
|
||||
case 'sync':
|
||||
case 'install':
|
||||
if (operation.phase !== 'idle') return state
|
||||
return {
|
||||
operation: action.type === 'sync'
|
||||
? { phase: 'syncing', profileId: action.profileId }
|
||||
: { phase: 'installing', profileId: action.profileId, progress: null },
|
||||
error: null,
|
||||
}
|
||||
case 'synced':
|
||||
return operation.phase === 'syncing' && operation.profileId === action.profileId
|
||||
? initialGameState : state
|
||||
case 'progress':
|
||||
return operation.phase === 'installing'
|
||||
? { ...state, operation: { ...operation, progress: action.progress } } : state
|
||||
case 'launch':
|
||||
return operation.phase === 'installing' && operation.profileId === action.profileId
|
||||
? { ...state, operation: { phase: 'launching', profileId: action.profileId } } : state
|
||||
case 'started':
|
||||
// A fast-exiting child can emit game-exited before invoke resolves.
|
||||
return operation.phase === 'launching' && operation.profileId === action.profileId
|
||||
? { ...state, operation: { phase: 'running', profileId: action.profileId } } : state
|
||||
case 'exited':
|
||||
if ((operation.phase !== 'launching' && operation.phase !== 'running') ||
|
||||
operation.profileId !== action.result.profileId) return state
|
||||
return {
|
||||
operation: { phase: 'idle' },
|
||||
error: action.result.exitCode === 0 ? null
|
||||
: action.result.exitCode === null ? 'Игра завершилась без кода выхода. Проверьте журнал игры.'
|
||||
: `Игра завершилась с кодом ${action.result.exitCode}. Проверьте журнал игры.`,
|
||||
}
|
||||
case 'failed':
|
||||
return { operation: { phase: 'idle' }, error: action.error }
|
||||
}
|
||||
}
|
||||
|
||||
export const installStageLabels: Record<InstallProgressPayload['stage'], string> = {
|
||||
java: 'Готовим Java',
|
||||
neoforge: 'Устанавливаем NeoForge',
|
||||
libraries: 'Скачиваем библиотеки',
|
||||
assets: 'Скачиваем ресурсы игры',
|
||||
}
|
||||
|
||||
export function installPercent(progress: InstallProgressPayload | null): number | null {
|
||||
if (!progress || progress.totalBytes <= 0 || !Number.isFinite(progress.totalBytes) ||
|
||||
!Number.isFinite(progress.currentBytes)) return null
|
||||
return Math.max(0, Math.min(100, Math.round(progress.currentBytes / progress.totalBytes * 100)))
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { equal } from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { profilesReducer } from './profiles.ts'
|
||||
|
||||
test('a failed repair invalidates an old ready inspection and permits retry', () => {
|
||||
const profileId = 'aeronautics'
|
||||
const inspection = { root: '/profiles/aeronautics', managedFiles: 251, missingFiles: 0, mismatchedFiles: 0, upToDate: true }
|
||||
const ready = profilesReducer({}, { type: 'checked', profileId, inspection })
|
||||
equal(ready[profileId]?.inspection?.upToDate, true)
|
||||
const repairing = profilesReducer(ready, { type: 'check', profileId })
|
||||
equal(repairing[profileId]?.inspection, null)
|
||||
const failed = profilesReducer(repairing, { type: 'failed', profileId, error: 'Download interrupted' })
|
||||
equal(failed[profileId]?.status, 'error')
|
||||
equal(failed[profileId]?.inspection, null)
|
||||
const retrying = profilesReducer(failed, { type: 'check', profileId })
|
||||
const repaired = profilesReducer(retrying, { type: 'checked', profileId, inspection })
|
||||
equal(repaired[profileId]?.inspection?.upToDate, true)
|
||||
equal(repaired[profileId]?.error, null)
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ProfileInspection } from '../types/launcher'
|
||||
|
||||
export type ProfileState =
|
||||
| { status: 'checking'; inspection: null; error: null }
|
||||
| { status: 'checked'; inspection: ProfileInspection; error: null }
|
||||
| { status: 'error'; inspection: null; error: string }
|
||||
|
||||
type ProfileAction =
|
||||
| { type: 'check'; profileId: string }
|
||||
| { type: 'checked'; profileId: string; inspection: ProfileInspection }
|
||||
| { type: 'failed'; profileId: string; error: string }
|
||||
|
||||
export function profilesReducer(
|
||||
profiles: Record<string, ProfileState>, action: ProfileAction,
|
||||
): Record<string, ProfileState> {
|
||||
const next: ProfileState = action.type === 'check'
|
||||
? { status: 'checking', inspection: null, error: null }
|
||||
: action.type === 'checked'
|
||||
? { status: 'checked', inspection: action.inspection, error: null }
|
||||
: { status: 'error', inspection: null, error: action.error }
|
||||
return { ...profiles, [action.profileId]: next }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { LauncherSettings } from '../types/launcher'
|
||||
|
||||
export const defaultSettings: LauncherSettings = {
|
||||
memoryMb: 6 * 1024,
|
||||
nickname: 'Emil',
|
||||
accountMode: 'offline',
|
||||
}
|
||||
|
||||
export const isValidNickname = (nickname: string) => /^[A-Za-z0-9_]{3,16}$/.test(nickname)
|
||||
@@ -0,0 +1,76 @@
|
||||
import { equal, match } from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { initialUpdaterState, updateBlocksOperations, updatePercent, updaterReducer } from './updater.ts'
|
||||
|
||||
const available = updaterReducer(initialUpdaterState, { type: 'loaded', checked: true,
|
||||
status: { currentVersion: '0.1.3', supported: true, version: '0.1.4' } })
|
||||
const downloading = updaterReducer(available, { type: 'install' })
|
||||
|
||||
test('checking and available update allow play; installation locks operations until restart', () => {
|
||||
equal(updateBlocksOperations(available), false)
|
||||
equal(updateBlocksOperations(updaterReducer(available, { type: 'check' })), false)
|
||||
equal(updateBlocksOperations(downloading), true)
|
||||
const ready = updaterReducer(downloading, { type: 'ready' })
|
||||
equal(updateBlocksOperations(ready), true)
|
||||
equal(updaterReducer(ready, { type: 'check' }), ready)
|
||||
equal(updaterReducer(ready, { type: 'install' }), ready)
|
||||
const restarting = updaterReducer(ready, { type: 'restart' })
|
||||
equal(updateBlocksOperations(restarting), true)
|
||||
})
|
||||
|
||||
test('an installation failure permits playing and retrying; a restart failure retains the lock', () => {
|
||||
const failed = updaterReducer(downloading, { type: 'failed', error: 'Подпись не совпадает' })
|
||||
equal(updateBlocksOperations(failed), false)
|
||||
equal(failed.status?.version, '0.1.4')
|
||||
const retry = updaterReducer(failed, { type: 'install' })
|
||||
equal(retry.error, null)
|
||||
equal(retry.phase, 'downloading')
|
||||
const ready = updaterReducer(retry, { type: 'ready' })
|
||||
const restartError = updaterReducer(updaterReducer(ready, { type: 'restart' }), { type: 'failed', error: 'Перезапустите вручную' })
|
||||
equal(restartError.phase, 'ready')
|
||||
match(restartError.error ?? '', /вручную/)
|
||||
equal(updateBlocksOperations(restartError), true)
|
||||
})
|
||||
|
||||
test('late progress cannot undo completion or a failed installation', () => {
|
||||
const installing = updaterReducer(downloading, { type: 'progress', progress: { stage: 'installing', downloadedBytes: 20 } })
|
||||
equal(updaterReducer(installing, { type: 'progress', progress: { stage: 'downloading', downloadedBytes: 10 } }), installing)
|
||||
const ready = updaterReducer(installing, { type: 'progress', progress: { stage: 'ready', downloadedBytes: 20 } })
|
||||
equal(updaterReducer(ready, { type: 'progress', progress: { stage: 'installing', downloadedBytes: 20 } }), ready)
|
||||
const failed = updaterReducer(downloading, { type: 'failed', error: 'Сбой сети' })
|
||||
equal(updaterReducer(failed, { type: 'ready' }), failed)
|
||||
equal(updaterReducer(failed, { type: 'progress', progress: { stage: 'ready', downloadedBytes: 20 } }), failed)
|
||||
})
|
||||
|
||||
test('native status restores an update after a webview reload; early events preserve readiness', () => {
|
||||
for (const stage of ['downloading', 'installing', 'ready'] as const) {
|
||||
const restored = updaterReducer(initialUpdaterState, { type: 'loaded', checked: false,
|
||||
status: { ...available.status!, stage } })
|
||||
equal(restored.phase, stage)
|
||||
equal(updateBlocksOperations(restored), true)
|
||||
}
|
||||
const earlyReady = updaterReducer(initialUpdaterState, { type: 'progress', progress: { stage: 'ready', downloadedBytes: 20 } })
|
||||
const staleStatus = updaterReducer(earlyReady, { type: 'loaded', checked: false,
|
||||
status: { ...available.status!, stage: 'downloading' } })
|
||||
equal(staleStatus.phase, 'ready')
|
||||
equal(staleStatus.status?.currentVersion, '0.1.3')
|
||||
})
|
||||
|
||||
test('unsupported package and no available version never enter installation', () => {
|
||||
const unsupported = updaterReducer(initialUpdaterState, { type: 'loaded', checked: false,
|
||||
status: { currentVersion: '0.1.3', supported: false, reason: 'Используйте AppImage' } })
|
||||
equal(updaterReducer(unsupported, { type: 'install' }), unsupported)
|
||||
const current = updaterReducer(initialUpdaterState, { type: 'loaded', checked: true,
|
||||
status: { currentVersion: '0.1.3', supported: true } })
|
||||
equal(updaterReducer(current, { type: 'install' }), current)
|
||||
})
|
||||
|
||||
test('unknown, invalid and excessive progress cannot produce misleading percentages', () => {
|
||||
equal(updatePercent(null), null)
|
||||
equal(updatePercent({ stage: 'downloading', downloadedBytes: 1 }), null)
|
||||
equal(updatePercent({ stage: 'downloading', downloadedBytes: NaN, totalBytes: 20 }), null)
|
||||
equal(updatePercent({ stage: 'downloading', downloadedBytes: 1, totalBytes: Infinity }), null)
|
||||
equal(updatePercent({ stage: 'downloading', downloadedBytes: 10, totalBytes: 20 }), 50)
|
||||
equal(updatePercent({ stage: 'downloading', downloadedBytes: 30, totalBytes: 20 }), 100)
|
||||
equal(updatePercent({ stage: 'downloading', downloadedBytes: -1, totalBytes: 20 }), 0)
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { LauncherUpdateProgress, LauncherUpdateStatus } from '../types/launcher'
|
||||
|
||||
export interface UpdaterState {
|
||||
phase: 'loading' | 'idle' | 'checking' | 'downloading' | 'installing' | 'ready' | 'restarting'
|
||||
status: LauncherUpdateStatus | null
|
||||
progress: LauncherUpdateProgress | null
|
||||
error: string | null
|
||||
checked: boolean
|
||||
}
|
||||
|
||||
export const initialUpdaterState: UpdaterState = {
|
||||
phase: 'loading', status: null, progress: null, error: null, checked: false,
|
||||
}
|
||||
|
||||
type UpdaterAction =
|
||||
| { type: 'loaded'; status: LauncherUpdateStatus; checked: boolean; error?: string | null }
|
||||
| { type: 'check' }
|
||||
| { type: 'install' }
|
||||
| { type: 'progress'; progress: LauncherUpdateProgress }
|
||||
| { type: 'ready' }
|
||||
| { type: 'restart' }
|
||||
| { type: 'failed'; error: string }
|
||||
|
||||
export function updateBlocksOperations(state: UpdaterState): boolean {
|
||||
return ['downloading', 'installing', 'ready', 'restarting'].includes(state.phase)
|
||||
}
|
||||
|
||||
export function updaterReducer(state: UpdaterState, action: UpdaterAction): UpdaterState {
|
||||
switch (action.type) {
|
||||
case 'loaded':
|
||||
if (updateBlocksOperations(state)) return { ...state, status: action.status }
|
||||
return { ...state, phase: action.status.stage === 'ready' || action.status.stage === 'downloading' || action.status.stage === 'installing'
|
||||
? action.status.stage : 'idle', status: action.status,
|
||||
checked: action.checked, error: action.error ?? null }
|
||||
case 'check':
|
||||
return updateBlocksOperations(state) ? state : { ...state, phase: 'checking', error: null }
|
||||
case 'install':
|
||||
return state.phase !== 'idle' || !state.status?.supported || !state.status.version ? state
|
||||
: { ...state, phase: 'downloading', progress: null, error: null }
|
||||
case 'progress':
|
||||
if (state.phase !== 'loading' && state.phase !== 'downloading' && state.phase !== 'installing') return state
|
||||
// A delayed download event cannot revert an installation to downloading.
|
||||
if (state.phase === 'installing' && action.progress.stage === 'downloading') return state
|
||||
return { ...state, phase: action.progress.stage, progress: action.progress }
|
||||
case 'ready':
|
||||
return state.phase === 'downloading' || state.phase === 'installing'
|
||||
? { ...state, phase: 'ready', error: null } : state
|
||||
case 'restart':
|
||||
return state.phase === 'ready' ? { ...state, phase: 'restarting', error: null } : state
|
||||
case 'failed':
|
||||
return { ...state, phase: state.phase === 'ready' || state.phase === 'restarting' ? 'ready' : 'idle', error: action.error }
|
||||
}
|
||||
}
|
||||
|
||||
export function updatePercent(progress: LauncherUpdateProgress | null): number | null {
|
||||
if (!progress || !progress.totalBytes || progress.totalBytes <= 0 ||
|
||||
!Number.isFinite(progress.totalBytes) || !Number.isFinite(progress.downloadedBytes)) return null
|
||||
return Math.max(0, Math.min(100, Math.round(progress.downloadedBytes / progress.totalBytes * 100)))
|
||||
}
|
||||
+48
-6
@@ -1,6 +1,7 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Unbounded:wght@500;600&display=swap');
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: 'Manrope', sans-serif;
|
||||
color: #eef2ed;
|
||||
background: #090c0a;
|
||||
@@ -17,10 +18,11 @@
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
button, input { font: inherit; }
|
||||
button, input, select { font: inherit; }
|
||||
button { color: inherit; }
|
||||
body { margin: 0; min-width: 1040px; min-height: 680px; overflow: hidden; }
|
||||
button:focus-visible, input:focus-visible { outline: 2px solid var(--green); outline-offset: 2px; }
|
||||
button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid var(--green); outline-offset: 2px; }
|
||||
button:disabled { cursor: default; }
|
||||
|
||||
.app-shell {
|
||||
height: 100vh;
|
||||
@@ -77,6 +79,7 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--green); out
|
||||
.account-chip strong { font-size: 12px; }
|
||||
.account-chip small { color: #6d756e; font-size: 9px; }
|
||||
.account-chip svg { color: #555e56; }
|
||||
.account-logout { padding: 0; background: transparent; border: 0; cursor: pointer; color: inherit; }
|
||||
|
||||
.stage { position: relative; overflow: hidden; isolation: isolate; background: #16231b; }
|
||||
.stage::before { content: ''; position: absolute; inset: 0; z-index: -5; background:
|
||||
@@ -123,11 +126,11 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--green); out
|
||||
|
||||
.drawer-backdrop { position: fixed; inset: 48px 0 0; background: rgba(0,0,0,.44); opacity: 0; pointer-events: none; transition: opacity .2s; z-index: 20; }
|
||||
.drawer-backdrop.visible { opacity: 1; pointer-events: auto; }
|
||||
.settings-drawer { position: fixed; top: 48px; right: 0; bottom: 0; width: 390px; background: #121713; border-left: 1px solid var(--line); z-index: 21; padding: 28px; transform: translateX(100%); transition: transform .24s cubic-bezier(.2,.8,.2,1); box-shadow: -30px 0 70px rgba(0,0,0,.35); }
|
||||
.settings-drawer { position: fixed; top: 48px; right: 0; bottom: 0; width: 390px; overflow-y: auto; background: #121713; border-left: 1px solid var(--line); z-index: 21; padding: 28px; transform: translateX(100%); transition: transform .24s cubic-bezier(.2,.8,.2,1); box-shadow: -30px 0 70px rgba(0,0,0,.35); }
|
||||
.settings-drawer.open { transform: translateX(0); }
|
||||
.drawer-title { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 34px; }
|
||||
.drawer-title p { color: #737c74; font-size: 11px; margin: 0 0 5px; }
|
||||
.drawer-title h2 { margin: 0; font-family: 'Unbounded'; font-size: 25px; }
|
||||
.drawer-title h2 { margin: 0; font-family: 'Unbounded', sans-serif; font-size: 21px; }
|
||||
.drawer-title button { border: 0; background: #222923; width: 38px; height: 38px; border-radius: 10px; display: grid; place-items: center; cursor: pointer; }
|
||||
.drawer-title button svg { width: 18px; }
|
||||
.range-setting { display: grid; background: #191f1a; padding: 18px; border-radius: 13px; margin-bottom: 12px; }
|
||||
@@ -142,13 +145,44 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--green); out
|
||||
.setting-row.static { cursor: default; }
|
||||
.setting-row.static:hover { color: #abb3ac; }
|
||||
.setting-row small { color: #7f8a80; font-size: 11px; }
|
||||
.setting-row select { background: #121713; border: 0; color: inherit; text-align: right; }
|
||||
.text-setting { display: grid; gap: 9px; padding: 18px 0; border-bottom: 1px solid var(--line); }
|
||||
.text-setting > span { display: flex; align-items: baseline; justify-content: space-between; }
|
||||
.text-setting strong { font-size: 12px; }
|
||||
.text-setting span small, .text-setting > small { color: #7f8a80; font-size: 11px; }
|
||||
.text-setting input { width: 100%; box-sizing: border-box; background: #0d110e; color: #eef2ed; border: 1px solid #384239; padding: 10px 11px; font: 600 13px/1 Manrope, sans-serif; outline: none; }
|
||||
.text-setting input:focus { border-color: var(--accent); }
|
||||
.drawer-note { position: absolute; left: 28px; right: 28px; bottom: 26px; padding: 13px 15px; background: #17231a; color: #8fb193; border-radius: 10px; font-size: 10px; }
|
||||
.text-setting input:focus { border-color: var(--green); }
|
||||
.drawer-note { margin-top: 24px; overflow-wrap: anywhere; padding: 13px 15px; background: #17231a; color: #8fb193; border-radius: 10px; font-size: 10px; }
|
||||
.settings-feedback { font-size: 11px; color: var(--muted); }
|
||||
.account-hint { font-size: 11px; line-height: 1.5; color: var(--muted); overflow-wrap: anywhere; }
|
||||
.recovery-codes { white-space: pre-line; font-size: 15px; max-height: 45vh; overflow: auto; }
|
||||
.settings-feedback button { padding: 6px 10px; background: var(--panel-2); border: 1px solid var(--line); cursor: pointer; }
|
||||
|
||||
.update-banner { position: absolute; top: 70px; right: 28px; z-index: 10; display: flex; align-items: center; gap: 9px; max-width: 440px; padding: 10px 14px; border: 1px solid #325939; border-radius: 9px; background: #17291bdc; color: #b6e5b8; font-size: 11px; cursor: pointer; box-shadow: 0 6px 24px #0003; }
|
||||
.update-banner:hover { background: #203a25; }
|
||||
.update-banner-dot { width: 6px; height: 6px; flex: 0 0 6px; border-radius: 50%; background: var(--green); }
|
||||
.launcher-update { border: 1px solid #2c3e30; border-radius: 12px; padding: 17px; margin-bottom: 24px; background: #17201a; }
|
||||
.launcher-update-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.launcher-update-heading h3 { margin: 0; font-size: 13px; font-weight: 750; }
|
||||
.launcher-update-heading > span { color: #a6bba8; font-size: 11px; }
|
||||
.launcher-update p { font-size: 11px; line-height: 1.65; margin: 10px 0 0; color: #97a89a; }
|
||||
.launcher-update p.update-success { color: #b0e2b1; }
|
||||
.launcher-update .update-error { color: #eaae89; }
|
||||
.launcher-update progress { width: 100%; height: 5px; accent-color: var(--green); margin-top: 12px; }
|
||||
.update-actions { display: flex; align-items: stretch; flex-direction: column; gap: 9px; margin-top: 15px; }
|
||||
.update-actions button { display: flex; justify-content: center; align-items: center; gap: 8px; border: 1px solid #35543b; border-radius: 7px; min-height: 35px; padding: 8px 10px; font-size: 11px; cursor: pointer; }
|
||||
.update-actions button:disabled { cursor: default; opacity: .48; }
|
||||
.update-actions button svg { width: 14px; height: 14px; }
|
||||
.update-primary { background: var(--green); color: #102313; font-weight: 800; }
|
||||
.update-primary:hover:not(:disabled) { background: #91df93; }
|
||||
.update-check { background: transparent; color: #b6c6b9; }
|
||||
.update-check:hover:not(:disabled) { background: #263b2c; }
|
||||
.update-notes { margin-top: 10px; font-size: 11px; color: #b6c6b9; }
|
||||
.update-notes summary { cursor: pointer; }
|
||||
.update-notes p { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 160px; overflow: auto; }
|
||||
.launcher-update .update-hint { color: #a8a58e; }
|
||||
.status-error, .build-state .status-error, .text-setting > .status-error { color: #eea18f; overflow-wrap: anywhere; }
|
||||
.build-state .status-error { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
|
||||
.login-modal { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 22; width: 360px; background: #121713; border: 1px solid var(--line); border-radius: 14px; padding: 26px; text-align: center; box-shadow: 0 30px 80px rgba(0,0,0,.5); }
|
||||
.login-modal h2 { margin: 0 0 10px; font-family: 'Unbounded'; font-size: 18px; }
|
||||
@@ -156,6 +190,14 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--green); out
|
||||
.login-code { font-family: 'Unbounded'; font-size: 28px; letter-spacing: .08em; color: var(--green); background: #191f1a; border-radius: 10px; padding: 14px; margin-bottom: 12px; }
|
||||
.login-url { color: var(--ice) !important; font-size: 11px !important; word-break: break-all; }
|
||||
|
||||
.feedback-dialog { width: min(440px, calc(100vw - 40px)); max-height: calc(100vh - 80px); overflow-y: auto; padding: 28px; margin: auto; color: #e9eee9; background: #151d17; border: 1px solid #456348; border-radius: 16px; box-shadow: 0 24px 90px #0009; }
|
||||
.feedback-dialog::backdrop { background: #050a07b8; }
|
||||
.feedback-dialog.error { border-color: #a46c54; }
|
||||
.feedback-dialog h2 { margin: 0 0 14px; font-size: 19px; }
|
||||
.feedback-dialog p { margin: 0 0 24px; color: #c1cbc2; font-size: 14px; line-height: 1.65; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.feedback-dialog button { width: 100%; padding: 12px; border: 0; border-radius: 9px; background: var(--green); color: #102112; font: inherit; font-weight: 650; cursor: pointer; }
|
||||
.feedback-dialog button:focus-visible { outline: 2px solid #e5ffe8; outline-offset: 4px; }
|
||||
|
||||
@media (max-width: 1160px) {
|
||||
.workspace { grid-template-columns: 58px 224px 1fr; }
|
||||
.brand { width: 240px; }
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user