merge: preserve ShaCraft 0.1.1 account and Windows features in modular launcher

This commit is contained in:
emil28092005
2026-09-09 13:10:17 +03:00
54 changed files with 2130 additions and 551 deletions
+25 -5
View File
@@ -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
+15 -12
View File
@@ -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
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ShaCraft
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+6 -4
View File
@@ -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. Не имитировать данные.
## Связанные серверные риски
+6 -4
View File
@@ -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
View File
@@ -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
+44 -30
View File
@@ -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.
+86
View File
@@ -0,0 +1,86 @@
# Глобальный рефакторинг — 2026-09-09
## Границы работы
Переработаны backend/шаблоны сайта и React/Rust-слои нового лаунчера.
Не менялись миры, модпак, порты, compose-топология, цены или режимы аккаунтов.
Не переносились production-БД, секреты и приватный ключ подписи.
## Backend
- Монолит main.py разделён на HTTP routers, services, schemas, middleware
и единый резолвер ресурсов. Entrypoint app.main:app сохранён.
- Cookie/bearer вход используют одну реализацию аккаунтов, паролей и сессий.
- Ограничитель попыток входа защищён от гонок и бесконечного роста памяти.
Он process-local; multi-worker развертывание требует общего хранилища.
- Оплата и подписка фиксируются одной SQLite-транзакцией. Claim/reject,
продление, recovery и привязки сериализуют конфликтующие операции.
- JSON metadata проверяются централизованно: неверный UTF-8, не-object JSON
и небезопасные идентификаторы не приводят к непредусмотренному чтению пути.
- Jinja наследование убирает копии фона/шапки/навигации. Размер фона 72×83
задаётся единожды. HTML-формы, скрипты и визуальная структура сохранены.
- Python зависимости зафиксированы на версиях действовавшего runtime.
Добавлены pytest, Ruff, CI и изолированный test-stage Docker.
## Лаунчер
- React разделён на компоненты, hooks, типизированный IPC и reducers.
TypeScript проверяется сборкой; ошибки IPC не теряются.
- Сохранены новые изменения GitHub 0.1.1: обязательный ShaCraft-аккаунт,
verified aoc nickname, реальный онлайн, управление окном и Windows-фиксы
установки/полных библиотек/точной Java major/длинной JVM-команды.
- Сохранения настроек упорядочены; lifecycle install/launch/exit явный;
демонстрационные значения прогресса не выдаются за реальную установку.
- Rust commands выделены по ответственности. Неиспользуемые команды
unsigned sync/inspect удалены; запись профиля требует verified manifest.
- Общие атомарные записи и process-local permits защищают файлы от
конфликтующих операций. Проверяются переносимые пути, symlink и подпись.
- HTTPS exact-host policy распространяется и на redirects отдельных
игровых провайдеров. Метаданные архивов Adoptium проверяются до загрузки.
- GitHub: тесты/сборка на push/PR; ручная матрица пакетов Linux/Windows/macOS
Intel/ARM с сохранением артефактов. Это не подпись релизов и не автообновление.
## Проверки
Фактический итог: 88 backend-тестов + 23 subtests (локально и в изолированном
Docker), 22 UI-теста, 59 Rust unit tests и отдельная живая read-only проверка
production signed-manifest — успешно. npm ci/audit: 0 известных уязвимостей
на момент прогона; строгий TypeScript/Vite и Ruff проходят. Это итог после
объединения со всеми изменениями GitHub 0.1.1; прежние upstream Rust-тесты
сохранены. Browser smoke: вход/регистрация ShaCraft, preview-gating,
настройки, закрытие Escape и восстановление фокуса проверены без отправки
учётных данных. Четыре тяжёлых/live игровых сценария не запускались.
Backend выложен, image ID контейнера совпадает с собранным образом;
главная/Help/Моды/кабинет/healthz/catalog/signed-manifest отвечают 200,
публичная админка по-прежнему закрыта Caddy (403). Внешний вид проверен
в браузере. Время запуска mc-aoc не изменилось. Резервный образ сохранён
как `shacraft-backend:before-refactor-20260909`, исходники —
`/root/shacraft-rollback-NWKzLR`. Схема БД не менялась.
Backend: неизменность полного OpenAPI-снимка, публичные шаблоны, аккаунты,
пароли/recovery, rate limit, LoginSystem gating, admin auth, платежи,
rollback/параллельные операции, каталоги/manifest/config, startup/shutdown.
Внешние эффекты замещены заглушками, БД только временная.
Launcher: строгий TypeScript + Vite, тесты UI state/settings/listeners,
Rust unit tests по подписи/путям/хранилищу/IPC guards/trusted hosts.
Полная холодная установка игры, OAuth и запуск на Windows/macOS требуют
отдельного ручного beta-прогона; не выдавать локальные проверки за такой тест.
## Не замаскированные рефактором ограничения
1. Привязка ника: NoGravity подтверждает авторизацию игрока на сервере,
но не связь с конкретным веб-запросом. Нужен одноразовый challenge в игре.
2. Реферальная акция: enforcement REFERRAL_ENABLED и новизны самого
плательщика не соответствует всей публичной формулировке. Требуется
отдельное согласованное изменение бизнес-правил и регрессионные тесты.
3. RCON/уведомления после commit не имеют outbox/retry. Сбой может потребовать
ручной выдачи, а не повторного начисления подписки.
4. Microsoft OAuth не является текущим способом входа: используется
ShaCraft account + verified nickname. Неактивный OAuth-модуль требует
собственного client ID и API approval при отдельной будущей активации.
5. Нужны подписанные installer/update-релизы, native beta на всех ОС,
полноценная отмена загрузок и recovery пользовательских данных.
6. Блокировки лаунчера process-local; symlink-проверки не защищают от
злонамеренного same-user TOCTOU. Keychain хранения refresh token пока нет.
+2 -2
View File
@@ -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
View File
@@ -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",
+1 -1
View File
@@ -3216,7 +3216,7 @@ dependencies = [
[[package]]
name = "shacraft-launcher"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"base64 0.22.1",
"ed25519-dalek",
+2 -1
View File
@@ -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]
+7 -1
View File
@@ -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 -56
View File
@@ -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)
);
}
}
+23 -25
View File
@@ -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.
+6 -1
View File
@@ -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> {
+1
View File
@@ -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};
+9
View File
@@ -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(
+120
View File
@@ -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
View File
@@ -2,7 +2,11 @@ use crate::download::ProgressCallback;
use crate::runtime::{self, RuntimeError};
use reqwest::blocking::Client;
use serde::Serialize;
use std::{env, fmt, path::{Path, PathBuf}, process::Command};
use std::{
env, fmt,
path::{Path, PathBuf},
process::Command,
};
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -21,9 +25,14 @@ pub enum EnsureJavaError {
impl fmt::Display for EnsureJavaError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Provisioning(error) => write!(formatter, "Cannot install a Java runtime: {error}"),
Self::Provisioning(error) => {
write!(formatter, "Cannot install a Java runtime: {error}")
}
Self::ProvisionedButUnrecognised(path) => {
write!(formatter, "Installed a Java runtime at {path:?}, but it did not report a usable version")
write!(
formatter,
"Installed a Java runtime at {path:?}, but it did not report a usable version"
)
}
}
}
@@ -38,21 +47,29 @@ pub fn detect() -> Option<JavaInstallation> {
candidates().into_iter().find_map(check_candidate)
}
/// Returns a Java runtime with at least `required_major`, preferring
/// whatever the user already has installed. Only downloads and extracts a
/// Returns a Java runtime with exactly `required_major`, preferring a matching
/// installation already on the machine. Newer JVM majors are not assumed to
/// be compatible with the selected NeoForge/modpack version. Only downloads and extracts a
/// ShaCraft-managed Eclipse Temurin JRE under `runtime_root` (never touches
/// the user's own Java) when nothing suitable is already on the machine.
/// `on_progress` reports real download bytes when a JRE actually needs
/// fetching; it fires once with `(1, 1)` when an existing Java is reused.
pub fn ensure_java(client: &Client, runtime_root: &Path, required_major: u8, on_progress: &ProgressCallback) -> Result<JavaInstallation, EnsureJavaError> {
pub fn ensure_java(
client: &Client,
runtime_root: &Path,
required_major: u8,
on_progress: &ProgressCallback,
) -> Result<JavaInstallation, EnsureJavaError> {
if let Some(installation) = detect() {
if installation.major >= required_major {
if installation.major == required_major {
on_progress(1, 1);
return Ok(installation);
}
}
let executable = runtime::ensure_runtime(client, runtime_root, required_major, on_progress).map_err(EnsureJavaError::Provisioning)?;
check_candidate(executable.clone()).ok_or(EnsureJavaError::ProvisionedButUnrecognised(executable))
let executable = runtime::ensure_runtime(client, runtime_root, required_major, on_progress)
.map_err(EnsureJavaError::Provisioning)?;
check_candidate(executable.clone())
.ok_or(EnsureJavaError::ProvisionedButUnrecognised(executable))
}
fn candidates() -> Vec<PathBuf> {
+128 -15
View File
@@ -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\"""#
);
}
}
+8
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();
}
+1
View File
@@ -11,6 +11,7 @@ use std::sync::{
pub(crate) struct LauncherOperations {
pub installation: Operation,
pub account: Operation,
pub shacraft_account: Operation,
}
#[derive(Clone, Default)]
+21
View File
@@ -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
View File
@@ -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();
}
}
+278
View File
@@ -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();
}
}
+56 -1
View File
@@ -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();
}
}
+9 -2
View File
@@ -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
View File
@@ -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>
)
}
+62
View File
@@ -0,0 +1,62 @@
import { useState } from 'react'
import { LogOut, Users } from 'lucide-react'
import type { useAccount } from '../hooks/useAccount'
import { isNative } from '../services/native'
export function AccountSettings({ session, locked }: { session: ReturnType<typeof useAccount>; locked: boolean }) {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [registering, setRegistering] = useState(false)
const [nickname, setNickname] = useState('')
const { account, linkedNickname, busy } = session
const disabled = locked || busy || account === undefined
return (
<>
<div className="setting-row static"><span><Users />Аккаунт</span><small>{account === undefined ? 'Проверяем…' : account?.username ?? 'Не авторизован'}</small></div>
{!account && (
<form onSubmit={async (event) => {
event.preventDefault()
if (disabled) return
if (await session.authenticate(username, password, registering)) setPassword('')
}}>
<label className="text-setting">
<span><strong>Логин ShaCraft</strong><small>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
View File
@@ -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>
</>
)
-16
View File
@@ -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>
</>
)
}
+5 -6
View File
@@ -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}
+24
View File
@@ -0,0 +1,24 @@
import { useEffect, useRef } from 'react'
export function RecoveryCodesModal({ codes, onAcknowledge }: { codes: string[]; onAcknowledge: () => void }) {
const button = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (!codes.length) return
const previous = document.activeElement
button.current?.focus()
return () => { if (previous instanceof HTMLElement) previous.focus() }
}, [codes])
if (!codes.length) return null
return (
<>
<div className="drawer-backdrop visible" />
<div className="login-modal" role="dialog" aria-modal="true" aria-labelledby="recovery-title"
onKeyDown={(event) => { if (event.key === 'Tab') { event.preventDefault(); button.current?.focus() } }}>
<h2 id="recovery-title">Коды восстановления</h2>
<p>Сохраните их сейчас. Каждый код можно использовать один раз для восстановления пароля.</p>
<div className="login-code recovery-codes">{codes.join('\n')}</div>
<button ref={button} className="setting-row" onClick={onAcknowledge}><span>Я сохранил коды</span></button>
</div>
</>
)
}
+8 -5
View File
@@ -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>
+4 -27
View File
@@ -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>
+12 -7
View File
@@ -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>
)
+44
View File
@@ -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
View File
@@ -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
View File
@@ -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([]),
}
}
+14 -3
View File
@@ -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
}
+27
View File
@@ -0,0 +1,27 @@
import { useEffect, useState } from 'react'
import { isNative, native } from '../services/native'
import type { ServerStatus } from '../types/launcher'
export function useServerStatus(profileId: string) {
const [status, setStatus] = useState<ServerStatus | null>(null)
useEffect(() => {
setStatus(null)
if (!isNative()) return
let active = true
let timer: ReturnType<typeof setTimeout> | undefined
const refresh = async () => {
try {
const next = await native.serverStatus(profileId)
if (active) setStatus(next)
} catch {
if (active) setStatus({ online: null, max: null, reachable: false })
} finally {
// Schedule after completion; slow network calls never overlap.
if (active) timer = setTimeout(() => { void refresh() }, 30_000)
}
}
void refresh()
return () => { active = false; clearTimeout(timer) }
}, [profileId])
return status
}
+3 -19
View File
@@ -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({})
},
}
+17 -1
View File
@@ -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)
})
+12
View File
@@ -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
View File
@@ -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: {
+31
View File
@@ -0,0 +1,31 @@
import { equal } from 'node:assert/strict'
import { test } from 'node:test'
import { launchAccess, linkedNickname, validCredentials } from './account.ts'
test('launch requires a restored ShaCraft account and its verified Aeronautics link', () => {
equal(launchAccess(undefined), 'loading')
equal(launchAccess(null), 'login')
equal(launchAccess({ username: 'account', links: [] }), 'link')
equal(launchAccess({ username: 'account', links: [{ server_id: 'create', mc_username: 'Other_Name' }] }), 'link')
equal(launchAccess({ username: 'account', links: [{ server_id: 'aoc', mc_username: 'Verified_Name' }] }), 'ready')
})
test('identity uses snake-case account link payload and never the account login', () => {
const account = { username: 'Local_Login', links: [
{ server_id: 'create', mc_username: 'Other_Name' },
{ server_id: 'aoc', mc_username: 'Verified_Name' },
] }
equal(linkedNickname(account), 'Verified_Name')
equal(linkedNickname({ username: 'Valid_Login', links: [] }), null)
equal(linkedNickname({ username: 'Valid_Login', links: [{ server_id: 'aoc', mc_username: '../unsafe' }] }), null)
})
test('ShaCraft credentials preserve the server 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)
})
+18
View File
@@ -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
}
+11
View File
@@ -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
View File
@@ -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
View File
@@ -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 {