merge: preserve ShaCraft 0.1.1 account and Windows features in modular launcher
This commit is contained in:
@@ -2,6 +2,8 @@ name: Cross-platform build
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -35,7 +37,7 @@ jobs:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Install Linux desktop dependencies
|
||||
- name: Install Linux system dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
@@ -44,10 +46,28 @@ jobs:
|
||||
- run: npm test
|
||||
- run: cargo test --locked --manifest-path src-tauri/Cargo.toml
|
||||
- run: npm run tauri:build -- ${{ matrix.args }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
- name: Upload Windows installers
|
||||
if: runner.os == 'Windows'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: shacraft-${{ matrix.os }}
|
||||
name: shacraft-launcher-windows-x64
|
||||
if-no-files-found: error
|
||||
path: |
|
||||
src-tauri/target/release/bundle/**
|
||||
src-tauri/target/*/release/bundle/**
|
||||
src-tauri/target/release/bundle/nsis/*.exe
|
||||
src-tauri/target/release/bundle/msi/*.msi
|
||||
- name: Upload Linux packages
|
||||
if: runner.os == 'Linux'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: shacraft-launcher-linux-x64
|
||||
if-no-files-found: error
|
||||
path: |
|
||||
src-tauri/target/release/bundle/appimage/*.AppImage
|
||||
src-tauri/target/release/bundle/deb/*.deb
|
||||
- name: Upload macOS package
|
||||
if: runner.os == 'macOS'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: shacraft-launcher-${{ matrix.name == 'macOS Apple Silicon' && 'macos-arm64' || 'macos-x64' }}
|
||||
if-no-files-found: error
|
||||
path: src-tauri/target/*/release/bundle/dmg/*.dmg
|
||||
|
||||
@@ -21,8 +21,11 @@ payload are in `/root/shacraft` on the ShaCraft host; see
|
||||
|
||||
## Trust model
|
||||
|
||||
- The only supported remote profile endpoint is
|
||||
- The only supported remote profile manifest endpoint is
|
||||
`https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest`.
|
||||
The read-only Aeronautics player-count endpoint
|
||||
`https://shacraft.ru/api/online/aoc` is also hardcoded in `remote.rs`; it
|
||||
is display-only and is never allowed to influence downloads or launching.
|
||||
- The response is an Ed25519 envelope. `src-tauri/src/remote.rs` verifies its
|
||||
embedded public key and `keyId` **before** parsing the payload.
|
||||
- `src-tauri/src/manifest.rs` then validates paths, SHA-256, sizes, HTTPS and
|
||||
@@ -36,15 +39,13 @@ 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. At launch,
|
||||
the nickname is fetched from the verified `aoc` account link; the legacy
|
||||
nickname in `settings.json` is ignored as an identity source. Server-side
|
||||
whitelist enforcement and LoginSystem remain the final access-control
|
||||
boundary, including for old launcher versions.
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -59,7 +60,7 @@ payload are in `/root/shacraft` on the ShaCraft host; see
|
||||
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.
|
||||
Offline launch must not acquire the Microsoft refresh permit.
|
||||
Launch must use the authenticated ShaCraft nickname; no settings fallback.
|
||||
- `storage.rs` — unique same-directory atomic writes, owner-only Unix files.
|
||||
- `trusted_http.rs` — HTTPS and exact-host redirect policy per game provider.
|
||||
- `download.rs` — shared verified-download helper (temp file, hash,
|
||||
@@ -74,6 +75,8 @@ 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.
|
||||
- `shacraft_account.rs` — local ShaCraft login/registration, session and
|
||||
verified nickname-link API.
|
||||
- `launch.rs` — builds and spawns the actual `java` process.
|
||||
- `src-tauri/src/settings.rs` — durable local preferences; maintain backward
|
||||
compatibility with already-written JSON.
|
||||
@@ -82,7 +85,7 @@ payload are in `/root/shacraft` on the ShaCraft host; see
|
||||
- `docs/game-trust-boundary.md` — the Mojang/NeoForge/Microsoft/Adoptium
|
||||
trust domains used to install and run the game itself.
|
||||
- `.github/workflows/check.yml` — push/PR UI checks and Linux Rust tests.
|
||||
- `.github/workflows/build.yml` — manual cross-platform builds with artifacts;
|
||||
- `.github/workflows/build.yml` — main-push/manual cross-platform builds with artifacts;
|
||||
not a signed release or updater publication.
|
||||
|
||||
## Verification
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 ShaCraft
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -11,16 +11,18 @@
|
||||
|
||||
## Следующие задачи
|
||||
|
||||
- [ ] Microsoft: собственный public-client ID + Minecraft API approval;
|
||||
затем живой device-code/login/refresh/logout тест. Сейчас ID — placeholder.
|
||||
- [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 не заменяют эти прогоны.
|
||||
- [ ] Подписанные installer-релизы и подписанное автообновление лаунчера.
|
||||
- [ ] Реальная отмена загрузок, журнал с редактированием токенов и retry UX.
|
||||
- [ ] Выбор каталога профиля и безопасный reset только managed-файлов.
|
||||
- [ ] Keychain-хранилище refresh token; cross-process exclusion при необходимости.
|
||||
- [ ] Динамический каталог/онлайн серверов, новости и ссылки сообщества.
|
||||
Недоступные функции сейчас отключены, данные не имитируются.
|
||||
- [ ] Динамический каталог и новости; реальный Aeronautics онлайн уже
|
||||
загружается через фиксированный display-only API. Не имитировать данные.
|
||||
|
||||
## Связанные серверные риски
|
||||
|
||||
|
||||
@@ -7,9 +7,11 @@ React/TypeScript интерфейс, Rust — файлы, сеть и запус
|
||||
модов и конфигурации, Java discovery/provisioning, bootstrap Minecraft и
|
||||
NeoForge, настройки памяти и ника, обработка установки/запуска/выхода.
|
||||
|
||||
Режим аккаунта выбирается явно: offline-профиль или Microsoft. Код Microsoft
|
||||
OAuth/проверки владения готов, но **вход ещё требует собственного client ID и
|
||||
одобрения Minecraft API**. Offline не подставляется при ошибке Microsoft.
|
||||
Вход выполняется через аккаунт ShaCraft — тот же, что на сайте. Игровой ник
|
||||
берётся только из подтверждённой привязки Aeronautics, а не из редактируемых
|
||||
локальных настроек. Пароли не сохраняются; сессию можно отозвать.
|
||||
Microsoft OAuth-модуль сохранён отдельно, но не используется текущим
|
||||
сценарием запуска; для его активации потребуются client ID и API approval.
|
||||
|
||||
## Разработка
|
||||
|
||||
@@ -34,7 +36,7 @@ cargo test --locked --manifest-path src-tauri/Cargo.toml
|
||||
```
|
||||
|
||||
Build включает строгий TypeScript. GitHub Actions проверяет UI и Rust на
|
||||
push/PR; ручной workflow собирает пакеты Windows x64, Linux x64, macOS Intel
|
||||
push/PR; workflow на main-push/ручном запуске собирает Windows x64, Linux x64, macOS Intel
|
||||
и Apple Silicon и сохраняет артефакты. Подпись релиза/автообновления ещё впереди.
|
||||
Используемые macOS runners соответствуют [списку GitHub](https://docs.github.com/en/actions/reference/runners/github-hosted-runners).
|
||||
|
||||
|
||||
+18
-10
@@ -33,10 +33,14 @@ real main class is `net.minecraftforge.installer.SimpleInstaller`, which
|
||||
supports this flag. **Empirically verified (2026-09-06)**: it refuses to
|
||||
target a directory unless a `launcher_profiles.json` stub already exists
|
||||
there ("you need to run the launcher first!") — `ensure_launcher_profiles_stub`
|
||||
writes a minimal one. It then fetches and patches vanilla itself; no
|
||||
pre-seeding needed. Its own downloads go straight to `maven.neoforged.net`/
|
||||
Mojang, outside our control — an accepted trust delegation to NeoForge's
|
||||
official tooling once the installer binary itself is verified.
|
||||
writes a minimal one. It fetches the inputs needed to patch vanilla, but does
|
||||
not guarantee that the complete vanilla runtime library set is present.
|
||||
After installation, `mojang::ensure_client_jar` and `ensure_libraries` always
|
||||
verify and download the complete merged launch set, including LWJGL and its
|
||||
platform natives. The installer's own downloads go straight to
|
||||
`maven.neoforged.net`/Mojang, outside our control — an accepted trust
|
||||
delegation to NeoForge's official tooling once the installer binary itself is
|
||||
verified.
|
||||
|
||||
Also verified: the resulting
|
||||
`libraries/net/neoforged/neoforge/<ver>/neoforge-<ver>-client.jar` (the
|
||||
@@ -53,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
|
||||
@@ -66,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
|
||||
|
||||
|
||||
@@ -4,10 +4,14 @@
|
||||
|
||||
The launcher persists local settings, synchronises Aeronautics mod/config
|
||||
files from the signed ShaCraft v2 manifest, installs the exact Minecraft +
|
||||
NeoForge version the manifest specifies, and launches the game. 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 game
|
||||
identity is derived only from that account's verified Aeronautics nickname;
|
||||
the legacy editable nickname setting is not trusted at launch.
|
||||
|
||||
The interface also shows a live Aeronautics player count from the fixed,
|
||||
read-only `https://shacraft.ru/api/online/aoc` endpoint. It is display-only:
|
||||
the result never controls files, versions, URLs, or the launch command.
|
||||
|
||||
Not yet implemented: a user-selectable profile directory, a "reset managed
|
||||
files only" recovery action, and signed cross-platform release builds of the
|
||||
@@ -28,7 +32,9 @@ Game itself (never controlled by the manifest above)
|
||||
-> Java 21 via Adoptium if none installed (runtime.rs)
|
||||
-> NeoForge's own installer, run headlessly (neoforge.rs)
|
||||
-> generic inheritsFrom merge of the two version JSONs (mojang.rs)
|
||||
-> explicit Microsoft session (msa.rs) OR offline identity (session.rs)
|
||||
-> SHA-1-verified merged libraries + platform natives (mojang.rs)
|
||||
-> verified ShaCraft account link (shacraft_account.rs)
|
||||
-> deterministic offline UUID for the linked nickname (session.rs)
|
||||
-> java process spawned with the merged classpath/args (launch.rs)
|
||||
```
|
||||
|
||||
@@ -37,8 +43,9 @@ 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. None of these should be assumed to
|
||||
be the system `.minecraft` directory.
|
||||
|
||||
## Aeronautics contract
|
||||
@@ -52,6 +59,22 @@ 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 most recently verified `aoc` nickname returned by the
|
||||
authenticated account API. Local nickname edits cannot select an identity.
|
||||
|
||||
## Planned but not implemented
|
||||
|
||||
1. User-selectable profile directory and structured launcher logs.
|
||||
2. "Reset managed files only" recovery action that doesn't touch player
|
||||
worlds/screenshots/resourcepacks.
|
||||
3. Signed, cross-platform release builds of the launcher itself.
|
||||
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.
|
||||
|
||||
Do not represent these as completed features in UI or release notes.
|
||||
|
||||
|
||||
## Module boundaries (2026-09-09)
|
||||
|
||||
@@ -62,32 +85,23 @@ 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/`. Install/account permits in
|
||||
`operations.rs` stay owned by blocking workers until completion. These are
|
||||
process-local guards, not cross-process locks or cancellation support.
|
||||
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
|
||||
account files are created owner-only rather than chmodded after writing.
|
||||
`trusted_http.rs` constrains initial provider URLs and every redirect.
|
||||
Manifest profile identity, size, signature, portable paths and existing
|
||||
symlinks are checked before managed file writes. Local same-user TOCTOU is
|
||||
outside this protection; do not describe it as an OS sandbox.
|
||||
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.
|
||||
|
||||
## 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 these checks on Linux.
|
||||
Manual `build.yml` builds Windows x64, Linux x64 and both macOS architectures
|
||||
and uploads bundles. Packages are not yet signed release artifacts.
|
||||
|
||||
## 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. Cancellation, structured logs and full cold-install/recovery beta on
|
||||
every target OS. Current install progress reports actual stage work;
|
||||
bytes and installer completion counts are not interchangeable units.
|
||||
|
||||
Do not represent these as completed features in UI or release notes.
|
||||
covers native policy and storage. Push/PR CI repeats checks on Linux.
|
||||
The package workflow runs on main pushes or manually and builds Windows
|
||||
x64, Linux x64 and both macOS architectures with named artifacts.
|
||||
Packages are not yet signed release artifacts. Native cold-install and
|
||||
launch tests are required before calling a platform release-ready.
|
||||
|
||||
@@ -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 пока нет.
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "shacraft-launcher-ui",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "shacraft-launcher-ui",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "2.11.1",
|
||||
"lucide-react": "1.41.0",
|
||||
|
||||
+3
-2
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "shacraft-launcher-ui",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "npm run typecheck && vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "tsx --test src/services/async.test.ts src/state/game.test.ts src/state/profiles.test.ts",
|
||||
"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",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "tauri dev",
|
||||
|
||||
Generated
+1
-1
@@ -3216,7 +3216,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "shacraft-launcher"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"ed25519-dalek",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
[package]
|
||||
name = "shacraft-launcher"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
description = "ShaCraft Minecraft launcher"
|
||||
authors = ["ShaCraft"]
|
||||
license = "MIT"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
||||
@@ -3,5 +3,11 @@
|
||||
"identifier": "main-window",
|
||||
"description": "Minimal permissions for the ShaCraft main window.",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default"]
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-start-dragging"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
use super::data_dir;
|
||||
use crate::{
|
||||
msa,
|
||||
operations::{LauncherOperations, Operation},
|
||||
session, settings,
|
||||
};
|
||||
use crate::{msa, operations::LauncherOperations};
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
@@ -116,53 +111,3 @@ pub(crate) async fn logout(
|
||||
.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.
|
||||
pub(super) fn resolve_identity(
|
||||
data_dir: &Path,
|
||||
settings: &settings::LauncherSettings,
|
||||
account_operation: &Operation,
|
||||
) -> Result<session::PlayerIdentity, String> {
|
||||
match settings.account_mode {
|
||||
settings::AccountMode::Offline => Ok(session::PlayerIdentity::Offline {
|
||||
name: settings.nickname.clone(),
|
||||
}),
|
||||
settings::AccountMode::Microsoft => {
|
||||
let _permit = account_operation.acquire("Account operation")?;
|
||||
let client = msa::http_client().map_err(|error| error.to_string())?;
|
||||
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())?;
|
||||
msa::save_refresh_token(data_dir, &result.refresh_token)
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(session::PlayerIdentity::Microsoft(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn offline_identity_does_not_wait_for_microsoft_refresh() {
|
||||
let operation = Operation::default();
|
||||
let _refresh = operation.acquire("Account operation").unwrap();
|
||||
let settings = settings::LauncherSettings::default();
|
||||
let identity = resolve_identity(
|
||||
Path::new("/unused-account-directory"),
|
||||
&settings,
|
||||
&operation,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(identity, session::PlayerIdentity::Offline { name } if name == settings.nickname)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{account::resolve_identity, data_dir};
|
||||
use super::{data_dir, shacraft::resolve_identity};
|
||||
use crate::{
|
||||
java, launch, manifest, mojang, neoforge, operations::LauncherOperations, remote, runtime,
|
||||
settings,
|
||||
@@ -114,24 +114,23 @@ pub(crate) async fn ensure_game_installed(
|
||||
&cache_dir,
|
||||
&stage_progress("neoforge"),
|
||||
)?;
|
||||
if manifest.minecraft.loader.kind != "neoforge" {
|
||||
// Vanilla-only profiles skip the installer, which normally
|
||||
// downloads vanilla itself; do it ourselves here instead.
|
||||
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())?;
|
||||
};
|
||||
// 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())?;
|
||||
@@ -151,10 +150,9 @@ pub(crate) struct GameExited {
|
||||
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
|
||||
/// 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(
|
||||
@@ -165,7 +163,7 @@ pub(crate) async fn launch_game(
|
||||
let game_dir = data_dir(&app)?.join("game");
|
||||
let data_dir = data_dir(&app)?;
|
||||
let permit = state.installation.acquire("Installation")?;
|
||||
let account_operation = state.account.clone();
|
||||
let account_operation = state.shacraft_account.clone();
|
||||
|
||||
tauri::async_runtime::spawn_blocking(move || -> Result<(), String> {
|
||||
let _permit = permit;
|
||||
@@ -174,7 +172,7 @@ pub(crate) async fn launch_game(
|
||||
let manifest = remote::fetch_manifest(&profile_id).map_err(|error| error.to_string())?;
|
||||
let profile_dir = data_dir.join("profiles").join(&manifest.id);
|
||||
let settings = settings::load(&data_dir).map_err(|error| error.to_string())?;
|
||||
let identity = resolve_identity(&data_dir, &settings, &account_operation)?;
|
||||
let identity = resolve_identity(&data_dir, &account_operation)?;
|
||||
|
||||
// Everything here should already be installed by `ensure_game_installed`,
|
||||
// so these are expected to hit their fast paths; no progress to show.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::data_dir;
|
||||
use crate::{java, manifest};
|
||||
use crate::{java, manifest, msa};
|
||||
use serde::Serialize;
|
||||
use tauri::AppHandle;
|
||||
|
||||
@@ -29,6 +29,11 @@ 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> {
|
||||
|
||||
@@ -4,6 +4,7 @@ pub(crate) mod game;
|
||||
pub(crate) mod host;
|
||||
pub(crate) mod preferences;
|
||||
pub(crate) mod profiles;
|
||||
pub(crate) mod shacraft;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
@@ -2,6 +2,15 @@ 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(
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
//! ShaCraft sessions and verified account links are the launch identity source.
|
||||
use super::data_dir;
|
||||
use crate::{
|
||||
operations::{LauncherOperations, Operation},
|
||||
session, 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_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
|
||||
}
|
||||
|
||||
/// Always revalidate the server session and its aoc link. Legacy local settings
|
||||
/// and Microsoft tokens do not select the identity in the ShaCraft-only flow.
|
||||
pub(super) fn resolve_identity(
|
||||
directory: &Path,
|
||||
operation: &Operation,
|
||||
) -> Result<session::PlayerIdentity, String> {
|
||||
let _permit = operation.acquire("ShaCraft account operation")?;
|
||||
let name =
|
||||
shacraft_account::aeronautics_nickname(directory).map_err(|error| error.to_string())?;
|
||||
Ok(session::PlayerIdentity::Offline { name })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn missing_shacraft_session_cannot_fall_back_to_legacy_nickname() {
|
||||
let directory = std::env::temp_dir().join(format!(
|
||||
"shacraft-identity-test-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
crate::settings::save(&directory, crate::settings::LauncherSettings::default()).unwrap();
|
||||
assert!(resolve_identity(&directory, &Operation::default()).is_err());
|
||||
std::fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
}
|
||||
+26
-9
@@ -2,7 +2,11 @@ use crate::download::ProgressCallback;
|
||||
use crate::runtime::{self, RuntimeError};
|
||||
use reqwest::blocking::Client;
|
||||
use serde::Serialize;
|
||||
use std::{env, fmt, path::{Path, PathBuf}, process::Command};
|
||||
use std::{
|
||||
env, fmt,
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -21,9 +25,14 @@ pub enum EnsureJavaError {
|
||||
impl fmt::Display for EnsureJavaError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Provisioning(error) => write!(formatter, "Cannot install a Java runtime: {error}"),
|
||||
Self::Provisioning(error) => {
|
||||
write!(formatter, "Cannot install a Java runtime: {error}")
|
||||
}
|
||||
Self::ProvisionedButUnrecognised(path) => {
|
||||
write!(formatter, "Installed a Java runtime at {path:?}, but it did not report a usable version")
|
||||
write!(
|
||||
formatter,
|
||||
"Installed a Java runtime at {path:?}, but it did not report a usable version"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,21 +47,29 @@ pub fn detect() -> Option<JavaInstallation> {
|
||||
candidates().into_iter().find_map(check_candidate)
|
||||
}
|
||||
|
||||
/// Returns a Java runtime with at least `required_major`, preferring
|
||||
/// whatever the user already has installed. Only downloads and extracts a
|
||||
/// Returns a Java runtime with exactly `required_major`, preferring a matching
|
||||
/// installation already on the machine. Newer JVM majors are not assumed to
|
||||
/// be compatible with the selected NeoForge/modpack version. Only downloads and extracts a
|
||||
/// ShaCraft-managed Eclipse Temurin JRE under `runtime_root` (never touches
|
||||
/// the user's own Java) when nothing suitable is already on the machine.
|
||||
/// `on_progress` reports real download bytes when a JRE actually needs
|
||||
/// fetching; it fires once with `(1, 1)` when an existing Java is reused.
|
||||
pub fn ensure_java(client: &Client, runtime_root: &Path, required_major: u8, on_progress: &ProgressCallback) -> Result<JavaInstallation, EnsureJavaError> {
|
||||
pub fn ensure_java(
|
||||
client: &Client,
|
||||
runtime_root: &Path,
|
||||
required_major: u8,
|
||||
on_progress: &ProgressCallback,
|
||||
) -> Result<JavaInstallation, EnsureJavaError> {
|
||||
if let Some(installation) = detect() {
|
||||
if installation.major >= required_major {
|
||||
if installation.major == required_major {
|
||||
on_progress(1, 1);
|
||||
return Ok(installation);
|
||||
}
|
||||
}
|
||||
let executable = runtime::ensure_runtime(client, runtime_root, required_major, on_progress).map_err(EnsureJavaError::Provisioning)?;
|
||||
check_candidate(executable.clone()).ok_or(EnsureJavaError::ProvisionedButUnrecognised(executable))
|
||||
let executable = runtime::ensure_runtime(client, runtime_root, required_major, on_progress)
|
||||
.map_err(EnsureJavaError::Provisioning)?;
|
||||
check_candidate(executable.clone())
|
||||
.ok_or(EnsureJavaError::ProvisionedButUnrecognised(executable))
|
||||
}
|
||||
|
||||
fn candidates() -> Vec<PathBuf> {
|
||||
|
||||
+128
-15
@@ -8,7 +8,7 @@ use crate::mojang::{self, MergedVersion};
|
||||
use crate::session::PlayerIdentity;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
collections::{HashMap, HashSet},
|
||||
fmt, fs, io,
|
||||
path::{Path, PathBuf},
|
||||
process::{Child, Command, Stdio},
|
||||
@@ -57,17 +57,37 @@ fn classpath_separator() -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn unique_classpath_entries(mut entries: Vec<PathBuf>) -> Vec<PathBuf> {
|
||||
let mut seen = HashSet::new();
|
||||
entries.retain(|path| seen.insert(path.clone()));
|
||||
entries
|
||||
}
|
||||
|
||||
fn build_classpath(game_dir: &Path, merged: &MergedVersion, client_jar: &Path) -> String {
|
||||
let no_features = HashMap::new();
|
||||
let mut entries: Vec<PathBuf> = merged
|
||||
.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());
|
||||
entries.iter().map(|path| path.display().to_string()).collect::<Vec<_>>().join(classpath_separator())
|
||||
// NeoForge's inherited profile can repeat vanilla libraries verbatim.
|
||||
// Passing the same jar twice makes SecureJarHandler abort during startup
|
||||
// (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())
|
||||
}
|
||||
|
||||
/// A persistent-but-not-security-sensitive per-install identifier for the
|
||||
@@ -93,7 +113,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;
|
||||
@@ -103,7 +129,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)
|
||||
}
|
||||
|
||||
@@ -118,6 +147,36 @@ 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
|
||||
@@ -129,17 +188,26 @@ pub fn launch(request: &LaunchRequest) -> Result<Child, LaunchError> {
|
||||
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());
|
||||
vars.insert("version_name", request.merged.id.clone());
|
||||
// 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
|
||||
// token must identify the parent or both vanilla and patched Minecraft
|
||||
// modules are loaded and Java aborts with a ResolutionException.
|
||||
vars.insert("version_name", request.merged.client_jar_version_id.clone());
|
||||
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_access_token",
|
||||
request.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());
|
||||
@@ -152,13 +220,24 @@ 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));
|
||||
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 {
|
||||
@@ -183,12 +262,18 @@ mod tests {
|
||||
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);
|
||||
@@ -201,6 +286,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")]
|
||||
);
|
||||
}
|
||||
|
||||
#[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\"""#
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ mod remote;
|
||||
mod runtime;
|
||||
mod session;
|
||||
mod settings;
|
||||
mod shacraft_account;
|
||||
mod storage;
|
||||
mod trusted_http;
|
||||
|
||||
@@ -22,11 +23,18 @@ pub fn run() {
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
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_link_status,
|
||||
commands::account::start_microsoft_login,
|
||||
commands::account::get_account,
|
||||
commands::account::logout,
|
||||
|
||||
+223
-42
@@ -26,7 +26,8 @@ use std::{
|
||||
},
|
||||
};
|
||||
|
||||
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",
|
||||
@@ -47,6 +48,28 @@ 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,
|
||||
],
|
||||
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)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum MojangError {
|
||||
Network(reqwest::Error),
|
||||
@@ -55,6 +78,7 @@ pub enum MojangError {
|
||||
ChecksumMismatch(String),
|
||||
DisallowedHost(String),
|
||||
MissingField(String),
|
||||
ConflictingLibrary(String),
|
||||
Download(DownloadError),
|
||||
Io(io::Error),
|
||||
}
|
||||
@@ -66,8 +90,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}"),
|
||||
}
|
||||
@@ -106,7 +136,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)
|
||||
}
|
||||
|
||||
@@ -141,7 +174,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)]
|
||||
@@ -227,7 +263,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
|
||||
}
|
||||
|
||||
@@ -246,7 +286,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;
|
||||
@@ -261,7 +303,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;
|
||||
}
|
||||
@@ -271,7 +316,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 {
|
||||
@@ -289,11 +337,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()));
|
||||
}
|
||||
@@ -344,8 +399,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()
|
||||
@@ -397,34 +458,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(),
|
||||
@@ -433,7 +529,7 @@ pub fn ensure_libraries(client: &Client, game_dir: &Path, libraries: &[Library],
|
||||
checksum: Checksum::Sha1(artifact.sha1.clone()),
|
||||
});
|
||||
}
|
||||
download_many(client, tasks, on_progress)?;
|
||||
download_many(client, tasks, on_progress, is_allowed_library_host)?;
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
@@ -448,20 +544,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
|
||||
@@ -469,14 +584,17 @@ 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()),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
download_many(client, tasks, on_progress)
|
||||
download_many(client, tasks, on_progress, is_allowed_host)
|
||||
}
|
||||
|
||||
struct DownloadTask {
|
||||
@@ -496,7 +614,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);
|
||||
@@ -512,7 +637,12 @@ fn download_with_retries(client: &Client, task: &DownloadTask) -> Result<u64, Do
|
||||
/// Downloads `tasks` using a small worker pool, calling `on_progress` with
|
||||
/// cumulative (downloaded, total) bytes as each file completes. Stops
|
||||
/// spawning new work once the first error is seen and returns it.
|
||||
fn download_many(client: &Client, tasks: Vec<DownloadTask>, on_progress: &ProgressCallback) -> Result<(), MojangError> {
|
||||
fn download_many(
|
||||
client: &Client,
|
||||
tasks: Vec<DownloadTask>,
|
||||
on_progress: &ProgressCallback,
|
||||
is_allowed: fn(&str) -> bool,
|
||||
) -> Result<(), MojangError> {
|
||||
let total: u64 = tasks.iter().map(|task| task.size).sum();
|
||||
if total == 0 {
|
||||
return Ok(());
|
||||
@@ -531,12 +661,16 @@ fn download_many(client: &Client, tasks: Vec<DownloadTask>, on_progress: &Progre
|
||||
if first_error.lock().unwrap().is_some() {
|
||||
break;
|
||||
}
|
||||
let Some(task) = queue.lock().unwrap().pop() else { break };
|
||||
if !is_allowed_host(&task.url) {
|
||||
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));
|
||||
@@ -562,7 +696,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,
|
||||
}
|
||||
}
|
||||
@@ -580,7 +717,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()));
|
||||
}
|
||||
@@ -589,7 +730,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()));
|
||||
@@ -610,7 +755,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]
|
||||
@@ -640,17 +788,39 @@ 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_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"));
|
||||
}
|
||||
|
||||
/// Live smoke test against the real Mojang CDN: manifest -> version JSON
|
||||
@@ -666,7 +836,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();
|
||||
@@ -678,11 +849,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();
|
||||
@@ -692,7 +872,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
@@ -33,7 +33,7 @@ use std::{
|
||||
/// access. Replace this before shipping login — see the module doc above.
|
||||
const MSA_CLIENT_ID: &str = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
fn client_id_is_configured() -> bool {
|
||||
pub fn is_configured() -> bool {
|
||||
MSA_CLIENT_ID != "00000000-0000-0000-0000-000000000000"
|
||||
}
|
||||
|
||||
@@ -41,15 +41,21 @@ 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))
|
||||
crate::trusted_http::client(
|
||||
&[
|
||||
"login.microsoftonline.com",
|
||||
"user.auth.xboxlive.com",
|
||||
"xsts.auth.xboxlive.com",
|
||||
"api.minecraftservices.com",
|
||||
],
|
||||
Duration::from_secs(30),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -113,12 +119,15 @@ struct DeviceCodeResponse {
|
||||
}
|
||||
|
||||
pub fn start_device_code(client: &Client) -> Result<DeviceCodeStart, MsaError> {
|
||||
if !client_id_is_configured() {
|
||||
if !is_configured() {
|
||||
return Err(MsaError::NotConfigured);
|
||||
}
|
||||
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() {
|
||||
@@ -151,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);
|
||||
|
||||
@@ -174,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() {
|
||||
@@ -188,13 +206,20 @@ 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> {
|
||||
if !client_id_is_configured() {
|
||||
pub fn refresh_microsoft_tokens(
|
||||
client: &Client,
|
||||
refresh_token: &str,
|
||||
) -> Result<MicrosoftTokens, MsaError> {
|
||||
if !is_configured() {
|
||||
return Err(MsaError::NotConfigured);
|
||||
}
|
||||
let response = client
|
||||
@@ -212,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,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -281,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",
|
||||
@@ -291,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
|
||||
@@ -319,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))
|
||||
}
|
||||
|
||||
@@ -335,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()));
|
||||
}
|
||||
@@ -354,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)
|
||||
@@ -383,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)
|
||||
}
|
||||
@@ -408,8 +486,15 @@ 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);
|
||||
crate::storage::write_atomic(&target, &contents)
|
||||
@@ -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,18 +536,26 @@ 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();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_to_run_with_placeholder_client_id() {
|
||||
assert!(!client_id_is_configured());
|
||||
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
|
||||
|
||||
+233
-40
@@ -40,7 +40,7 @@ use std::{
|
||||
thread,
|
||||
};
|
||||
|
||||
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
|
||||
@@ -56,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}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,7 +100,7 @@ impl From<io::Error> for NeoForgeError {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_allowed_host(url: &str) -> bool {
|
||||
pub(crate) fn is_allowed_host(url: &str) -> bool {
|
||||
crate::trusted_http::allows(url, &[NEOFORGE_HOST])
|
||||
}
|
||||
|
||||
@@ -102,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));
|
||||
}
|
||||
@@ -142,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.
|
||||
@@ -164,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);
|
||||
@@ -176,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
|
||||
@@ -222,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');
|
||||
@@ -246,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))
|
||||
}
|
||||
@@ -261,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 {
|
||||
@@ -299,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();
|
||||
@@ -315,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);
|
||||
@@ -329,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)]);
|
||||
}
|
||||
@@ -353,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();
|
||||
@@ -363,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.
|
||||
@@ -372,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();
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use std::sync::{
|
||||
pub(crate) struct LauncherOperations {
|
||||
pub installation: Operation,
|
||||
pub account: Operation,
|
||||
pub shacraft_account: Operation,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
|
||||
@@ -254,6 +254,27 @@ mod tests {
|
||||
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()
|
||||
));
|
||||
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() {
|
||||
|
||||
+100
-14
@@ -1,20 +1,32 @@
|
||||
use crate::manifest::{self, Manifest};
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use ed25519_dalek::{Signature, VerifyingKey};
|
||||
use reqwest::header::ACCEPT_ENCODING;
|
||||
use reqwest::{blocking::Client, redirect::Policy};
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
fmt,
|
||||
io::{self, Read},
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
const AERONAUTICS_MANIFEST: &str =
|
||||
"https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest";
|
||||
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";
|
||||
// Allow the base64 envelope around a payload of up to 2 MiB.
|
||||
const MAX_ENVELOPE_BYTES: usize = 3 * 1024 * 1024;
|
||||
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")]
|
||||
@@ -62,21 +74,13 @@ pub fn fetch_manifest(profile_id: &str) -> Result<Manifest, RemoteError> {
|
||||
_ => 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 > MAX_ENVELOPE_BYTES as u64)
|
||||
{
|
||||
return Err(RemoteError::TooLarge);
|
||||
}
|
||||
let source = read_envelope(response)?;
|
||||
let source = fetch_manifest_bytes(&client, url)?;
|
||||
let public_key_bytes = STANDARD
|
||||
.decode(MANIFEST_PUBLIC_KEY)
|
||||
.expect("embedded public key must be valid");
|
||||
@@ -89,6 +93,55 @@ pub fn fetch_manifest(profile_id: &str) -> Result<Manifest, RemoteError> {
|
||||
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,
|
||||
_ => return Err(RemoteError::UnknownProfile),
|
||||
};
|
||||
let client = Client::builder()
|
||||
.https_only(true)
|
||||
.timeout(Duration::from_secs(10))
|
||||
.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()));
|
||||
}
|
||||
response
|
||||
.json::<ServerStatus>()
|
||||
.map_err(RemoteError::Network)
|
||||
}
|
||||
|
||||
fn read_envelope(source: impl Read) -> Result<Vec<u8>, RemoteError> {
|
||||
let mut bytes = Vec::new();
|
||||
source
|
||||
@@ -213,4 +266,37 @@ mod tests {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
//! 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, 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,
|
||||
NoLinkedNickname,
|
||||
}
|
||||
|
||||
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 истекла — войдите снова"),
|
||||
Self::NoLinkedNickname => {
|
||||
formatter.write_str("Сначала привяжите игровой ник к серверу Aeronautics")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
pub fn aeronautics_nickname(data_dir: &Path) -> Result<String, AccountError> {
|
||||
get_account(data_dir)?
|
||||
.links
|
||||
.into_iter()
|
||||
.find(|link| link.server_id == "aoc")
|
||||
.map(|link| link.mc_username)
|
||||
.ok_or(AccountError::NoLinkedNickname)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{load_session, save_session, session_path};
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,7 @@ impl AtomicFile {
|
||||
pub fn commit(mut self) -> io::Result<()> {
|
||||
self.writer().sync_all()?;
|
||||
drop(self.file.take());
|
||||
fs::rename(&self.temporary, &self.target)?;
|
||||
replace_file(&self.temporary, &self.target)?;
|
||||
self.committed = true;
|
||||
Ok(())
|
||||
}
|
||||
@@ -89,6 +89,43 @@ pub(crate) fn write_atomic(target: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
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::*;
|
||||
@@ -132,4 +169,22 @@ mod tests {
|
||||
);
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ShaCraft Launcher",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"identifier": "ru.shacraft.launcher",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -28,6 +28,13 @@
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all"
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+30
-27
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Library } from './components/Library'
|
||||
import { LoginModal } from './components/LoginModal'
|
||||
import { RecoveryCodesModal } from './components/RecoveryCodesModal'
|
||||
import { PlayDock } from './components/PlayDock'
|
||||
import { ServerStage } from './components/ServerStage'
|
||||
import { SettingsDrawer } from './components/SettingsDrawer'
|
||||
@@ -8,68 +8,71 @@ import { Titlebar } from './components/Titlebar'
|
||||
import { servers } from './data/servers'
|
||||
import { useAccount } from './hooks/useAccount'
|
||||
import { useLauncher } from './hooks/useLauncher'
|
||||
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 preferences = useSettings()
|
||||
const session = useAccount()
|
||||
const launcher = useLauncher()
|
||||
const serverStatus = useServerStatus(selected.profileId)
|
||||
const closeSettings = useCallback(() => setSettingsOpen(false), [])
|
||||
const desktop = isNative()
|
||||
const profile = selected.profileId ? launcher.profiles[selected.profileId] : undefined
|
||||
const profile = launcher.profiles[selected.profileId]
|
||||
const ready = profile?.inspection?.upToDate === true
|
||||
const operation = launcher.game.operation
|
||||
const busy = operation.phase !== 'idle'
|
||||
const microsoft = preferences.settings.accountMode === 'microsoft'
|
||||
const needsLogin = microsoft && session.account === null
|
||||
const accountLoading = microsoft && session.account === undefined
|
||||
const checking = desktop && (!profile || profile.status === 'checking') && !selected.disabled
|
||||
const settingsBlocked = !preferences.loaded || preferences.saving || !!preferences.error || (!microsoft && !!preferences.nicknameError)
|
||||
const disabled = !desktop || !!selected.disabled || !selected.profileId || busy || checking || settingsBlocked || accountLoading || session.busy || !launcher.eventsReady || (needsLogin && !session.eventsReady)
|
||||
const repairDisabled = !desktop || !!selected.disabled || !selected.profileId || busy || checking
|
||||
const error = launcher.game.error ?? preferences.error ?? (!microsoft ? preferences.nicknameError : null) ?? launcher.environmentError ?? (microsoft ? session.error : null) ?? profile?.error ?? null
|
||||
const access = launchAccess(session.account)
|
||||
const checking = desktop && (!profile || profile.status === 'checking')
|
||||
const settingsBlocked = !preferences.loaded || preferences.saving || !!preferences.error
|
||||
const disabled = !desktop || busy || session.busy || access === 'loading' ||
|
||||
(access === 'ready' && (checking || settingsBlocked || !launcher.eventsReady))
|
||||
const repairDisabled = !desktop || busy || checking
|
||||
const error = launcher.game.error ?? preferences.error ?? windowError ?? launcher.environmentError ?? session.error ?? profile?.error ?? null
|
||||
|
||||
let label = 'Играть'
|
||||
if (selected.disabled) label = 'Недоступно'
|
||||
else if (!desktop) label = 'В приложении'
|
||||
if (!desktop) label = 'В приложении'
|
||||
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 (accountLoading || !preferences.loaded) label = 'Загрузка…'
|
||||
else if (needsLogin) label = session.busy ? 'Ждём вход…' : 'Войти через Microsoft'
|
||||
else if (!preferences.loaded) label = 'Загрузка…'
|
||||
else if (checking) label = 'Проверяем…'
|
||||
else if (!ready) label = 'Проверить'
|
||||
|
||||
const primary = () => {
|
||||
if (disabled || !selected.profileId) return
|
||||
if (needsLogin) void session.login()
|
||||
else if (!ready) void launcher.repair(selected.profileId)
|
||||
if (disabled) return
|
||||
if (access !== 'ready') setSettingsOpen(true)
|
||||
else void launcher.launch(selected.profileId)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<Titlebar host={launcher.host} />
|
||||
<div className="workspace">
|
||||
<Library selected={selected} profiles={launcher.profiles} settings={preferences.settings}
|
||||
account={session.account} native={desktop} locked={busy || session.busy}
|
||||
onSelect={setSelected} onSettings={() => setSettingsOpen(true)} onLogout={session.logout} />
|
||||
<ServerStage server={selected}>
|
||||
<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 || 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={needsLogin}
|
||||
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 && selected.profileId) void launcher.repair(selected.profileId) }} />
|
||||
onPrimary={primary} onRepair={() => { if (!repairDisabled) void launcher.repair(selected.profileId) }} />
|
||||
</ServerStage>
|
||||
</div>
|
||||
<SettingsDrawer open={settingsOpen && !session.code} locked={busy || session.busy} preferences={preferences}
|
||||
<SettingsDrawer open={settingsOpen && !session.recoveryCodes.length} locked={busy} preferences={preferences}
|
||||
session={session} host={launcher.host} java={launcher.java} onClose={closeSettings} />
|
||||
<LoginModal code={session.code} />
|
||||
<RecoveryCodesModal codes={session.recoveryCodes} onAcknowledge={session.acknowledgeRecoveryCodes} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useState } from 'react'
|
||||
import { LogOut, Users } from 'lucide-react'
|
||||
import type { useAccount } from '../hooks/useAccount'
|
||||
import { isNative } from '../services/native'
|
||||
|
||||
export function AccountSettings({ session, locked }: { session: ReturnType<typeof useAccount>; locked: boolean }) {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [registering, setRegistering] = useState(false)
|
||||
const [nickname, setNickname] = useState('')
|
||||
const { account, linkedNickname, busy } = session
|
||||
const disabled = locked || busy || account === undefined
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="setting-row static"><span><Users />Аккаунт</span><small>{account === undefined ? 'Проверяем…' : account?.username ?? 'Не авторизован'}</small></div>
|
||||
{!account && (
|
||||
<form onSubmit={async (event) => {
|
||||
event.preventDefault()
|
||||
if (disabled) return
|
||||
if (await session.authenticate(username, password, registering)) setPassword('')
|
||||
}}>
|
||||
<label className="text-setting">
|
||||
<span><strong>Логин ShaCraft</strong><small>3–32 символа</small></span>
|
||||
<input value={username} minLength={3} maxLength={32} pattern="[A-Za-z0-9_]{3,32}" required autoComplete="username"
|
||||
disabled={disabled} onChange={(event) => setUsername(event.target.value)} placeholder="Логин" />
|
||||
</label>
|
||||
<label className="text-setting">
|
||||
<span><strong>Пароль</strong><small>Минимум 3 символа</small></span>
|
||||
<input type="password" value={password} minLength={3} maxLength={128} required
|
||||
autoComplete={registering ? 'new-password' : 'current-password'} disabled={disabled}
|
||||
onChange={(event) => setPassword(event.target.value)} placeholder="Пароль" />
|
||||
</label>
|
||||
<button className="setting-row" type="submit" disabled={disabled || !isNative()}>
|
||||
<span>{busy ? 'Подождите…' : registering ? 'Создать аккаунт' : 'Войти'}</span>
|
||||
</button>
|
||||
<button className="setting-row" type="button" disabled={disabled} onClick={() => { setRegistering(!registering); session.clearError() }}>
|
||||
<span>{registering ? 'Уже есть аккаунт' : 'Нет аккаунта — регистрация'}</span>
|
||||
</button>
|
||||
{!isNative() && <p className="account-hint">Вход и регистрация доступны в приложении лаунчера.</p>}
|
||||
</form>
|
||||
)}
|
||||
{account && !linkedNickname && (
|
||||
<form onSubmit={(event) => { event.preventDefault(); if (!disabled) void session.startLink(nickname) }}>
|
||||
<label className="text-setting">
|
||||
<span><strong>Игровой ник</strong><small>Aeronautics</small></span>
|
||||
<input value={nickname} minLength={3} maxLength={16} pattern="[A-Za-z0-9_]{3,16}" required
|
||||
disabled={disabled || session.linking} onChange={(event) => setNickname(event.target.value)} placeholder="Player" />
|
||||
<small>Подтвердите владение ником на сервере Aeronautics.</small>
|
||||
</label>
|
||||
<button className="setting-row" type="submit" disabled={disabled || session.linking}>
|
||||
<span>{session.linking ? 'Ожидаем подтверждения…' : 'Привязать ник'}</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>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
+19
-29
@@ -1,60 +1,50 @@
|
||||
import { ChevronRight, Library as LibraryIcon, LogOut, MessageCircle, Newspaper, Settings } from 'lucide-react'
|
||||
import { ChevronRight, Settings } from 'lucide-react'
|
||||
import { servers } from '../data/servers'
|
||||
import { linkedNickname } from '../state/account'
|
||||
import type { ProfileState } from '../state/profiles'
|
||||
import type { LauncherSettings, MinecraftProfile, Server } from '../types/launcher'
|
||||
import type { ShaCraftAccount, Server } from '../types/launcher'
|
||||
|
||||
interface LibraryProps {
|
||||
selected: Server
|
||||
profiles: Record<string, ProfileState>
|
||||
settings: LauncherSettings
|
||||
account: MinecraftProfile | null | undefined
|
||||
account: ShaCraftAccount | null | undefined
|
||||
locked: boolean
|
||||
native: boolean
|
||||
onSelect: (server: Server) => void
|
||||
onSettings: () => void
|
||||
onLogout: () => void
|
||||
}
|
||||
|
||||
export function Library(props: LibraryProps) {
|
||||
const { selected, profiles, settings, account, locked, onSelect, onSettings, onLogout } = props
|
||||
const name = settings.accountMode === 'offline' ? settings.nickname
|
||||
: account === undefined ? 'Проверяем…' : account?.name ?? 'Не авторизован'
|
||||
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="Основное меню">
|
||||
<div className="rail-main">
|
||||
<button className="rail-button active" aria-label="Сборки" aria-current="page"><LibraryIcon /></button>
|
||||
<button className="rail-button" aria-label="Новости" disabled title="Скоро"><Newspaper /></button>
|
||||
<button className="rail-button" aria-label="Сообщество" disabled title="Скоро"><MessageCircle /></button>
|
||||
</div>
|
||||
<button className="rail-button" aria-label="Настройки" onClick={onSettings}><Settings /></button>
|
||||
<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>1 доступна</span></div>
|
||||
<div className="library-heading"><p>Сборки</p><span>{servers.length} доступна</span></div>
|
||||
<div className="server-list">
|
||||
{servers.map((server) => {
|
||||
const profile = server.profileId ? profiles[server.profileId] : undefined
|
||||
const status = server.disabled ? 'На паузе' : !props.native ? 'Доступна'
|
||||
: !profile || profile.status === 'checking' ? 'Проверяем…'
|
||||
: profile.inspection?.upToDate ? 'Установлена' : 'Требуется проверка'
|
||||
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.id === 'aoc' ? 'A' : 'C'}</span>
|
||||
<span className={`server-glyph ${server.id}`} aria-hidden="true">A</span>
|
||||
<span className="server-copy"><strong>{server.name}</strong><small>{status}</small></span>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="account-chip">
|
||||
<span className="avatar">{settings.accountMode === 'offline' || account ? name.slice(0, 2).toUpperCase() : '?'}</span>
|
||||
<span><strong>{name}</strong><small>{settings.accountMode === 'offline' ? 'Offline-аккаунт' : account ? 'Microsoft-аккаунт' : 'Войдите, чтобы играть'}</small></span>
|
||||
{settings.accountMode === 'microsoft' && account ? (
|
||||
<button className="account-logout" aria-label="Выйти из аккаунта" disabled={locked} onClick={onLogout}><LogOut size={16} /></button>
|
||||
) : <ChevronRight size={16} />}
|
||||
</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>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { DeviceCodePayload } from '../types/launcher'
|
||||
|
||||
export function LoginModal({ code }: { code: DeviceCodePayload | null }) {
|
||||
if (!code) return null
|
||||
return (
|
||||
<>
|
||||
<div className="drawer-backdrop visible" />
|
||||
<div className="login-modal" role="dialog" aria-modal="true" aria-labelledby="login-title">
|
||||
<h2 id="login-title">Вход через Microsoft</h2>
|
||||
<p>Откройте страницу и введите код, чтобы подтвердить вход в аккаунт с лицензией Minecraft.</p>
|
||||
<div className="login-code">{code.userCode}</div>
|
||||
<p className="login-url">{code.verificationUri}</p>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Download, Gauge, Globe2, Play, RotateCcw, ShieldCheck, Wrench } from 'lucide-react'
|
||||
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'
|
||||
@@ -11,6 +11,7 @@ interface PlayDockProps {
|
||||
memoryGb: number
|
||||
native: boolean
|
||||
needsLogin: boolean
|
||||
needsLink: boolean
|
||||
error: string | null
|
||||
label: string
|
||||
primaryDisabled: boolean
|
||||
@@ -35,12 +36,10 @@ export function PlayDock(props: PlayDockProps) {
|
||||
title = 'Игра запущена'; detail = 'Вернитесь после завершения игры'
|
||||
} else if (operation.phase === 'syncing') {
|
||||
title = 'Синхронизируем сборку'; detail = 'Скачиваем и проверяем файлы'
|
||||
} else if (server.disabled) {
|
||||
title = 'Техобслуживание'; detail = 'Сообщим, когда сервер вернётся'
|
||||
} else if (!props.native) {
|
||||
title = 'Предпросмотр интерфейса'; detail = 'Установка и запуск доступны в приложении'
|
||||
} else {
|
||||
title = needsLogin ? 'Нужен вход' : profile?.status === 'checking' ? 'Проверяем сборку'
|
||||
title = needsLogin ? 'Нужен вход ShaCraft' : props.needsLink ? 'Нужно привязать ник' : profile?.status === 'checking' ? 'Проверяем сборку'
|
||||
: profile?.inspection?.upToDate ? 'Сборка готова' : 'Требуется проверка'
|
||||
detail = profile?.inspection ? `${profile.inspection.managedFiles} файлов под контролем` : 'Проверяем локальные файлы'
|
||||
}
|
||||
@@ -48,8 +47,8 @@ export function PlayDock(props: PlayDockProps) {
|
||||
return (
|
||||
<section className="play-dock">
|
||||
<div className="build-state" aria-live="polite">
|
||||
<span className={`state-icon ${working ? 'downloading' : server.disabled ? 'muted' : ''}`}>
|
||||
{working ? <Download size={19} /> : server.disabled ? <Wrench size={19} /> : <ShieldCheck size={19} />}
|
||||
<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}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,21 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Users } from 'lucide-react'
|
||||
import type { Server } from '../types/launcher'
|
||||
import { isNative } from '../services/native'
|
||||
import type { Server, ServerStatus } from '../types/launcher'
|
||||
|
||||
export function ServerStage({ server, children }: { server: Server; children: ReactNode }) {
|
||||
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 offline"><span /> {server.disabled ? 'Не в сети' : 'Статус не проверен'}</div>
|
||||
<div className="players"><Users size={16} /> {server.disabled ? 'Сервер остановлен' : 'Онлайн неизвестен'}</div>
|
||||
<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.composition}</dd></div>
|
||||
<div><dt>Загрузчик</dt><dd>{server.loader}</dd></div>
|
||||
<div><dt>Java</dt><dd>Версия 21</dd></div>
|
||||
</dl>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { ChevronRight, FolderOpen, LogOut, Users, Wrench, X } from 'lucide-react'
|
||||
import { FolderOpen, Wrench, X } from 'lucide-react'
|
||||
import { AccountSettings } from './AccountSettings'
|
||||
import type { useAccount } from '../hooks/useAccount'
|
||||
import type { useSettings } from '../hooks/useSettings'
|
||||
import type { JavaInstallation, NativeHost } from '../types/launcher'
|
||||
@@ -15,7 +16,7 @@ interface SettingsDrawerProps {
|
||||
}
|
||||
|
||||
export function SettingsDrawer({ open, locked, host, java, preferences, session, onClose }: SettingsDrawerProps) {
|
||||
const { settings, nickname, loaded, saving, error, nicknameError } = preferences
|
||||
const { settings, loaded, saving, error } = preferences
|
||||
const closeButton = useRef<HTMLButtonElement>(null)
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
@@ -52,29 +53,7 @@ export function SettingsDrawer({ open, locked, host, java, preferences, session,
|
||||
onChange={(event) => preferences.updateRam(Number(event.target.value))} />
|
||||
<small>Для Aeronautics рекомендуется 6 ГБ</small>
|
||||
</label>
|
||||
<div className="setting-row static"><span><Users />Аккаунт</span><small>{settings.accountMode === 'offline' ? 'Offline' : session.account?.name ?? 'Не авторизован'}</small></div>
|
||||
{settings.accountMode === 'offline' && (
|
||||
<label className="text-setting">
|
||||
<span><strong>Игровой ник</strong><small>Offline-профиль</small></span>
|
||||
<input value={nickname} maxLength={16} disabled={!loaded || locked} aria-invalid={!!nicknameError}
|
||||
aria-describedby="nickname-hint" onChange={(event) => preferences.updateNickname(event.target.value)}
|
||||
onBlur={preferences.saveNickname} placeholder="Player" />
|
||||
<small id="nickname-hint" className={nicknameError ? 'status-error' : undefined}>{nicknameError ?? 'Латинские буквы, цифры и _ · от 3 до 16 символов'}</small>
|
||||
</label>
|
||||
)}
|
||||
<label className="setting-row">
|
||||
<span>Тип аккаунта</span>
|
||||
<select value={settings.accountMode} disabled={!loaded || locked || saving || session.busy}
|
||||
onChange={(event) => preferences.updateMode(event.target.value === 'microsoft' ? 'microsoft' : 'offline')}>
|
||||
<option value="offline">Offline</option><option value="microsoft">Microsoft</option>
|
||||
</select>
|
||||
</label>
|
||||
{settings.accountMode === 'microsoft' && (
|
||||
<button className="setting-row" disabled={locked || saving || session.busy || session.account === undefined || !session.eventsReady}
|
||||
onClick={session.account ? session.logout : session.login}>
|
||||
<span><LogOut />{session.busy ? 'Ждём вход…' : session.account ? 'Выйти из Microsoft' : 'Войти через Microsoft'}</span>
|
||||
</button>
|
||||
)}
|
||||
<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>
|
||||
@@ -82,10 +61,8 @@ export function SettingsDrawer({ open, locked, host, java, preferences, session,
|
||||
: java?.major === 21 ? 'Java 21 найдена' : java ? `Нужна Java 21 · найдена ${java.major}`
|
||||
: 'Лаунчер установит Java 21 автоматически'}</small>
|
||||
</div>
|
||||
<button className="setting-row" disabled title="Скоро"><span><Wrench />Дополнительные параметры</span><ChevronRight /></button>
|
||||
<div className="settings-feedback" aria-live="polite">
|
||||
{error && <><p className="status-error">{error}</p><button onClick={preferences.retry} disabled={locked || saving}>Повторить</button></>}
|
||||
{session.error && settings.accountMode === 'microsoft' && <p className="status-error">{session.error}</p>}
|
||||
{saving && <p>Сохраняем настройки…</p>}
|
||||
</div>
|
||||
<div className="drawer-note">{host ? `Данные лаунчера: ${host.dataDir}` : 'Java 21 будет управляться лаунчером автоматически.'}</div>
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
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 }: { host: NativeHost | null }) {
|
||||
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">
|
||||
<div className="brand"><img src={logo} alt="" /><span>ShaCraft</span></div>
|
||||
<div className="titlebar-drag">{host ? `Лаунчер · ${host.platform}` : 'Лаунчер'}</div>
|
||||
<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 title="Управление окном пока недоступно"><Minus size={15} /></button>
|
||||
<button aria-label="Развернуть" disabled title="Управление окном пока недоступно"><Square size={12} /></button>
|
||||
<button className="close" aria-label="Закрыть" disabled title="Управление окном пока недоступно"><X size={15} /></button>
|
||||
<button aria-label="Свернуть" disabled={!isNative()} onClick={() => control(windowControls.minimize)}><Minus size={15} /></button>
|
||||
<button aria-label="Развернуть" disabled={!isNative()} onClick={() => control(windowControls.toggleMaximize)}><Square size={12} /></button>
|
||||
<button className="close" aria-label="Закрыть" disabled={!isNative()} onClick={() => control(windowControls.close)}><X size={15} /></button>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
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 type { useAccount } from '../hooks/useAccount'
|
||||
|
||||
function session(): ReturnType<typeof useAccount> {
|
||||
return {
|
||||
account: null, error: null, busy: false, recoveryCodes: [], linkMessage: null,
|
||||
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, /Aeronautics/)
|
||||
})
|
||||
|
||||
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={() => {}} />), '')
|
||||
})
|
||||
+2
-13
@@ -5,22 +5,11 @@ import type { Server } from '../types/launcher'
|
||||
export const servers: readonly [Server, ...Server[]] = [
|
||||
{
|
||||
id: 'aoc',
|
||||
kicker: 'All of Create / сборка 2.5',
|
||||
kicker: 'Основная сборка',
|
||||
name: 'Aeronautics',
|
||||
subtitle: 'Строй корабли. Поднимай города в небо.',
|
||||
version: '1.21.1 · NeoForge',
|
||||
composition: '250 модов',
|
||||
version: '1.21.1 · NeoForge 21.1.248',
|
||||
loader: 'NeoForge 21.1.248',
|
||||
profileId: 'aeronautics',
|
||||
},
|
||||
{
|
||||
id: 'create',
|
||||
kicker: 'На техобслуживании',
|
||||
name: 'Create',
|
||||
subtitle: 'Механизмы, фабрики и большие идеи.',
|
||||
version: '1.21.1 · NeoForge',
|
||||
composition: '41 мод',
|
||||
loader: 'NeoForge 21.1.249',
|
||||
disabled: true,
|
||||
},
|
||||
]
|
||||
|
||||
+131
-50
@@ -1,80 +1,161 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { errorMessage } from '../services/async'
|
||||
import { isNative, native, watchAccount } from '../services/native'
|
||||
import type { DeviceCodePayload, MinecraftProfile } from '../types/launcher'
|
||||
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'
|
||||
|
||||
interface PendingLink {
|
||||
challengeId: number
|
||||
expiresAt: number
|
||||
isCurrent: () => boolean
|
||||
}
|
||||
|
||||
export function useAccount() {
|
||||
// undefined = restoring saved account; null = signed out.
|
||||
const [account, setAccount] = useState<MinecraftProfile | null | undefined>(isNative() ? undefined : null)
|
||||
const [code, setCode] = useState<DeviceCodePayload | null>(null)
|
||||
const [account, setAccount] = useState<ShaCraftAccount | null | undefined>(isNative() ? undefined : null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [eventsReady, setEventsReady] = useState(false)
|
||||
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([])
|
||||
const [challenge, setChallenge] = useState<PendingLink | null>(null)
|
||||
const [linkMessage, setLinkMessage] = useState<string | null>(null)
|
||||
const pending = useRef(false)
|
||||
const requests = useRef(createRequestScope())
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNative()) return
|
||||
let active = true
|
||||
let changed = false
|
||||
const subscription = watchAccount({
|
||||
code: (payload) => { if (active) setCode(payload) },
|
||||
result: (payload) => {
|
||||
if (!active) return
|
||||
changed = true
|
||||
pending.current = false
|
||||
setBusy(false)
|
||||
setCode(null)
|
||||
if (payload.ok && payload.profile) {
|
||||
setAccount(payload.profile)
|
||||
setError(null)
|
||||
} else setError(payload.error || 'Не удалось войти через Microsoft')
|
||||
},
|
||||
})
|
||||
void subscription.ready.then(() => {
|
||||
if (active) setEventsReady(true)
|
||||
void native.getAccount().then((value) => {
|
||||
if (active) setAccount(value)
|
||||
}).catch((reason: unknown) => {
|
||||
if (active) setError(errorMessage(reason, 'Не удалось подготовить вход через Microsoft'))
|
||||
})
|
||||
void native.getAccount().then((profile) => {
|
||||
if (active && !changed) setAccount(profile)
|
||||
}).catch((reason: unknown) => {
|
||||
if (!active || changed) return
|
||||
if (!active) return
|
||||
setAccount(null)
|
||||
setError(errorMessage(reason, 'Не удалось восстановить аккаунт'))
|
||||
setError(errorMessage(reason, 'Не удалось восстановить аккаунт ShaCraft'))
|
||||
})
|
||||
return () => { active = false; subscription.dispose() }
|
||||
return () => { active = false; requests.current.invalidate() }
|
||||
}, [])
|
||||
|
||||
const login = async () => {
|
||||
if (!isNative() || !eventsReady || pending.current || account === undefined) return
|
||||
pending.current = true
|
||||
setBusy(true)
|
||||
setCode(null)
|
||||
setError(null)
|
||||
try {
|
||||
await native.startLogin()
|
||||
} catch (reason) {
|
||||
pending.current = false
|
||||
setBusy(false)
|
||||
setError(errorMessage(reason, 'Не удалось начать вход через Microsoft'))
|
||||
useEffect(() => {
|
||||
if (!challenge) return
|
||||
let active = true
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const current = () => active && challenge.isCurrent()
|
||||
const poll = async () => {
|
||||
if (!current()) return
|
||||
if (Date.now() >= challenge.expiresAt) {
|
||||
setChallenge(null)
|
||||
setLinkMessage('Срок проверки истёк. Начните привязку ещё раз.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await native.linkStatus(challenge.challengeId)
|
||||
if (!current()) return
|
||||
if (result.status === 'verified') {
|
||||
const refreshed = await native.getAccount()
|
||||
if (!current()) return
|
||||
setAccount(refreshed)
|
||||
setChallenge(null)
|
||||
setLinkMessage(linkedNickname(refreshed) ? 'Ник подтверждён.' : 'Не удалось подтвердить привязку. Войдите снова.')
|
||||
} else if (result.status === 'expired' || result.status === 'conflict') {
|
||||
setChallenge(null)
|
||||
setLinkMessage(result.detail || 'Проверка завершилась. Попробуйте ещё раз.')
|
||||
} else {
|
||||
// One request at a time; dispose and logout cancel future polling.
|
||||
timer = setTimeout(() => { void poll() }, 3_000)
|
||||
}
|
||||
} catch (reason) {
|
||||
if (!current()) return
|
||||
setChallenge(null)
|
||||
setLinkMessage(errorMessage(reason, 'Не удалось проверить ник'))
|
||||
}
|
||||
}
|
||||
}
|
||||
timer = setTimeout(() => { void poll() }, 3_000)
|
||||
return () => { active = false; clearTimeout(timer) }
|
||||
}, [challenge])
|
||||
|
||||
const logout = async () => {
|
||||
if (!isNative() || pending.current) return
|
||||
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 {
|
||||
await native.logout()
|
||||
setAccount(null)
|
||||
const result = await native.authenticate(username, password, register)
|
||||
if (!currentRequest()) return false
|
||||
setAccount(result.account)
|
||||
setChallenge(null)
|
||||
setLinkMessage(null)
|
||||
setRecoveryCodes(result.recoveryCodes)
|
||||
return true
|
||||
} catch (reason) {
|
||||
setError(errorMessage(reason, 'Не удалось выйти из аккаунта'))
|
||||
setError(errorMessage(reason, register ? 'Не удалось зарегистрироваться' : 'Не удалось войти'))
|
||||
return false
|
||||
} finally {
|
||||
pending.current = false
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return { account, code, error, busy, eventsReady, login, logout }
|
||||
const logout = async () => {
|
||||
if (!isNative() || pending.current) return
|
||||
pending.current = true
|
||||
requests.current.invalidate()
|
||||
setChallenge(null)
|
||||
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 (!isNative() || pending.current || challenge || !account) return
|
||||
if (!isValidNickname(nickname)) {
|
||||
setLinkMessage('Ник: 3–16 латинских букв, цифр или _')
|
||||
return
|
||||
}
|
||||
pending.current = true
|
||||
setBusy(true)
|
||||
setLinkMessage('Создаём проверку…')
|
||||
requests.current.invalidate()
|
||||
const currentRequest = requests.current.capture()
|
||||
try {
|
||||
const started = await native.startLink(nickname)
|
||||
if (!currentRequest()) return
|
||||
setLinkMessage(started.registered_on_server
|
||||
? 'Зайдите на Aeronautics с этим ником и выполните /login.'
|
||||
: 'Зайдите на Aeronautics с этим ником и выполните /register.')
|
||||
setChallenge({ challengeId: started.challenge_id,
|
||||
expiresAt: Date.now() + started.expires_in_seconds * 1000, isCurrent: currentRequest })
|
||||
} catch (reason) {
|
||||
setLinkMessage(errorMessage(reason, 'Не удалось начать привязку'))
|
||||
} finally {
|
||||
pending.current = false
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
account, error, busy, recoveryCodes, linkMessage, linking: challenge !== null,
|
||||
linkedNickname: linkedNickname(account), authenticate, logout, startLink,
|
||||
clearError: () => setError(null),
|
||||
acknowledgeRecoveryCodes: () => setRecoveryCodes([]),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,6 @@ export function useLauncher() {
|
||||
})
|
||||
for (const server of servers) {
|
||||
const profileId = server.profileId
|
||||
if (!profileId || server.disabled) continue
|
||||
updateProfile({ type: 'check', profileId })
|
||||
void native.inspectProfile(profileId).then((inspection) => {
|
||||
if (active) updateProfile({ type: 'checked', profileId, inspection })
|
||||
@@ -78,14 +77,26 @@ export function useLauncher() {
|
||||
const launch = async (profileId: string) => {
|
||||
if (!isNative() || !eventsReady || busy.current || game.operation.phase !== 'idle') return
|
||||
busy.current = true
|
||||
dispatch({ type: 'install', profileId })
|
||||
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) {
|
||||
dispatch({ type: 'failed', error: errorMessage(reason, 'Не удалось запустить игру') })
|
||||
const error = errorMessage(reason, 'Не удалось запустить игру')
|
||||
updateProfile({ type: 'failed', profileId, error })
|
||||
dispatch({ type: 'failed', error })
|
||||
} finally {
|
||||
busy.current = false
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,16 +1,14 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createSerialQueue, errorMessage } from '../services/async'
|
||||
import { isNative, native } from '../services/native'
|
||||
import { defaultSettings, isValidNickname } from '../state/settings'
|
||||
import type { AccountMode, LauncherSettings } from '../types/launcher'
|
||||
import { defaultSettings } from '../state/settings'
|
||||
import type { LauncherSettings } from '../types/launcher'
|
||||
|
||||
export function useSettings() {
|
||||
const [settings, setSettings] = useState(defaultSettings)
|
||||
const [nickname, setNickname] = useState(defaultSettings.nickname)
|
||||
const [loaded, setLoaded] = useState(!isNative())
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [nicknameError, setNicknameError] = useState<string | null>(null)
|
||||
const [loadAttempt, setLoadAttempt] = useState(0)
|
||||
const current = useRef(settings)
|
||||
const durable = useRef(settings)
|
||||
@@ -26,7 +24,6 @@ export function useSettings() {
|
||||
current.current = value
|
||||
durable.current = value
|
||||
setSettings(value)
|
||||
setNickname(value.nickname)
|
||||
setLoaded(true)
|
||||
}).catch((reason: unknown) => {
|
||||
if (active) setError(errorMessage(reason, 'Не удалось прочитать настройки'))
|
||||
@@ -59,24 +56,11 @@ export function useSettings() {
|
||||
})
|
||||
}
|
||||
|
||||
const saveNickname = () => {
|
||||
if (!isValidNickname(nickname)) {
|
||||
setNicknameError('Ник: от 3 до 16 латинских букв, цифр или символов _')
|
||||
return
|
||||
}
|
||||
setNicknameError(null)
|
||||
save({ nickname })
|
||||
}
|
||||
|
||||
return {
|
||||
settings, nickname, loaded, saving, error, nicknameError,
|
||||
settings, loaded, saving, error,
|
||||
updateRam: (memoryGb: number) => save({ memoryMb: memoryGb * 1024 }),
|
||||
updateMode: (accountMode: AccountMode) => save({ accountMode }),
|
||||
updateNickname: (value: string) => { setNickname(value); setNicknameError(null) },
|
||||
saveNickname,
|
||||
retry: () => {
|
||||
if (!loaded) setLoadAttempt((attempt) => attempt + 1)
|
||||
else if (isValidNickname(nickname)) save({ nickname })
|
||||
else save({})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { deepStrictEqual, equal, rejects } from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { createSerialQueue, createSubscription, errorMessage, singleFlight } from './async.ts'
|
||||
import { createRequestScope, createSerialQueue, createSubscription, errorMessage, singleFlight } from './async.ts'
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
@@ -95,3 +95,19 @@ test('failed account restore can be retried', async () => {
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -18,6 +18,18 @@ export function singleFlight<T>(operation: () => Promise<T>): () => Promise<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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()
|
||||
|
||||
+17
-15
@@ -1,14 +1,16 @@
|
||||
import { invoke, isTauri } from '@tauri-apps/api/core'
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
import { createSubscription, singleFlight } from './async'
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||
import { createSerialQueue, createSubscription, singleFlight } from './async'
|
||||
import type {
|
||||
DeviceCodePayload, GameExitedPayload, InstallProgressPayload,
|
||||
JavaInstallation, LauncherSettings, LoginResultPayload, MinecraftProfile,
|
||||
NativeHost, ProfileInspection, SyncResult,
|
||||
GameExitedPayload, InstallProgressPayload, JavaInstallation, LauncherSettings,
|
||||
LinkChallenge, LinkStatus, NativeHost, ProfileInspection, ServerStatus,
|
||||
ShaCraftAccount, ShaCraftLoginResult, SyncResult,
|
||||
} from '../types/launcher'
|
||||
|
||||
export const isNative = () => typeof window !== 'undefined' && isTauri()
|
||||
const restoreAccount = singleFlight(() => invoke<MinecraftProfile | null>('get_account'))
|
||||
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.
|
||||
@@ -20,20 +22,20 @@ export const native = {
|
||||
inspectProfile: (profileId: string) => invoke<ProfileInspection>('inspect_remote_profile', { profileId }),
|
||||
syncProfile: (profileId: string) => invoke<SyncResult>('sync_remote_profile', { profileId }),
|
||||
getAccount: restoreAccount,
|
||||
startLogin: () => invoke<void>('start_microsoft_login'),
|
||||
logout: () => invoke<void>('logout'),
|
||||
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 })),
|
||||
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 }),
|
||||
}
|
||||
|
||||
export function watchAccount(handlers: {
|
||||
code: (payload: DeviceCodePayload) => void
|
||||
result: (payload: LoginResultPayload) => void
|
||||
}) {
|
||||
return createSubscription([
|
||||
listen<DeviceCodePayload>('msa-login-code', ({ payload }) => handlers.code(payload)),
|
||||
listen<LoginResultPayload>('msa-login-result', ({ payload }) => handlers.result(payload)),
|
||||
])
|
||||
export const windowControls = {
|
||||
minimize: () => getCurrentWindow().minimize(),
|
||||
toggleMaximize: () => getCurrentWindow().toggleMaximize(),
|
||||
close: () => getCurrentWindow().close(),
|
||||
}
|
||||
|
||||
export function watchGame(handlers: {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { equal } from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { launchAccess, linkedNickname, validCredentials } from './account.ts'
|
||||
|
||||
test('launch requires a restored ShaCraft account and its verified Aeronautics link', () => {
|
||||
equal(launchAccess(undefined), 'loading')
|
||||
equal(launchAccess(null), 'login')
|
||||
equal(launchAccess({ username: 'account', links: [] }), 'link')
|
||||
equal(launchAccess({ username: 'account', links: [{ server_id: 'create', mc_username: 'Other_Name' }] }), 'link')
|
||||
equal(launchAccess({ username: 'account', links: [{ server_id: 'aoc', mc_username: 'Verified_Name' }] }), 'ready')
|
||||
})
|
||||
|
||||
test('identity uses snake-case account link payload and never the account login', () => {
|
||||
const account = { username: 'Local_Login', links: [
|
||||
{ server_id: 'create', mc_username: 'Other_Name' },
|
||||
{ server_id: 'aoc', mc_username: 'Verified_Name' },
|
||||
] }
|
||||
equal(linkedNickname(account), 'Verified_Name')
|
||||
equal(linkedNickname({ username: 'Valid_Login', links: [] }), null)
|
||||
equal(linkedNickname({ username: 'Valid_Login', links: [{ server_id: 'aoc', mc_username: '../unsafe' }] }), null)
|
||||
})
|
||||
|
||||
test('ShaCraft credentials preserve the server 3–32 login and 3–128 password contract', () => {
|
||||
equal(validCredentials('abc', '123'), true)
|
||||
equal(validCredentials('a'.repeat(32), 'p'.repeat(128)), true)
|
||||
equal(validCredentials('ab', '123'), false)
|
||||
equal(validCredentials('a'.repeat(33), '123'), false)
|
||||
equal(validCredentials('неверно', '123'), false)
|
||||
equal(validCredentials('account', '12'), false)
|
||||
equal(validCredentials('account', 'p'.repeat(129)), false)
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ShaCraftAccount } from '../types/launcher'
|
||||
import { isValidNickname } from './settings'
|
||||
|
||||
/** Only the authenticated account's verified Aeronautics link selects a name. */
|
||||
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
|
||||
}
|
||||
@@ -14,6 +14,17 @@ test('installation, running game and exit have distinct states', () => {
|
||||
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 })
|
||||
|
||||
+5
-5
@@ -50,8 +50,7 @@ button:disabled { cursor: default; }
|
||||
.window-actions .close:hover { background: #a93939; }
|
||||
|
||||
.workspace { height: calc(100vh - 48px); display: grid; grid-template-columns: 64px 250px 1fr; }
|
||||
.rail { background: #0c100d; border-right: 1px solid var(--line); padding: 18px 10px 14px; display: flex; flex-direction: column; justify-content: space-between; }
|
||||
.rail-main { display: grid; gap: 8px; }
|
||||
.rail { background: #0c100d; border-right: 1px solid var(--line); padding: 18px 10px 14px; display: flex; flex-direction: column; justify-content: flex-end; }
|
||||
.rail-button { border: 0; width: 44px; height: 44px; border-radius: 5px; display: grid; place-items: center; background: transparent; color: #69716a; cursor: pointer; }
|
||||
.rail-button svg { width: 20px; }
|
||||
.rail-button:hover { color: #cfd6cf; background: #171c18; }
|
||||
@@ -73,7 +72,8 @@ button:disabled { cursor: default; }
|
||||
.server-copy strong { font-size: 13px; font-weight: 700; }
|
||||
.server-copy small { font-size: 10px; color: var(--green); margin-top: 2px; }
|
||||
.server-row:not(.selected) .server-copy small { color: #707871; }
|
||||
.account-chip { margin-top: auto; padding: 10px; border-top: 1px solid var(--line); display: grid; grid-template-columns: 34px 1fr 16px; gap: 9px; align-items: center; }
|
||||
.account-chip { width: 100%; margin-top: auto; padding: 10px; border: 0; border-top: 1px solid var(--line); background: transparent; color: inherit; display: grid; grid-template-columns: 34px 1fr 16px; gap: 9px; align-items: center; text-align: left; cursor: pointer; }
|
||||
.account-chip:hover { background: #171c18; }
|
||||
.account-chip .avatar { width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center; background: #243127; color: #bfe1c1; font-size: 11px; font-weight: 800; }
|
||||
.account-chip > span:nth-child(2) { display: grid; }
|
||||
.account-chip strong { font-size: 12px; }
|
||||
@@ -88,7 +88,6 @@ button:disabled { cursor: default; }
|
||||
linear-gradient(112deg, #0b130e 0%, #11291d 39%, #28301f 66%, #512713 100%);
|
||||
}
|
||||
.stage::after { content: ''; position: absolute; inset: 0; z-index: -1; pointer-events: none; background: linear-gradient(180deg, transparent 45%, rgba(5,8,6,.9) 100%); }
|
||||
.stage-create::before { filter: grayscale(.55); background: linear-gradient(110deg, #152019, #27372b 55%, #151a16); }
|
||||
.stage-top { display: flex; justify-content: flex-end; align-items: center; gap: 12px; padding: 22px 26px; }
|
||||
.live-pill, .players { height: 28px; padding: 0 0 0 12px; border: 0; border-left: 1px solid rgba(255,255,255,.16); border-radius: 0; background: transparent; display: flex; align-items: center; gap: 7px; font-size: 11px; color: #c0c8c0; }
|
||||
.live-pill span { width: 7px; height: 7px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 4px rgba(119,209,122,.1), 0 0 12px rgba(119,209,122,.55); }
|
||||
@@ -99,7 +98,6 @@ button:disabled { cursor: default; }
|
||||
.hero-copy p::before { content: ''; width: 26px; height: 2px; background: var(--copper); }
|
||||
.hero-copy h1 { font-family: 'Unbounded', sans-serif; font-size: clamp(40px, 5vw, 66px); line-height: 1; letter-spacing: -.045em; margin: 0; text-shadow: 0 8px 36px rgba(0,0,0,.3); }
|
||||
.hero-copy h2 { max-width: 440px; font-size: 15px; line-height: 1.5; font-weight: 500; color: #aeb7af; margin: 16px 0 0; }
|
||||
.stage-create .hero-copy p { color: #9aa49c; }
|
||||
.hero-meta { display: flex; gap: 0; margin: 36px 0 0; padding: 0; }
|
||||
.hero-meta div { min-width: 130px; padding: 0 26px; border-left: 1px solid rgba(235,243,234,.14); }
|
||||
.hero-meta div:first-child { padding-left: 0; border-left: 0; }
|
||||
@@ -156,6 +154,8 @@ button:disabled { cursor: default; }
|
||||
.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; }
|
||||
.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; }
|
||||
|
||||
+22
-14
@@ -6,10 +6,8 @@ export interface Server {
|
||||
name: string
|
||||
subtitle: string
|
||||
version: string
|
||||
composition: string
|
||||
loader: string
|
||||
profileId?: string
|
||||
disabled?: boolean
|
||||
profileId: string
|
||||
}
|
||||
|
||||
export interface NativeHost {
|
||||
@@ -45,21 +43,31 @@ export interface SyncResult {
|
||||
downloadedBytes: number
|
||||
}
|
||||
|
||||
export interface MinecraftProfile {
|
||||
id: string
|
||||
name: string
|
||||
export interface ShaCraftAccount {
|
||||
username: string
|
||||
links: { server_id: string; mc_username: string }[]
|
||||
}
|
||||
|
||||
export interface DeviceCodePayload {
|
||||
verificationUri: string
|
||||
userCode: string
|
||||
expiresInSeconds: number
|
||||
export interface ShaCraftLoginResult {
|
||||
account: ShaCraftAccount
|
||||
recoveryCodes: string[]
|
||||
}
|
||||
|
||||
export interface LoginResultPayload {
|
||||
ok: boolean
|
||||
profile: MinecraftProfile | null
|
||||
error: string | null
|
||||
export interface LinkChallenge {
|
||||
challenge_id: number
|
||||
expires_in_seconds: number
|
||||
registered_on_server: boolean
|
||||
}
|
||||
|
||||
export interface LinkStatus {
|
||||
status: string
|
||||
detail?: string | null
|
||||
}
|
||||
|
||||
export interface ServerStatus {
|
||||
online: number | null
|
||||
max: number | null
|
||||
reachable: boolean
|
||||
}
|
||||
|
||||
export interface InstallProgressPayload {
|
||||
|
||||
Reference in New Issue
Block a user