Author SHA1 Message Date
emil28092005 728fed7de3 Document verified launcher 0.1.7 publication [skip ci] 2026-09-17 04:12:13 +03:00
emil28092005 b674347e86 Prepare launcher 0.1.7 for server migration and manual key transition 2026-09-17 03:44:57 +03:00
Emil a7eee390ab Record published launcher 0.1.6 verification [skip ci] 2026-09-13 23:05:35 +03:00
Emil 5b741771b4 Run publisher signature checks where minisign is packaged 2026-09-13 22:38:51 +03:00
Emil 799fa692ef Add Minigames profile and Fabric admission in launcher 0.1.6 2026-09-13 22:36:20 +03:00
Emil 69946e62f1 Verify live admission mod recovery and document direct multiplayer join 2026-09-11 00:02:55 +03:00
Emil fd8a6a87a1 Document verified cross-platform launcher 0.1.5 publication 2026-09-10 21:36:06 +03:00
Emil b43fc610c4 Fix updater platform selection compilation on Windows and macOS 2026-09-10 21:10:20 +03:00
Emil effe683a40 Prepare launcher 0.1.5 cross-platform release 2026-09-10 21:04:46 +03:00
Emil 260e4577d0 Support signed deb self-updates with system authorization in 0.1.4 2026-09-10 03:28:51 +03:00
Emil bb4b1f8051 Add signed launcher self-updates and publish Linux 0.1.3 2026-09-10 02:47:16 +03:00
Emil bf43254c57 Add account admission tickets and nickname feedback for launcher 0.1.2 2026-09-10 01:30:50 +03:00
emil28092005 3f3be65a3f merge: preserve ShaCraft 0.1.1 account and Windows features in modular launcher 2026-09-09 13:10:17 +03:00
emil28092005 cde13c4489 refactor: modularize launcher UI and native services with verified IO 2026-09-09 12:55:15 +03:00
Emil 2d60f5eb3c chore: bump launcher to 0.1.1 2026-09-07 22:51:03 +03:00
Emil ff666ba55f fix: harden Windows install and launch pipeline 2026-09-07 22:41:59 +03:00
Emil 5fa58b0013 chore: allow three-character passwords 2026-09-07 16:23:27 +03:00
Emil 513be6274f feat: require ShaCraft account for launcher identity 2026-09-07 14:26:37 +03:00
Emil 6311a7c502 docs: license project under MIT 2026-09-06 23:38:08 +03:00
126 changed files with 14520 additions and 1405 deletions
+33 -7
View File
@@ -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
+56
View File
@@ -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
+4
View File
@@ -10,3 +10,7 @@ src-tauri/gen/
*.p12
*.pfx
*.sig
admission-client/.gradle/
admission-client/build/
graphify-out/
+195 -17
View File
@@ -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.
+21
View File
@@ -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.
+129
View File
@@ -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 доказательством
неизменённости клиентского бинарника.
+52 -24
View File
@@ -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 токены, ключи, пользовательские данные или пакеты игры.
+47
View File
@@ -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.
+37
View File
@@ -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
+9
View File
@@ -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
Vendored Executable
+248
View File
@@ -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" "$@"
+82
View File
@@ -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%
+8
View File
@@ -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));
}
}
+28 -6
View File
@@ -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
View File
@@ -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 316 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.
+271
View File
@@ -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/).
+86
View File
@@ -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 пока нет.
+20
View File
@@ -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.
+117
View File
@@ -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.
+63
View File
@@ -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
}
}
+589 -39
View File
File diff suppressed because it is too large Load Diff
+16 -11
View File
@@ -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"
}
}
+1
View File
@@ -0,0 +1 @@
__pycache__/
+360
View File
@@ -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()
+5
View File
@@ -0,0 +1,5 @@
{
"bundle": {
"createUpdaterArtifacts": false
}
}
+287
View File
@@ -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()
+332 -8
View File
@@ -1770,6 +1770,36 @@ dependencies = [
"windows-sys 0.45.0",
]
[[package]]
name = "jni"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
dependencies = [
"cfg-if",
"combine",
"jni-macros",
"jni-sys 0.4.1",
"log",
"simd_cesu8",
"thiserror 2.0.20",
"walkdir",
"windows-link 0.2.1",
]
[[package]]
name = "jni-macros"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"simd_cesu8",
"syn 2.0.119",
]
[[package]]
name = "jni-sys"
version = "0.3.1"
@@ -1975,6 +2005,12 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -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
View File
@@ -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"] }
+244
View File
@@ -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);
}
}
+113
View File
@@ -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())
}
+242
View File
@@ -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}"))?
}
+43
View File
@@ -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())
}
+17
View File
@@ -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}"))
}
+24
View File
@@ -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())
}
+47
View File
@@ -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}"))?
}
+97
View File
@@ -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
}
+203
View File
@@ -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()
}
+555
View File
@@ -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
View File
@@ -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());
}
}
+220
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();
}
+122
View File
@@ -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
View File
@@ -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
View File
@@ -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();
}
}
+56
View File
@@ -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
View File
@@ -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]
+387
View File
@@ -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("зарезервирован"));
}
}
+190
View File
@@ -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();
}
}
+55
View File
@@ -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}");
}
}
}
+853
View File
@@ -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
);
});
}
}
+23 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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>
)
}
+62
View File
@@ -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>332 символа</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>}
</>
)
}
+34
View File
@@ -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>
)
}
+56
View File
@@ -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>
}
+51
View File
@@ -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>
</>
)
}
+67
View File
@@ -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>
)
}
+24
View File
@@ -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>
</>
)
}
+26
View File
@@ -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>
)
}
+76
View File
@@ -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>
</>
)
}
+22
View File
@@ -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>
)
}
+60
View File
@@ -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/)
})
+75
View File
@@ -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, /администратора|системном окне/)
}
})
+20
View File
@@ -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',
},
]
+137
View File
@@ -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([]),
}
}
+106
View File
@@ -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 }
}
+83
View File
@@ -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 }
}
+27
View File
@@ -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
}
+67
View File
@@ -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
View File
@@ -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>)
+113
View File
@@ -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)
})
+67
View File
@@ -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 }
}
+61
View File
@@ -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)),
])
}
+31
View File
@@ -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 332 login and 3128 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)
})
+18
View File
@@ -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
}
+63
View File
@@ -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)
})
+75
View File
@@ -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)))
}
+19
View File
@@ -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)
})
+22
View File
@@ -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 }
}
+9
View File
@@ -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)
+76
View File
@@ -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)
})
+59
View File
@@ -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
View File
@@ -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