refactor: modularize launcher UI and native services with verified IO
This commit is contained in:
@@ -3,6 +3,9 @@ name: Cross-platform build
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: ${{ matrix.name }}
|
||||
@@ -14,13 +17,13 @@ jobs:
|
||||
os: ubuntu-22.04
|
||||
args: --bundles appimage,deb
|
||||
- name: Windows x64
|
||||
os: windows-latest
|
||||
os: windows-2022
|
||||
args: --bundles nsis,msi
|
||||
- name: macOS Apple Silicon
|
||||
os: macos-14
|
||||
os: macos-15
|
||||
args: --target aarch64-apple-darwin --bundles dmg
|
||||
- name: macOS Intel
|
||||
os: macos-13
|
||||
os: macos-15-intel
|
||||
args: --target x86_64-apple-darwin --bundles dmg
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
@@ -32,5 +35,19 @@ jobs:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Install Linux desktop dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||
- run: npm ci
|
||||
- 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
|
||||
with:
|
||||
name: shacraft-${{ matrix.os }}
|
||||
if-no-files-found: error
|
||||
path: |
|
||||
src-tauri/target/release/bundle/**
|
||||
src-tauri/target/*/release/bundle/**
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
name: Launcher checks
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: checks-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Install Linux desktop dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||
- run: npm ci
|
||||
- run: npm test
|
||||
- run: npm run build
|
||||
- run: cargo test --locked --manifest-path src-tauri/Cargo.toml
|
||||
@@ -48,9 +48,20 @@ payload are in `/root/shacraft` on the ShaCraft host; see
|
||||
|
||||
## Layout
|
||||
|
||||
- `src/main.tsx` — UI state and Tauri command calls; do not put privileged
|
||||
operations in the web layer.
|
||||
- `src/main.tsx` — React entrypoint; `src/App.tsx` composes the screen.
|
||||
- `src/components/` — presentational UI; `src/hooks/` — lifecycle/settings/account.
|
||||
- `src/services/native.ts` — typed IPC and event subscriptions; keep schemas
|
||||
aligned with Rust. `src/services/async.ts` — serialized writes, single-flight
|
||||
account restore and listener disposal. `src/state/` — tested reducers.
|
||||
- Native filesystem/network/process operations never belong in the web layer.
|
||||
- `src-tauri/src/` — native commands and security-sensitive logic.
|
||||
- `lib.rs` — module/command registration only; `commands/` holds adapters
|
||||
for account/game/host/preferences/profiles. Unsigned sync/inspect IPC was
|
||||
removed; only verified remote manifests may drive profile mutations.
|
||||
- `operations.rs` — process-local install/account permits owned by workers.
|
||||
Offline launch must not acquire the Microsoft refresh permit.
|
||||
- `storage.rs` — unique same-directory atomic writes, owner-only Unix files.
|
||||
- `trusted_http.rs` — HTTPS and exact-host redirect policy per game provider.
|
||||
- `download.rs` — shared verified-download helper (temp file, hash,
|
||||
atomic rename, progress callback); `manifest.rs`/`profile.rs` (ShaCraft
|
||||
mods) and `mojang.rs`/`neoforge.rs`/`runtime.rs` (the game itself) all
|
||||
@@ -70,13 +81,17 @@ payload are in `/root/shacraft` on the ShaCraft host; see
|
||||
(mods/config only).
|
||||
- `docs/game-trust-boundary.md` — the Mojang/NeoForge/Microsoft/Adoptium
|
||||
trust domains used to install and run the game itself.
|
||||
- `.github/workflows/build.yml` — manual cross-platform build matrix.
|
||||
- `.github/workflows/check.yml` — push/PR UI checks and Linux Rust tests.
|
||||
- `.github/workflows/build.yml` — manual cross-platform builds with artifacts;
|
||||
not a signed release or updater publication.
|
||||
|
||||
## Verification
|
||||
|
||||
Run from repository root:
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm test
|
||||
npm run build
|
||||
(cd src-tauri && /home/emil/.cargo/bin/cargo test)
|
||||
npm run tauri:dev
|
||||
@@ -85,6 +100,9 @@ npm run tauri:dev
|
||||
`tauri:dev` is for local desktop testing. A successful web build alone does
|
||||
not prove Tauri commands work.
|
||||
|
||||
See `PLAN.md` for known gaps. Never label browser preview or unit tests as
|
||||
a successful cold game install / Microsoft OAuth / Windows/macOS beta test.
|
||||
|
||||
## Working conventions
|
||||
|
||||
- Keep UI copy in Russian; code, identifiers and errors may remain English.
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# План ShaCraft Launcher
|
||||
|
||||
## Сделано: рефакторинг 2026-09-09
|
||||
|
||||
- [x] React components/hooks/typed IPC/state reducers вместо единого main.tsx.
|
||||
- [x] Строгий TypeScript, тесты async lifecycle и последовательных сохранений.
|
||||
- [x] Rust commands по ответственности; signed-only синхронизация профиля.
|
||||
- [x] Общие atomic files, process-local operation guards, portable path checks.
|
||||
- [x] Проверка подписи, размера envelope и profile ID; bounded provider redirects.
|
||||
- [x] Push/PR проверки и ручная матрица сборки с артефактами.
|
||||
|
||||
## Следующие задачи
|
||||
|
||||
- [ ] Microsoft: собственный public-client ID + Minecraft API approval;
|
||||
затем живой device-code/login/refresh/logout тест. Сейчас ID — placeholder.
|
||||
- [ ] Cold install / repair / update / game exit на чистых Windows/Linux/macOS.
|
||||
Unit tests и web preview не заменяют эти прогоны.
|
||||
- [ ] Подписанные installer-релизы и подписанное автообновление лаунчера.
|
||||
- [ ] Реальная отмена загрузок, журнал с редактированием токенов и retry UX.
|
||||
- [ ] Выбор каталога профиля и безопасный reset только managed-файлов.
|
||||
- [ ] Keychain-хранилище refresh token; cross-process exclusion при необходимости.
|
||||
- [ ] Динамический каталог/онлайн серверов, новости и ссылки сообщества.
|
||||
Недоступные функции сейчас отключены, данные не имитируются.
|
||||
|
||||
## Связанные серверные риски
|
||||
|
||||
Серверный план находится в `/root/shacraft/PLAN.md`. Важные следующие шаги:
|
||||
одноразовое подтверждение ника внутри игры (NoGravity не связывает игрока
|
||||
с веб-запросом), enforcement реферальных правил и очередь повторов whitelist.
|
||||
Не менять этот протокол незаметно в клиентском рефакторинге.
|
||||
@@ -1,41 +1,49 @@
|
||||
# ShaCraft Launcher
|
||||
|
||||
Кроссплатформенный лаунчер для сети Minecraft-серверов ShaCraft.
|
||||
Кроссплатформенный Tauri 2 лаунчер для [ShaCraft](https://shacraft.ru/):
|
||||
React/TypeScript интерфейс, Rust — файлы, сеть и запуск процессов.
|
||||
|
||||
Сейчас реализовано:
|
||||
Реализованы подписанная синхронизация Aeronautics, проверка/восстановление
|
||||
модов и конфигурации, Java discovery/provisioning, bootstrap Minecraft и
|
||||
NeoForge, настройки памяти и ника, обработка установки/запуска/выхода.
|
||||
|
||||
- интерактивный интерфейс профилей и настроек;
|
||||
- Tauri 2 native shell с безопасной командой `native_host`;
|
||||
- сохранение памяти профиля в локальном каталоге данных приложения;
|
||||
- безопасное обнаружение установленной Java (включая `JAVA_HOME`) перед запуском;
|
||||
- интеграция с фиксированным ShaCraft launcher v2 manifest для Aeronautics;
|
||||
- адаптивное окно для Windows, Linux и macOS;
|
||||
- вход через настоящий аккаунт Microsoft (без него игра не устанавливается
|
||||
и не запускается — так владение игрой проверяется по-настоящему);
|
||||
- установка Minecraft и NeoForge версии, которую задаёт manifest, и запуск
|
||||
игры.
|
||||
Режим аккаунта выбирается явно: offline-профиль или Microsoft. Код Microsoft
|
||||
OAuth/проверки владения готов, но **вход ещё требует собственного client ID и
|
||||
одобрения Minecraft API**. Offline не подставляется при ошибке Microsoft.
|
||||
|
||||
## Локальная разработка
|
||||
## Разработка
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Для нативного приложения нужен Rust:
|
||||
Это браузерный preview — он не устанавливает и не запускает игру.
|
||||
Для приложения нужен Rust и системные зависимости Tauri:
|
||||
|
||||
```bash
|
||||
npm run tauri:dev
|
||||
```
|
||||
|
||||
На Ubuntu для сборки Tauri также потребуются системные пакеты WebKit/GTK и
|
||||
DBus development headers. Их установка описана в официальной документации
|
||||
Tauri и требует прав администратора.
|
||||
## Проверка
|
||||
|
||||
## Статус
|
||||
```bash
|
||||
npm test
|
||||
npm run build
|
||||
cargo test --locked --manifest-path src-tauri/Cargo.toml
|
||||
```
|
||||
|
||||
Вход через Microsoft, установка и запуск Aeronautics уже работают. Вход
|
||||
через Microsoft пока не активен на боевой сборке: нужна собственная
|
||||
регистрация приложения ShaCraft в Azure AD и её одобрение Microsoft для
|
||||
доступа к Minecraft API — см. комментарий к `MSA_CLIENT_ID` в
|
||||
`src-tauri/src/msa.rs`.
|
||||
Build включает строгий TypeScript. GitHub Actions проверяет UI и Rust на
|
||||
push/PR; ручной workflow собирает пакеты Windows x64, Linux x64, macOS Intel
|
||||
и Apple Silicon и сохраняет артефакты. Подпись релиза/автообновления ещё впереди.
|
||||
Используемые macOS runners соответствуют [списку GitHub](https://docs.github.com/en/actions/reference/runners/github-hosted-runners).
|
||||
|
||||
## Навигация
|
||||
|
||||
- [Архитектура](docs/launcher-architecture.md) — компоненты, данные, IPC.
|
||||
- [Trust boundaries](docs/game-trust-boundary.md) — доверенные источники игры.
|
||||
- [Manifest](docs/manifest-v1.md) — подписанный контракт модпака.
|
||||
- [PLAN.md](PLAN.md) — ограничения и следующие шаги.
|
||||
- [AGENTS.md](AGENTS.md) — инструкции для следующего разработчика/агента.
|
||||
|
||||
Не хранить в Git токены, ключи, пользовательские данные или пакеты игры.
|
||||
|
||||
@@ -28,7 +28,7 @@ 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)
|
||||
-> real Microsoft/Xbox/Minecraft Services login (msa.rs)
|
||||
-> explicit Microsoft session (msa.rs) OR offline identity (session.rs)
|
||||
-> java process spawned with the merged classpath/args (launch.rs)
|
||||
```
|
||||
|
||||
@@ -53,14 +53,41 @@ be the system `.minecraft` directory.
|
||||
- ShaCraft download files: HTTPS only, exact hosts `shacraft.ru` and
|
||||
`cdn.shacraft.ru`.
|
||||
|
||||
## Module boundaries (2026-09-09)
|
||||
|
||||
React entrypoint → App/components → hooks → typed native service. Pure
|
||||
reducers own game and profile states; IPC failures retain their real message.
|
||||
Settings writes are serialized, and account restoration is single-flight
|
||||
even under React StrictMode. A failed repair invalidates profile readiness.
|
||||
Game exit may arrive before launch acknowledgement; the reducer handles both.
|
||||
Browser preview cannot install/launch and does not simulate download progress.
|
||||
|
||||
Rust `lib.rs` registers commands from `commands/`. 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.
|
||||
`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.
|
||||
|
||||
## 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. Real per-stage byte progress for the Java/NeoForge install steps
|
||||
(currently start/done only — the dominant, user-visible wait, asset
|
||||
downloading, already reports real bytes).
|
||||
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.
|
||||
|
||||
Generated
+587
-37
File diff suppressed because it is too large
Load Diff
+14
-10
@@ -5,23 +5,27 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build": "npm run typecheck && vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "tsx --test src/services/async.test.ts src/state/game.test.ts src/state/profiles.test.ts",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "tauri build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@vitejs/plugin-react": "latest",
|
||||
"@tauri-apps/cli": "^2.9.4",
|
||||
"lucide-react": "latest",
|
||||
"react": "latest",
|
||||
"react-dom": "latest",
|
||||
"typescript": "latest",
|
||||
"vite": "latest"
|
||||
"@tauri-apps/api": "2.11.1",
|
||||
"lucide-react": "1.41.0",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.11.4"
|
||||
"@tauri-apps/cli": "2.11.4",
|
||||
"@types/node": "^22.19.0",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"tsx": "4.23.13",
|
||||
"typescript": "7.0.2",
|
||||
"vite": "8.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
use super::data_dir;
|
||||
use crate::{
|
||||
msa,
|
||||
operations::{LauncherOperations, Operation},
|
||||
session, settings,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DeviceCodePayload {
|
||||
verification_uri: String,
|
||||
user_code: String,
|
||||
expires_in_seconds: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct LoginResultPayload {
|
||||
ok: bool,
|
||||
profile: Option<msa::MinecraftProfile>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
/// Starts a Microsoft device-code login in the background. Emits
|
||||
/// `msa-login-code` as soon as the user code is available (show it to the
|
||||
/// player immediately — they have a limited time to enter it), then
|
||||
/// `msa-login-result` once sign-in finishes, fails, or times out. Returns
|
||||
/// immediately; it does not wait for the user to finish signing in.
|
||||
#[tauri::command]
|
||||
pub(crate) fn start_microsoft_login(
|
||||
app: AppHandle,
|
||||
state: State<'_, LauncherOperations>,
|
||||
) -> Result<(), String> {
|
||||
let directory = data_dir(&app)?;
|
||||
let permit = state.account.acquire("Account operation")?;
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let _permit = permit;
|
||||
let login = || -> Result<msa::MinecraftProfile, String> {
|
||||
let client = msa::http_client().map_err(|error| error.to_string())?;
|
||||
let start = msa::start_device_code(&client).map_err(|error| error.to_string())?;
|
||||
let _ = app.emit(
|
||||
"msa-login-code",
|
||||
DeviceCodePayload {
|
||||
verification_uri: start.verification_uri.clone(),
|
||||
user_code: start.user_code.clone(),
|
||||
expires_in_seconds: start.expires_in_seconds,
|
||||
},
|
||||
);
|
||||
let result =
|
||||
msa::login_with_device_code(&client, &start).map_err(|error| error.to_string())?;
|
||||
msa::save_refresh_token(&directory, &result.refresh_token)
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(result.profile)
|
||||
};
|
||||
let payload = match login() {
|
||||
Ok(profile) => LoginResultPayload {
|
||||
ok: true,
|
||||
profile: Some(profile),
|
||||
error: None,
|
||||
},
|
||||
Err(error) => LoginResultPayload {
|
||||
ok: false,
|
||||
profile: None,
|
||||
error: Some(error),
|
||||
},
|
||||
};
|
||||
let _ = app.emit("msa-login-result", payload);
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tries to restore a session from a previously saved refresh token
|
||||
/// (silent, no browser/user code). Returns `None` if there is none saved
|
||||
/// or it no longer works — the UI should fall back to offering login.
|
||||
#[tauri::command]
|
||||
pub(crate) async fn get_account(
|
||||
app: AppHandle,
|
||||
state: State<'_, LauncherOperations>,
|
||||
) -> Result<Option<msa::MinecraftProfile>, String> {
|
||||
let data_dir = data_dir(&app)?;
|
||||
let permit = state.account.acquire("Account operation")?;
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let _permit = permit;
|
||||
let Some(refresh_token) = msa::load_refresh_token(&data_dir) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let client = msa::http_client().map_err(|error| error.to_string())?;
|
||||
match msa::login_with_refresh_token(&client, &refresh_token) {
|
||||
Ok(result) => {
|
||||
msa::save_refresh_token(&data_dir, &result.refresh_token)
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(Some(result.profile))
|
||||
}
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("Account restore task failed: {error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn logout(
|
||||
app: AppHandle,
|
||||
state: State<'_, LauncherOperations>,
|
||||
) -> Result<(), String> {
|
||||
let data_dir = data_dir(&app)?;
|
||||
let permit = state.account.acquire("Account operation")?;
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let _permit = permit;
|
||||
msa::clear_account(&data_dir)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("Logout task failed: {error}"))?
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
use super::{account::resolve_identity, data_dir};
|
||||
use crate::{
|
||||
java, launch, manifest, mojang, neoforge, operations::LauncherOperations, remote, runtime,
|
||||
settings,
|
||||
};
|
||||
use reqwest::blocking::Client;
|
||||
use serde::Serialize;
|
||||
use std::{path::Path, sync::Arc, time::SystemTime};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct InstallProgress {
|
||||
stage: &'static str,
|
||||
current_bytes: u64,
|
||||
total_bytes: u64,
|
||||
}
|
||||
|
||||
/// Resolves the vanilla + (if any) loader version JSONs for `manifest` and
|
||||
/// merges them, ensuring a Java runtime and (for NeoForge profiles) running
|
||||
/// the installer along the way. Shared by `ensure_game_installed` and
|
||||
/// `launch_game` so both always agree on exactly what "installed" means.
|
||||
/// `on_progress` is forwarded to the NeoForge installer when one runs;
|
||||
/// callers that don't display progress (e.g. `launch_game`, which only
|
||||
/// hits this after `ensure_game_installed` already installed everything)
|
||||
/// pass a no-op callback.
|
||||
fn resolve_merged_version(
|
||||
client: &Client,
|
||||
manifest: &manifest::Manifest,
|
||||
java_executable: &Path,
|
||||
game_dir: &Path,
|
||||
cache_dir: &Path,
|
||||
on_progress: &mojang::ProgressCallback,
|
||||
) -> Result<mojang::MergedVersion, String> {
|
||||
let mojang_manifest =
|
||||
mojang::fetch_version_manifest(client).map_err(|error| error.to_string())?;
|
||||
let vanilla_entry = mojang::find_version(&mojang_manifest, &manifest.minecraft.version)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"Mojang does not list Minecraft version {}",
|
||||
manifest.minecraft.version
|
||||
)
|
||||
})?;
|
||||
let vanilla =
|
||||
mojang::fetch_version_json(client, vanilla_entry).map_err(|error| error.to_string())?;
|
||||
|
||||
if manifest.minecraft.loader.kind == "neoforge" {
|
||||
let installer_client = neoforge::http_client().map_err(|error| error.to_string())?;
|
||||
let neoforge_version = neoforge::ensure_client_installed(
|
||||
&installer_client,
|
||||
java_executable,
|
||||
game_dir,
|
||||
cache_dir,
|
||||
&manifest.minecraft.loader.version,
|
||||
on_progress,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
mojang::merge_versions(&vanilla, Some(&neoforge_version)).map_err(|error| error.to_string())
|
||||
} else {
|
||||
mojang::merge_versions(&vanilla, None).map_err(|error| error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Downloads and installs everything needed to run `profile_id`: the
|
||||
/// exact Minecraft/loader version the ShaCraft-signed manifest specifies,
|
||||
/// a Java runtime if none is already usable, and game assets. Emits
|
||||
/// `game-install-progress` throughout with real progress for every stage:
|
||||
/// download bytes for Java, installer-confirmed library/processor counts
|
||||
/// for NeoForge, and download bytes for libraries/assets.
|
||||
#[tauri::command]
|
||||
pub(crate) async fn ensure_game_installed(
|
||||
app: AppHandle,
|
||||
state: State<'_, LauncherOperations>,
|
||||
profile_id: String,
|
||||
) -> Result<(), String> {
|
||||
let game_dir = data_dir(&app)?.join("game");
|
||||
let runtime_root = game_dir.join("runtime");
|
||||
let cache_dir = game_dir.join("cache");
|
||||
let permit = state.installation.acquire("Installation")?;
|
||||
|
||||
tauri::async_runtime::spawn_blocking(move || -> Result<(), String> {
|
||||
let _permit = permit;
|
||||
let client = mojang::http_client().map_err(|error| error.to_string())?;
|
||||
let runtime_client = runtime::http_client().map_err(|error| error.to_string())?;
|
||||
let manifest = remote::fetch_manifest(&profile_id).map_err(|error| error.to_string())?;
|
||||
|
||||
let stage_progress = |stage: &'static str| -> mojang::ProgressCallback {
|
||||
let app = app.clone();
|
||||
Arc::new(move |current, total| {
|
||||
let _ = app.emit(
|
||||
"game-install-progress",
|
||||
InstallProgress {
|
||||
stage,
|
||||
current_bytes: current,
|
||||
total_bytes: total,
|
||||
},
|
||||
);
|
||||
})
|
||||
};
|
||||
|
||||
let java_install = java::ensure_java(
|
||||
&runtime_client,
|
||||
&runtime_root,
|
||||
manifest.minecraft.java_major,
|
||||
&stage_progress("java"),
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
let merged = resolve_merged_version(
|
||||
&client,
|
||||
&manifest,
|
||||
Path::new(&java_install.executable),
|
||||
&game_dir,
|
||||
&cache_dir,
|
||||
&stage_progress("neoforge"),
|
||||
)?;
|
||||
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())?;
|
||||
};
|
||||
|
||||
let asset_index = mojang::ensure_asset_index(&client, &game_dir, &merged.asset_index)
|
||||
.map_err(|error| error.to_string())?;
|
||||
mojang::ensure_assets(&client, &game_dir, &asset_index, &stage_progress("assets"))
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("Install task failed: {error}"))?
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct GameExited {
|
||||
profile_id: String,
|
||||
exit_code: Option<i32>,
|
||||
}
|
||||
|
||||
/// Launches `profile_id` as the account chosen in settings (`account_mode`).
|
||||
/// In `Microsoft` mode a real session is required (see `resolve_identity`);
|
||||
/// in `Offline` mode the local nickname from settings is used, so no
|
||||
/// Microsoft account is needed. Spawns the game detached; watches it on a
|
||||
/// background thread only to emit `game-exited` when it eventually closes.
|
||||
#[tauri::command]
|
||||
pub(crate) async fn launch_game(
|
||||
app: AppHandle,
|
||||
state: State<'_, LauncherOperations>,
|
||||
profile_id: String,
|
||||
) -> Result<(), String> {
|
||||
let game_dir = data_dir(&app)?.join("game");
|
||||
let data_dir = data_dir(&app)?;
|
||||
let permit = state.installation.acquire("Installation")?;
|
||||
let account_operation = state.account.clone();
|
||||
|
||||
tauri::async_runtime::spawn_blocking(move || -> Result<(), String> {
|
||||
let _permit = permit;
|
||||
let client = mojang::http_client().map_err(|error| error.to_string())?;
|
||||
let runtime_client = runtime::http_client().map_err(|error| error.to_string())?;
|
||||
let manifest = remote::fetch_manifest(&profile_id).map_err(|error| error.to_string())?;
|
||||
let profile_dir = data_dir.join("profiles").join(&manifest.id);
|
||||
let settings = settings::load(&data_dir).map_err(|error| error.to_string())?;
|
||||
let identity = resolve_identity(&data_dir, &settings, &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.
|
||||
let no_progress: mojang::ProgressCallback = Arc::new(|_, _| {});
|
||||
let java_install = java::ensure_java(
|
||||
&runtime_client,
|
||||
&game_dir.join("runtime"),
|
||||
manifest.minecraft.java_major,
|
||||
&no_progress,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let merged = resolve_merged_version(
|
||||
&client,
|
||||
&manifest,
|
||||
Path::new(&java_install.executable),
|
||||
&game_dir,
|
||||
&game_dir.join("cache"),
|
||||
&no_progress,
|
||||
)?;
|
||||
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let log_dir = data_dir.join("logs");
|
||||
std::fs::create_dir_all(&log_dir).map_err(|error| error.to_string())?;
|
||||
let log_path = log_dir.join(format!("{profile_id}-{timestamp}.log"));
|
||||
|
||||
let request = launch::LaunchRequest {
|
||||
java_executable: Path::new(&java_install.executable),
|
||||
game_dir: &game_dir,
|
||||
profile_dir: &profile_dir,
|
||||
merged: &merged,
|
||||
identity: &identity,
|
||||
memory_mb: settings.memory_mb,
|
||||
log_path: &log_path,
|
||||
};
|
||||
let mut child = launch::launch(&request).map_err(|error| error.to_string())?;
|
||||
|
||||
let watch_app = app.clone();
|
||||
let watch_profile_id = profile_id.clone();
|
||||
std::thread::spawn(move || {
|
||||
let exit_code = child.wait().ok().and_then(|status| status.code());
|
||||
let _ = watch_app.emit(
|
||||
"game-exited",
|
||||
GameExited {
|
||||
profile_id: watch_profile_id,
|
||||
exit_code,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("Launch task failed: {error}"))?
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
use super::data_dir;
|
||||
use crate::{java, manifest};
|
||||
use serde::Serialize;
|
||||
use tauri::AppHandle;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct NativeHost {
|
||||
platform: &'static str,
|
||||
data_dir: String,
|
||||
launcher_version: &'static str,
|
||||
}
|
||||
|
||||
/// Returns non-sensitive environment information needed by the interface.
|
||||
#[tauri::command]
|
||||
pub(crate) fn native_host(app: AppHandle) -> Result<NativeHost, String> {
|
||||
let data_dir = data_dir(&app)?;
|
||||
|
||||
Ok(NativeHost {
|
||||
platform: std::env::consts::OS,
|
||||
data_dir: data_dir.display().to_string(),
|
||||
launcher_version: env!("CARGO_PKG_VERSION"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Detects an existing Java installation. This is read-only and never downloads Java.
|
||||
#[tauri::command]
|
||||
pub(crate) fn detect_java() -> Option<java::JavaInstallation> {
|
||||
java::detect()
|
||||
}
|
||||
|
||||
/// Validates an untrusted profile manifest before any file is downloaded.
|
||||
#[tauri::command]
|
||||
pub(crate) fn validate_manifest(manifest_json: String) -> Result<(), String> {
|
||||
manifest::validate_json(&manifest_json)
|
||||
.map(|_| ())
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! Thin Tauri adapters grouped by the domain they expose.
|
||||
pub(crate) mod account;
|
||||
pub(crate) mod game;
|
||||
pub(crate) mod host;
|
||||
pub(crate) mod preferences;
|
||||
pub(crate) mod profiles;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
fn data_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
app.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| format!("Cannot resolve launcher data directory: {error}"))
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
use super::data_dir;
|
||||
use crate::settings;
|
||||
use tauri::AppHandle;
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn load_settings(app: AppHandle) -> Result<settings::LauncherSettings, String> {
|
||||
let data_dir = data_dir(&app)?;
|
||||
tauri::async_runtime::spawn_blocking(move || settings::load(&data_dir))
|
||||
.await
|
||||
.map_err(|error| format!("Settings task failed: {error}"))?
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn save_settings(
|
||||
app: AppHandle,
|
||||
settings: settings::LauncherSettings,
|
||||
) -> Result<settings::LauncherSettings, String> {
|
||||
let data_dir = data_dir(&app)?;
|
||||
tauri::async_runtime::spawn_blocking(move || settings::save(&data_dir, settings))
|
||||
.await
|
||||
.map_err(|error| format!("Settings task failed: {error}"))?
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
use super::data_dir;
|
||||
use crate::{operations::LauncherOperations, profile, remote};
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
/// Loads and validates the published ShaCraft manifest before inspecting a profile.
|
||||
#[tauri::command]
|
||||
pub(crate) async fn inspect_remote_profile(
|
||||
app: AppHandle,
|
||||
profile_id: String,
|
||||
) -> Result<profile::ProfileInspection, String> {
|
||||
let data_dir = data_dir(&app)?;
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let manifest = remote::fetch_manifest(&profile_id).map_err(|error| error.to_string())?;
|
||||
profile::inspect(&data_dir.join("profiles").join(&manifest.id), &manifest)
|
||||
.map_err(|error| error.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("Profile inspection task failed: {error}"))?
|
||||
}
|
||||
|
||||
/// Downloads missing or changed ShaCraft-managed files from the fixed v2 endpoint.
|
||||
#[tauri::command]
|
||||
pub(crate) async fn sync_remote_profile(
|
||||
app: AppHandle,
|
||||
state: State<'_, LauncherOperations>,
|
||||
profile_id: String,
|
||||
) -> Result<profile::SyncResult, String> {
|
||||
let data_dir = data_dir(&app)?;
|
||||
let permit = state.installation.acquire("Installation")?;
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let _permit = permit;
|
||||
let manifest = remote::fetch_manifest(&profile_id).map_err(|error| error.to_string())?;
|
||||
profile::sync(&data_dir.join("profiles").join(&manifest.id), &manifest)
|
||||
.map_err(|error| error.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("Profile synchronization task failed: {error}"))?
|
||||
}
|
||||
+109
-31
@@ -1,11 +1,12 @@
|
||||
use reqwest::blocking::{Client, Response};
|
||||
use crate::storage::AtomicFile;
|
||||
use reqwest::blocking::Client;
|
||||
use sha1::Sha1;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
fmt,
|
||||
fs::{self, File},
|
||||
io::{self, Read, Write},
|
||||
path::{Path, PathBuf},
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
@@ -73,12 +74,19 @@ pub fn file_hashes(path: &Path) -> io::Result<(String, String)> {
|
||||
sha1.update(&buffer[..read]);
|
||||
sha256.update(&buffer[..read]);
|
||||
}
|
||||
Ok((format!("{:x}", sha1.finalize()), format!("{:x}", sha256.finalize())))
|
||||
Ok((
|
||||
format!("{:x}", sha1.finalize()),
|
||||
format!("{:x}", sha256.finalize()),
|
||||
))
|
||||
}
|
||||
|
||||
/// True if `path` already exists, matches `expected_size` (when given) and
|
||||
/// `checksum`. Used to skip re-downloading files that are already current.
|
||||
pub fn is_current(path: &Path, expected_size: Option<u64>, checksum: &Checksum) -> io::Result<bool> {
|
||||
pub fn is_current(
|
||||
path: &Path,
|
||||
expected_size: Option<u64>,
|
||||
checksum: &Checksum,
|
||||
) -> io::Result<bool> {
|
||||
let metadata = match path.metadata() {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false),
|
||||
@@ -96,14 +104,6 @@ pub fn is_current(path: &Path, expected_size: Option<u64>, checksum: &Checksum)
|
||||
Ok(checksum.matches(&sha1_hex, &sha256_hex))
|
||||
}
|
||||
|
||||
fn temp_path(target: &Path) -> Result<PathBuf, DownloadError> {
|
||||
let file_name = target
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.ok_or(DownloadError::InvalidTargetPath)?;
|
||||
Ok(target.with_file_name(format!(".{file_name}.shacraft.part")))
|
||||
}
|
||||
|
||||
/// Downloads `url` to `target`, verifying size (if known ahead of time) and
|
||||
/// `checksum` before atomically renaming the temporary file into place.
|
||||
/// `on_progress(downloaded_bytes, total_bytes)` is called after every chunk;
|
||||
@@ -126,30 +126,34 @@ pub fn download_verified(
|
||||
let total = expected_size.or_else(|| response.content_length());
|
||||
if let (Some(expected), Some(length)) = (expected_size, response.content_length()) {
|
||||
if expected != length {
|
||||
return Err(DownloadError::SizeMismatch { expected, actual: length });
|
||||
return Err(DownloadError::SizeMismatch {
|
||||
expected,
|
||||
actual: length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let temporary = temp_path(target)?;
|
||||
let result = write_and_verify(&mut response, &temporary, expected_size, checksum, total, &mut on_progress);
|
||||
if let Err(error) = result {
|
||||
let _ = fs::remove_file(&temporary);
|
||||
return Err(error);
|
||||
}
|
||||
let bytes = result.unwrap();
|
||||
fs::rename(&temporary, target).map_err(DownloadError::Io)?;
|
||||
let mut output = AtomicFile::new(target).map_err(DownloadError::Io)?;
|
||||
let bytes = write_and_verify(
|
||||
&mut response,
|
||||
output.writer(),
|
||||
expected_size,
|
||||
checksum,
|
||||
total,
|
||||
&mut on_progress,
|
||||
)?;
|
||||
output.commit().map_err(DownloadError::Io)?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn write_and_verify(
|
||||
response: &mut Response,
|
||||
temporary: &Path,
|
||||
response: &mut impl Read,
|
||||
output: &mut impl Write,
|
||||
expected_size: Option<u64>,
|
||||
checksum: &Checksum,
|
||||
total: Option<u64>,
|
||||
on_progress: &mut impl FnMut(u64, Option<u64>),
|
||||
) -> Result<u64, DownloadError> {
|
||||
let mut output = File::create(temporary).map_err(DownloadError::Io)?;
|
||||
let mut sha1 = Sha1::new();
|
||||
let mut sha256 = Sha256::new();
|
||||
let mut bytes = 0_u64;
|
||||
@@ -160,17 +164,29 @@ fn write_and_verify(
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
output.write_all(&buffer[..read]).map_err(DownloadError::Io)?;
|
||||
bytes += read as u64;
|
||||
if let Some(expected) = expected_size {
|
||||
if bytes > expected {
|
||||
return Err(DownloadError::SizeMismatch {
|
||||
expected,
|
||||
actual: bytes,
|
||||
});
|
||||
}
|
||||
}
|
||||
output
|
||||
.write_all(&buffer[..read])
|
||||
.map_err(DownloadError::Io)?;
|
||||
sha1.update(&buffer[..read]);
|
||||
sha256.update(&buffer[..read]);
|
||||
bytes += read as u64;
|
||||
on_progress(bytes, total);
|
||||
}
|
||||
output.sync_all().map_err(DownloadError::Io)?;
|
||||
|
||||
if let Some(expected) = expected_size {
|
||||
if bytes != expected {
|
||||
return Err(DownloadError::SizeMismatch { expected, actual: bytes });
|
||||
return Err(DownloadError::SizeMismatch {
|
||||
expected,
|
||||
actual: bytes,
|
||||
});
|
||||
}
|
||||
}
|
||||
let sha1_hex = format!("{:x}", sha1.finalize());
|
||||
@@ -184,13 +200,19 @@ fn write_and_verify(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{file_hashes, is_current, Checksum};
|
||||
use std::{fs, process, time::{SystemTime, UNIX_EPOCH}};
|
||||
use std::{
|
||||
fs, process,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
fn temp_file(contents: &[u8]) -> std::path::PathBuf {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"shacraft-download-test-{}-{}",
|
||||
process::id(),
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos()
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
fs::write(&path, contents).unwrap();
|
||||
path
|
||||
@@ -201,7 +223,10 @@ mod tests {
|
||||
let path = temp_file(b"hello shacraft");
|
||||
let (sha1_hex, sha256_hex) = file_hashes(&path).unwrap();
|
||||
assert_eq!(sha1_hex, "124b319646ec08b4fb2a2b65bbd21c0431b4eaf4");
|
||||
assert_eq!(sha256_hex, "d34eb8ea6396e8492109813c717f7eefd0437c10ff55a7b11949cfae900c946d");
|
||||
assert_eq!(
|
||||
sha256_hex,
|
||||
"d34eb8ea6396e8492109813c717f7eefd0437c10ff55a7b11949cfae900c946d"
|
||||
);
|
||||
fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
@@ -220,4 +245,57 @@ mod tests {
|
||||
let path = std::env::temp_dir().join("shacraft-download-test-missing-file-xyz");
|
||||
assert!(!is_current(&path, None, &Checksum::Sha256("0".repeat(64))).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_download_keeps_existing_file_and_cleans_temporary() {
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
net::TcpListener,
|
||||
};
|
||||
let path = temp_file(b"previous version");
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let url = format!("http://{}/test.jar", listener.local_addr().unwrap());
|
||||
let server = std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut request = [0_u8; 4096];
|
||||
let _ = stream.read(&mut request).unwrap();
|
||||
stream
|
||||
.write_all(
|
||||
b"HTTP/1.1 200 OK\r\nContent-Length: 7\r\nConnection: close\r\n\r\ncorrupt",
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
let error = super::download_verified(
|
||||
&reqwest::blocking::Client::new(),
|
||||
&url,
|
||||
&path,
|
||||
Some(7),
|
||||
&Checksum::Sha256("0".repeat(64)),
|
||||
|_, _| {},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, super::DownloadError::ChecksumMismatch));
|
||||
assert_eq!(fs::read(&path).unwrap(), b"previous version");
|
||||
server.join().unwrap();
|
||||
fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_body_is_stopped_before_writing_excess() {
|
||||
let mut output = Vec::new();
|
||||
let error = super::write_and_verify(
|
||||
&mut std::io::repeat(b'x'),
|
||||
&mut output,
|
||||
Some(2),
|
||||
&Checksum::Sha256("0".repeat(64)),
|
||||
Some(2),
|
||||
&mut |_, _| {},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
super::DownloadError::SizeMismatch { expected: 2, .. }
|
||||
));
|
||||
assert!(output.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
+17
-388
@@ -10,399 +10,28 @@ mod remote;
|
||||
mod runtime;
|
||||
mod session;
|
||||
mod settings;
|
||||
mod storage;
|
||||
mod trusted_http;
|
||||
|
||||
use reqwest::blocking::Client;
|
||||
use serde::Serialize;
|
||||
use std::{path::Path, sync::Arc, time::SystemTime};
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
|
||||
fn http_client() -> Client {
|
||||
Client::new()
|
||||
}
|
||||
|
||||
fn game_dir(app: &AppHandle) -> Result<std::path::PathBuf, String> {
|
||||
Ok(app.path().app_data_dir().map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?.join("game"))
|
||||
}
|
||||
|
||||
fn profile_dir(app: &AppHandle, profile_id: &str) -> Result<std::path::PathBuf, String> {
|
||||
Ok(app.path().app_data_dir().map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?.join("profiles").join(profile_id))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct NativeHost {
|
||||
platform: &'static str,
|
||||
data_dir: String,
|
||||
launcher_version: &'static str,
|
||||
}
|
||||
|
||||
/// Returns non-sensitive environment information needed by the interface.
|
||||
/// File access and child-process launching are deliberately not exposed yet.
|
||||
#[tauri::command]
|
||||
fn native_host(app: AppHandle) -> Result<NativeHost, String> {
|
||||
let data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?;
|
||||
|
||||
Ok(NativeHost {
|
||||
platform: std::env::consts::OS,
|
||||
data_dir: data_dir.display().to_string(),
|
||||
launcher_version: env!("CARGO_PKG_VERSION"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Detects an existing Java installation. This is read-only and never downloads Java.
|
||||
#[tauri::command]
|
||||
fn detect_java() -> Option<java::JavaInstallation> {
|
||||
java::detect()
|
||||
}
|
||||
|
||||
/// Validates an untrusted profile manifest before any file is downloaded.
|
||||
#[tauri::command]
|
||||
fn validate_manifest(manifest_json: String) -> Result<(), String> {
|
||||
manifest::validate_json(&manifest_json).map(|_| ()).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// Inspects the local profile without changing player files.
|
||||
#[tauri::command]
|
||||
async fn inspect_profile(app: AppHandle, manifest_json: String) -> Result<profile::ProfileInspection, String> {
|
||||
let manifest = manifest::validate_json(&manifest_json).map_err(|error| error.to_string())?;
|
||||
let root = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?
|
||||
.join("profiles")
|
||||
.join(&manifest.id);
|
||||
|
||||
tauri::async_runtime::spawn_blocking(move || profile::inspect(&root, &manifest))
|
||||
.await
|
||||
.map_err(|error| format!("Profile inspection task failed: {error}"))?
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// Synchronizes launcher-managed files after manifest validation.
|
||||
#[tauri::command]
|
||||
async fn sync_profile(app: AppHandle, manifest_json: String) -> Result<profile::SyncResult, String> {
|
||||
let manifest = manifest::validate_json(&manifest_json).map_err(|error| error.to_string())?;
|
||||
let root = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?
|
||||
.join("profiles")
|
||||
.join(&manifest.id);
|
||||
|
||||
tauri::async_runtime::spawn_blocking(move || profile::sync(&root, &manifest))
|
||||
.await
|
||||
.map_err(|error| format!("Profile synchronization task failed: {error}"))?
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// Loads and validates the published ShaCraft manifest before inspecting a profile.
|
||||
#[tauri::command]
|
||||
async fn inspect_remote_profile(app: AppHandle, profile_id: String) -> Result<profile::ProfileInspection, String> {
|
||||
let data_dir = app.path().app_data_dir()
|
||||
.map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?;
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let manifest = remote::fetch_manifest(&profile_id).map_err(|error| error.to_string())?;
|
||||
profile::inspect(&data_dir.join("profiles").join(&manifest.id), &manifest)
|
||||
.map_err(|error| error.to_string())
|
||||
}).await.map_err(|error| format!("Profile inspection task failed: {error}"))?
|
||||
}
|
||||
|
||||
/// Downloads missing or changed ShaCraft-managed files from the fixed v2 endpoint.
|
||||
#[tauri::command]
|
||||
async fn sync_remote_profile(app: AppHandle, profile_id: String) -> Result<profile::SyncResult, String> {
|
||||
let data_dir = app.path().app_data_dir()
|
||||
.map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?;
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let manifest = remote::fetch_manifest(&profile_id).map_err(|error| error.to_string())?;
|
||||
profile::sync(&data_dir.join("profiles").join(&manifest.id), &manifest)
|
||||
.map_err(|error| error.to_string())
|
||||
}).await.map_err(|error| format!("Profile synchronization task failed: {error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn load_settings(app: AppHandle) -> Result<settings::LauncherSettings, String> {
|
||||
let data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?;
|
||||
tauri::async_runtime::spawn_blocking(move || settings::load(&data_dir))
|
||||
.await
|
||||
.map_err(|error| format!("Settings task failed: {error}"))?
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn save_settings(app: AppHandle, settings: settings::LauncherSettings) -> Result<settings::LauncherSettings, String> {
|
||||
let data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?;
|
||||
tauri::async_runtime::spawn_blocking(move || settings::save(&data_dir, settings))
|
||||
.await
|
||||
.map_err(|error| format!("Settings task failed: {error}"))?
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Microsoft account login
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DeviceCodePayload {
|
||||
verification_uri: String,
|
||||
user_code: String,
|
||||
expires_in_seconds: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LoginResultPayload {
|
||||
ok: bool,
|
||||
profile: Option<msa::MinecraftProfile>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
/// Starts a Microsoft device-code login in the background. Emits
|
||||
/// `msa-login-code` as soon as the user code is available (show it to the
|
||||
/// player immediately — they have a limited time to enter it), then
|
||||
/// `msa-login-result` once sign-in finishes, fails, or times out. Returns
|
||||
/// immediately; it does not wait for the user to finish signing in.
|
||||
#[tauri::command]
|
||||
fn start_microsoft_login(app: AppHandle) -> Result<(), String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let client = http_client();
|
||||
let start = match msa::start_device_code(&client) {
|
||||
Ok(start) => start,
|
||||
Err(error) => {
|
||||
let _ = app.emit("msa-login-result", LoginResultPayload { ok: false, profile: None, error: Some(error.to_string()) });
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ = app.emit(
|
||||
"msa-login-code",
|
||||
DeviceCodePayload { verification_uri: start.verification_uri.clone(), user_code: start.user_code.clone(), expires_in_seconds: start.expires_in_seconds },
|
||||
);
|
||||
|
||||
match msa::login_with_device_code(&client, &start) {
|
||||
Ok(result) => {
|
||||
if let Ok(data_dir) = app.path().app_data_dir() {
|
||||
let _ = msa::save_refresh_token(&data_dir, &result.refresh_token);
|
||||
}
|
||||
let _ = app.emit("msa-login-result", LoginResultPayload { ok: true, profile: Some(result.profile), error: None });
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = app.emit("msa-login-result", LoginResultPayload { ok: false, profile: None, error: Some(error.to_string()) });
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tries to restore a session from a previously saved refresh token
|
||||
/// (silent, no browser/user code). Returns `None` if there is none saved
|
||||
/// or it no longer works — the UI should fall back to offering login.
|
||||
#[tauri::command]
|
||||
async fn get_account(app: AppHandle) -> Result<Option<msa::MinecraftProfile>, String> {
|
||||
let data_dir = app.path().app_data_dir().map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?;
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let Some(refresh_token) = msa::load_refresh_token(&data_dir) else { return Ok(None) };
|
||||
let client = http_client();
|
||||
match msa::login_with_refresh_token(&client, &refresh_token) {
|
||||
Ok(result) => {
|
||||
let _ = msa::save_refresh_token(&data_dir, &result.refresh_token);
|
||||
Ok(Some(result.profile))
|
||||
}
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("Account restore task failed: {error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn logout(app: AppHandle) -> Result<(), String> {
|
||||
let data_dir = app.path().app_data_dir().map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?;
|
||||
tauri::async_runtime::spawn_blocking(move || msa::clear_account(&data_dir))
|
||||
.await
|
||||
.map_err(|error| format!("Logout task failed: {error}"))?
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// Resolves the identity to launch as, based on the persisted `account_mode`.
|
||||
/// In `Microsoft` mode this requires a real signed-in session (see
|
||||
/// `msa::login_with_refresh_token`) and returns an error if there is none;
|
||||
/// in `Offline` mode it uses the local nickname from settings, so no
|
||||
/// Microsoft account is needed at all. Offline is never silently used in
|
||||
/// place of a missing Microsoft session.
|
||||
fn resolve_identity(client: &Client, data_dir: &Path) -> Result<session::PlayerIdentity, String> {
|
||||
let settings = settings::load(data_dir).map_err(|error| error.to_string())?;
|
||||
match settings.account_mode {
|
||||
settings::AccountMode::Offline => Ok(session::PlayerIdentity::Offline { name: settings.nickname }),
|
||||
settings::AccountMode::Microsoft => {
|
||||
let refresh_token = msa::load_refresh_token(data_dir).ok_or("Not signed in with a Microsoft account")?;
|
||||
let result = msa::login_with_refresh_token(client, &refresh_token).map_err(|error| error.to_string())?;
|
||||
let _ = msa::save_refresh_token(data_dir, &result.refresh_token);
|
||||
Ok(session::PlayerIdentity::Microsoft(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Game install + launch
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct InstallProgress {
|
||||
stage: &'static str,
|
||||
current_bytes: u64,
|
||||
total_bytes: u64,
|
||||
}
|
||||
|
||||
/// Resolves the vanilla + (if any) loader version JSONs for `manifest` and
|
||||
/// merges them, ensuring a Java runtime and (for NeoForge profiles) running
|
||||
/// the installer along the way. Shared by `ensure_game_installed` and
|
||||
/// `launch_game` so both always agree on exactly what "installed" means.
|
||||
/// `on_progress` is forwarded to the NeoForge installer when one runs;
|
||||
/// callers that don't display progress (e.g. `launch_game`, which only
|
||||
/// hits this after `ensure_game_installed` already installed everything)
|
||||
/// pass a no-op callback.
|
||||
fn resolve_merged_version(client: &Client, manifest: &manifest::Manifest, java_executable: &Path, game_dir: &Path, cache_dir: &Path, on_progress: &mojang::ProgressCallback) -> Result<mojang::MergedVersion, String> {
|
||||
let mojang_manifest = mojang::fetch_version_manifest(client).map_err(|error| error.to_string())?;
|
||||
let vanilla_entry = mojang::find_version(&mojang_manifest, &manifest.minecraft.version)
|
||||
.ok_or_else(|| format!("Mojang does not list Minecraft version {}", manifest.minecraft.version))?;
|
||||
let vanilla = mojang::fetch_version_json(client, vanilla_entry).map_err(|error| error.to_string())?;
|
||||
|
||||
if manifest.minecraft.loader.kind == "neoforge" {
|
||||
let neoforge_version = neoforge::ensure_client_installed(client, java_executable, game_dir, cache_dir, &manifest.minecraft.loader.version, on_progress).map_err(|error| error.to_string())?;
|
||||
mojang::merge_versions(&vanilla, Some(&neoforge_version)).map_err(|error| error.to_string())
|
||||
} else {
|
||||
mojang::merge_versions(&vanilla, None).map_err(|error| error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Downloads and installs everything needed to run `profile_id`: the
|
||||
/// exact Minecraft/loader version the ShaCraft-signed manifest specifies,
|
||||
/// a Java runtime if none is already usable, and game assets. Emits
|
||||
/// `game-install-progress` throughout with real progress for every stage:
|
||||
/// download bytes for Java, installer-confirmed library/processor counts
|
||||
/// for NeoForge, and download bytes for libraries/assets.
|
||||
#[tauri::command]
|
||||
async fn ensure_game_installed(app: AppHandle, profile_id: String) -> Result<(), String> {
|
||||
let game_dir = game_dir(&app)?;
|
||||
let runtime_root = game_dir.join("runtime");
|
||||
let cache_dir = game_dir.join("cache");
|
||||
|
||||
tauri::async_runtime::spawn_blocking(move || -> Result<(), String> {
|
||||
let client = http_client();
|
||||
let manifest = remote::fetch_manifest(&profile_id).map_err(|error| error.to_string())?;
|
||||
|
||||
let stage_progress = |stage: &'static str| -> mojang::ProgressCallback {
|
||||
let app = app.clone();
|
||||
Arc::new(move |current, total| {
|
||||
let _ = app.emit("game-install-progress", InstallProgress { stage, current_bytes: current, total_bytes: total });
|
||||
})
|
||||
};
|
||||
|
||||
let java_install = java::ensure_java(&client, &runtime_root, manifest.minecraft.java_major, &stage_progress("java")).map_err(|error| error.to_string())?;
|
||||
|
||||
let merged = resolve_merged_version(&client, &manifest, Path::new(&java_install.executable), &game_dir, &cache_dir, &stage_progress("neoforge"))?;
|
||||
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())?;
|
||||
};
|
||||
|
||||
let asset_index = mojang::ensure_asset_index(&client, &game_dir, &merged.asset_index).map_err(|error| error.to_string())?;
|
||||
mojang::ensure_assets(&client, &game_dir, &asset_index, &stage_progress("assets")).map_err(|error| error.to_string())?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("Install task failed: {error}"))?
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GameExited {
|
||||
profile_id: String,
|
||||
exit_code: Option<i32>,
|
||||
}
|
||||
|
||||
/// Launches `profile_id` as the account chosen in settings (`account_mode`).
|
||||
/// In `Microsoft` mode a real session is required (see `resolve_identity`);
|
||||
/// in `Offline` mode the local nickname from settings is used, so no
|
||||
/// Microsoft account is needed. Spawns the game detached; watches it on a
|
||||
/// background thread only to emit `game-exited` when it eventually closes.
|
||||
#[tauri::command]
|
||||
async fn launch_game(app: AppHandle, profile_id: String) -> Result<(), String> {
|
||||
let game_dir = game_dir(&app)?;
|
||||
let profile_dir = profile_dir(&app, &profile_id)?;
|
||||
let data_dir = app.path().app_data_dir().map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?;
|
||||
|
||||
tauri::async_runtime::spawn_blocking(move || -> Result<(), String> {
|
||||
let client = http_client();
|
||||
let identity = resolve_identity(&client, &data_dir)?;
|
||||
let manifest = remote::fetch_manifest(&profile_id).map_err(|error| error.to_string())?;
|
||||
let settings = settings::load(&data_dir).map_err(|error| error.to_string())?;
|
||||
|
||||
// Everything here should already be installed by `ensure_game_installed`,
|
||||
// so these are expected to hit their fast paths; no progress to show.
|
||||
let no_progress: mojang::ProgressCallback = Arc::new(|_, _| {});
|
||||
let java_install = java::ensure_java(&client, &game_dir.join("runtime"), manifest.minecraft.java_major, &no_progress).map_err(|error| error.to_string())?;
|
||||
let merged = resolve_merged_version(&client, &manifest, Path::new(&java_install.executable), &game_dir, &game_dir.join("cache"), &no_progress)?;
|
||||
|
||||
let timestamp = SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||
let log_dir = data_dir.join("logs");
|
||||
std::fs::create_dir_all(&log_dir).map_err(|error| error.to_string())?;
|
||||
let log_path = log_dir.join(format!("{profile_id}-{timestamp}.log"));
|
||||
|
||||
let request = launch::LaunchRequest {
|
||||
java_executable: Path::new(&java_install.executable),
|
||||
game_dir: &game_dir,
|
||||
profile_dir: &profile_dir,
|
||||
merged: &merged,
|
||||
identity: &identity,
|
||||
memory_mb: settings.memory_mb,
|
||||
log_path: &log_path,
|
||||
};
|
||||
let mut child = launch::launch(&request).map_err(|error| error.to_string())?;
|
||||
|
||||
let watch_app = app.clone();
|
||||
let watch_profile_id = profile_id.clone();
|
||||
std::thread::spawn(move || {
|
||||
let exit_code = child.wait().ok().and_then(|status| status.code());
|
||||
let _ = watch_app.emit("game-exited", GameExited { profile_id: watch_profile_id, exit_code });
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("Launch task failed: {error}"))?
|
||||
}
|
||||
mod commands;
|
||||
mod operations;
|
||||
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.manage(operations::LauncherOperations::default())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
native_host,
|
||||
detect_java,
|
||||
validate_manifest,
|
||||
inspect_profile,
|
||||
sync_profile,
|
||||
inspect_remote_profile,
|
||||
sync_remote_profile,
|
||||
load_settings,
|
||||
save_settings,
|
||||
start_microsoft_login,
|
||||
get_account,
|
||||
logout,
|
||||
ensure_game_installed,
|
||||
launch_game
|
||||
commands::host::native_host,
|
||||
commands::host::detect_java,
|
||||
commands::host::validate_manifest,
|
||||
commands::profiles::inspect_remote_profile,
|
||||
commands::profiles::sync_remote_profile,
|
||||
commands::preferences::load_settings,
|
||||
commands::preferences::save_settings,
|
||||
commands::account::start_microsoft_login,
|
||||
commands::account::get_account,
|
||||
commands::account::logout,
|
||||
commands::game::ensure_game_installed,
|
||||
commands::game::launch_game
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running ShaCraft Launcher");
|
||||
|
||||
+104
-23
@@ -1,6 +1,5 @@
|
||||
use serde::Deserialize;
|
||||
use std::{collections::HashSet, fmt};
|
||||
use url::Url;
|
||||
|
||||
const MAX_MANIFEST_BYTES: usize = 2 * 1024 * 1024;
|
||||
const CURRENT_SCHEMA_VERSION: u32 = 1;
|
||||
@@ -82,19 +81,27 @@ fn validate(manifest: &Manifest) -> Result<(), ManifestError> {
|
||||
)));
|
||||
}
|
||||
if !is_identifier(&manifest.id) {
|
||||
return Err(ManifestError::Invalid("Profile id must contain only lowercase letters, numbers and hyphens".into()));
|
||||
return Err(ManifestError::Invalid(
|
||||
"Profile id must contain only lowercase letters, numbers and hyphens".into(),
|
||||
));
|
||||
}
|
||||
if manifest.display_name.trim().is_empty() {
|
||||
return Err(ManifestError::Invalid("Profile displayName cannot be empty".into()));
|
||||
return Err(ManifestError::Invalid(
|
||||
"Profile displayName cannot be empty".into(),
|
||||
));
|
||||
}
|
||||
if manifest.minecraft.version.trim().is_empty()
|
||||
if !is_version(&manifest.minecraft.version)
|
||||
|| manifest.minecraft.loader.kind.trim().is_empty()
|
||||
|| manifest.minecraft.loader.version.trim().is_empty()
|
||||
|| !is_version(&manifest.minecraft.loader.version)
|
||||
{
|
||||
return Err(ManifestError::Invalid("Minecraft version and loader must be specified".into()));
|
||||
return Err(ManifestError::Invalid(
|
||||
"Minecraft version and loader must be specified".into(),
|
||||
));
|
||||
}
|
||||
if !(8..=25).contains(&manifest.minecraft.java_major) {
|
||||
return Err(ManifestError::Invalid("Unsupported Java major version".into()));
|
||||
return Err(ManifestError::Invalid(
|
||||
"Unsupported Java major version".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut paths = HashSet::new();
|
||||
@@ -103,19 +110,35 @@ fn validate(manifest: &Manifest) -> Result<(), ManifestError> {
|
||||
FilePolicy::Managed | FilePolicy::Seed => {}
|
||||
}
|
||||
if !is_safe_relative_path(&file.path) {
|
||||
return Err(ManifestError::Invalid(format!("Unsafe file path: {}", file.path)));
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"Unsafe file path: {}",
|
||||
file.path
|
||||
)));
|
||||
}
|
||||
if !paths.insert(&file.path) {
|
||||
return Err(ManifestError::Invalid(format!("Duplicate file path: {}", file.path)));
|
||||
// A manifest must resolve to the same distinct files on Windows/macOS.
|
||||
if !paths.insert(file.path.to_lowercase()) {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"Duplicate file path: {}",
|
||||
file.path
|
||||
)));
|
||||
}
|
||||
if !is_allowed_download_url(&file.url) {
|
||||
return Err(ManifestError::Invalid(format!("File URL must use HTTPS and a ShaCraft host: {}", file.path)));
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"File URL must use HTTPS and a ShaCraft host: {}",
|
||||
file.path
|
||||
)));
|
||||
}
|
||||
if file.size == 0 {
|
||||
return Err(ManifestError::Invalid(format!("File has zero size: {}", file.path)));
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"File has zero size: {}",
|
||||
file.path
|
||||
)));
|
||||
}
|
||||
if file.sha256.len() != 64 || !file.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||||
return Err(ManifestError::Invalid(format!("Invalid SHA-256 for {}", file.path)));
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"Invalid SHA-256 for {}",
|
||||
file.path
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -124,22 +147,49 @@ fn validate(manifest: &Manifest) -> Result<(), ManifestError> {
|
||||
fn is_identifier(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 48
|
||||
&& value.bytes().all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
|
||||
}
|
||||
|
||||
fn is_version(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 128
|
||||
&& value != "."
|
||||
&& value != ".."
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || b".-_".contains(&byte))
|
||||
}
|
||||
|
||||
fn is_safe_relative_path(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& !value.starts_with('/')
|
||||
&& !value.starts_with('\\')
|
||||
&& !value.contains('\\')
|
||||
&& !value.split('/').any(|part| part.is_empty() || part == "." || part == "..")
|
||||
!value.is_empty() && value.split('/').all(is_portable_component)
|
||||
}
|
||||
|
||||
pub(crate) fn is_portable_component(value: &str) -> bool {
|
||||
if value.is_empty()
|
||||
|| value.ends_with(['.', ' '])
|
||||
|| value
|
||||
.chars()
|
||||
.any(|ch| ch.is_control() || "\\:<>\"|?*".contains(ch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let stem = value
|
||||
.split('.')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_ascii_uppercase();
|
||||
!matches!(
|
||||
stem.as_str(),
|
||||
"CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$"
|
||||
) && !(stem.len() == 4
|
||||
&& (stem.starts_with("COM") || stem.starts_with("LPT"))
|
||||
&& matches!(stem.as_bytes()[3], b'1'..=b'9'))
|
||||
}
|
||||
|
||||
pub(crate) fn is_allowed_download_url(value: &str) -> bool {
|
||||
let Ok(url) = Url::parse(value) else {
|
||||
return false;
|
||||
};
|
||||
url.scheme() == "https" && url.host_str().is_some_and(|host| DOWNLOAD_HOSTS.contains(&host))
|
||||
crate::trusted_http::allows(value, &DOWNLOAD_HOSTS)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -179,4 +229,35 @@ mod tests {
|
||||
fn rejects_third_party_download_hosts() {
|
||||
assert!(validate_json(&VALID.replace("cdn.shacraft.ru", "example.com")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_nonportable_paths_and_version_traversal() {
|
||||
for path in [
|
||||
"C:/escape.jar",
|
||||
"mods/file.jar:stream",
|
||||
"mods/CON.jar",
|
||||
"mods/LPT1",
|
||||
"mods/file.jar.",
|
||||
"mods/file.jar ",
|
||||
"mods//file.jar",
|
||||
"mods/../file.jar",
|
||||
] {
|
||||
assert!(
|
||||
validate_json(&VALID.replace("mods/example.jar", path)).is_err(),
|
||||
"{path}"
|
||||
);
|
||||
}
|
||||
assert!(validate_json(&VALID.replace("21.1.248", "../../escape")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_ambiguous_download_authorities() {
|
||||
for host in [
|
||||
"user@cdn.shacraft.ru",
|
||||
"cdn.shacraft.ru:444",
|
||||
"cdn.shacraft.ru.evil.example",
|
||||
] {
|
||||
assert!(validate_json(&VALID.replace("cdn.shacraft.ru", host)).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ use std::{
|
||||
Arc, Mutex,
|
||||
},
|
||||
};
|
||||
use url::Url;
|
||||
|
||||
const VERSION_MANIFEST_URL: &str = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json";
|
||||
const MOJANG_HOSTS: [&str; 4] = [
|
||||
@@ -41,7 +40,11 @@ const MOJANG_HOSTS: [&str; 4] = [
|
||||
const ASSET_WORKERS: usize = 48;
|
||||
|
||||
pub fn is_allowed_host(url: &str) -> bool {
|
||||
Url::parse(url).ok().and_then(|parsed| parsed.host_str().map(|host| MOJANG_HOSTS.contains(&host))).unwrap_or(false)
|
||||
crate::trusted_http::allows(url, &MOJANG_HOSTS)
|
||||
}
|
||||
|
||||
pub fn http_client() -> Result<Client, reqwest::Error> {
|
||||
crate::trusted_http::client(&MOJANG_HOSTS, std::time::Duration::from_secs(10 * 60))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -45,6 +45,13 @@ const MINECRAFT_LOGIN_URL: &str = "https://api.minecraftservices.com/authenticat
|
||||
const MINECRAFT_PROFILE_URL: &str = "https://api.minecraftservices.com/minecraft/profile";
|
||||
const ACCOUNT_FILE: &str = "account.json";
|
||||
|
||||
pub fn http_client() -> Result<Client, reqwest::Error> {
|
||||
crate::trusted_http::client(&[
|
||||
"login.microsoftonline.com", "user.auth.xboxlive.com",
|
||||
"xsts.auth.xboxlive.com", "api.minecraftservices.com",
|
||||
], Duration::from_secs(30))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum MsaError {
|
||||
NotConfigured,
|
||||
@@ -405,14 +412,7 @@ pub fn save_refresh_token(data_dir: &Path, refresh_token: &str) -> io::Result<()
|
||||
let contents = serde_json::to_vec_pretty(&StoredAccount { refresh_token: refresh_token.to_string(), saved_at_unix }).expect("StoredAccount is serializable");
|
||||
|
||||
let target = data_dir.join(ACCOUNT_FILE);
|
||||
let temporary = data_dir.join(".account.json.shacraft.part");
|
||||
fs::write(&temporary, contents)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
fs::rename(temporary, target)
|
||||
crate::storage::write_atomic(&target, &contents)
|
||||
}
|
||||
|
||||
pub fn load_refresh_token(data_dir: &Path) -> Option<String> {
|
||||
|
||||
@@ -39,7 +39,6 @@ use std::{
|
||||
},
|
||||
thread,
|
||||
};
|
||||
use url::Url;
|
||||
|
||||
const NEOFORGE_HOST: &str = "maven.neoforged.net";
|
||||
|
||||
@@ -89,7 +88,11 @@ impl From<io::Error> for NeoForgeError {
|
||||
}
|
||||
|
||||
fn is_allowed_host(url: &str) -> bool {
|
||||
Url::parse(url).ok().and_then(|parsed| parsed.host_str().map(|host| host == NEOFORGE_HOST)).unwrap_or(false)
|
||||
crate::trusted_http::allows(url, &[NEOFORGE_HOST])
|
||||
}
|
||||
|
||||
pub fn http_client() -> Result<Client, reqwest::Error> {
|
||||
crate::trusted_http::client(&[NEOFORGE_HOST], std::time::Duration::from_secs(10 * 60))
|
||||
}
|
||||
|
||||
fn installer_jar_url(loader_version: &str) -> String {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Process-local exclusion for operations that share installation/account files.
|
||||
//!
|
||||
//! Acquire before scheduling the worker and move the permit into it. Dropping
|
||||
//! the caller's future cannot unlock an operation that is still running.
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct LauncherOperations {
|
||||
pub installation: Operation,
|
||||
pub account: Operation,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct Operation(Arc<AtomicBool>);
|
||||
|
||||
impl Operation {
|
||||
pub fn acquire(&self, label: &str) -> Result<Permit, String> {
|
||||
self.0
|
||||
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
|
||||
.map_err(|_| format!("{label} is already in progress; wait for it to finish"))?;
|
||||
Ok(Permit(self.0.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct Permit(Arc<AtomicBool>);
|
||||
|
||||
impl Drop for Permit {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(false, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Operation;
|
||||
|
||||
#[test]
|
||||
fn rejects_overlap_and_releases_on_worker_error() {
|
||||
let operation = Operation::default();
|
||||
let worker = || -> Result<(), String> {
|
||||
let _permit = operation.acquire("Installation")?;
|
||||
assert!(operation.acquire("Installation").is_err());
|
||||
Err("simulated worker failure".into())
|
||||
};
|
||||
assert!(worker().is_err());
|
||||
assert!(operation.acquire("Installation").is_ok());
|
||||
}
|
||||
}
|
||||
+126
-15
@@ -2,7 +2,11 @@ use crate::download::{self, Checksum, DownloadError};
|
||||
use crate::manifest::{is_allowed_download_url, FilePolicy, ManagedFile, Manifest};
|
||||
use reqwest::{blocking::Client, redirect::Policy};
|
||||
use serde::Serialize;
|
||||
use std::{fmt, io, path::Path};
|
||||
use std::{
|
||||
fmt, fs, io,
|
||||
path::{Path, PathBuf},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -28,14 +32,22 @@ pub enum ProfileError {
|
||||
Io(io::Error),
|
||||
Network(reqwest::Error),
|
||||
Download { path: String, source: DownloadError },
|
||||
UnsafePath(PathBuf),
|
||||
}
|
||||
|
||||
impl fmt::Display for ProfileError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Io(error) => write!(formatter, "Cannot inspect profile: {error}"),
|
||||
Self::Io(error) => write!(formatter, "Cannot access profile: {error}"),
|
||||
Self::Network(error) => write!(formatter, "Cannot download profile file: {error}"),
|
||||
Self::Download { path, source } => write!(formatter, "Download failed for {path}: {source}"),
|
||||
Self::Download { path, source } => {
|
||||
write!(formatter, "Download failed for {path}: {source}")
|
||||
}
|
||||
Self::UnsafePath(path) => write!(
|
||||
formatter,
|
||||
"Profile path contains a symbolic link: {}",
|
||||
path.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,11 +57,14 @@ pub fn inspect(root: &Path, manifest: &Manifest) -> Result<ProfileInspection, Pr
|
||||
let mut mismatched_files = 0;
|
||||
|
||||
for expected in &manifest.files {
|
||||
let path = root.join(&expected.path);
|
||||
if !path.exists() {
|
||||
let path = managed_target(root, &expected.path)?;
|
||||
if !path.try_exists().map_err(ProfileError::Io)? {
|
||||
missing_files += 1;
|
||||
continue;
|
||||
}
|
||||
if matches!(expected.policy, FilePolicy::Seed) && path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let checksum = Checksum::Sha256(expected.sha256.clone());
|
||||
if !download::is_current(&path, Some(expected.size), &checksum).map_err(ProfileError::Io)? {
|
||||
mismatched_files += 1;
|
||||
@@ -67,8 +82,12 @@ pub fn inspect(root: &Path, manifest: &Manifest) -> Result<ProfileInspection, Pr
|
||||
|
||||
pub fn sync(root: &Path, manifest: &Manifest) -> Result<SyncResult, ProfileError> {
|
||||
let client = Client::builder()
|
||||
.connect_timeout(Duration::from_secs(15))
|
||||
.timeout(Duration::from_secs(10 * 60))
|
||||
.redirect(Policy::custom(|attempt| {
|
||||
if is_allowed_download_url(attempt.url().as_str()) {
|
||||
if attempt.previous().len() >= 10 {
|
||||
attempt.error("too many redirects")
|
||||
} else if is_allowed_download_url(attempt.url().as_str()) {
|
||||
attempt.follow()
|
||||
} else {
|
||||
attempt.stop()
|
||||
@@ -82,13 +101,15 @@ pub fn sync(root: &Path, manifest: &Manifest) -> Result<SyncResult, ProfileError
|
||||
let mut downloaded_bytes = 0;
|
||||
|
||||
for expected in &manifest.files {
|
||||
let target = root.join(&expected.path);
|
||||
if matches!(expected.policy, FilePolicy::Seed) && target.exists() {
|
||||
let target = managed_target(root, &expected.path)?;
|
||||
if matches!(expected.policy, FilePolicy::Seed) && target.is_file() {
|
||||
reused_files += 1;
|
||||
continue;
|
||||
}
|
||||
let checksum = Checksum::Sha256(expected.sha256.clone());
|
||||
if download::is_current(&target, Some(expected.size), &checksum).map_err(ProfileError::Io)? {
|
||||
if download::is_current(&target, Some(expected.size), &checksum)
|
||||
.map_err(ProfileError::Io)?
|
||||
{
|
||||
reused_files += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -106,10 +127,51 @@ pub fn sync(root: &Path, manifest: &Manifest) -> Result<SyncResult, ProfileError
|
||||
})
|
||||
}
|
||||
|
||||
fn download_managed_file(client: &Client, expected: &ManagedFile, target: &Path) -> Result<u64, ProfileError> {
|
||||
/// Reject pre-existing links in the managed subtree before inspecting or
|
||||
/// replacing files. A signed relative path must not follow a local link into
|
||||
/// an unrelated directory. This is not a sandbox against a hostile local user
|
||||
/// changing directories concurrently under the launcher's OS identity.
|
||||
fn managed_target(root: &Path, relative: &str) -> Result<PathBuf, ProfileError> {
|
||||
let mut path = root.to_path_buf();
|
||||
if let Some(parent) = root.parent() {
|
||||
reject_symlink(parent)?;
|
||||
}
|
||||
reject_symlink(&path)?;
|
||||
for component in relative.split('/') {
|
||||
path.push(component);
|
||||
reject_symlink(&path)?;
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn reject_symlink(path: &Path) -> Result<(), ProfileError> {
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||||
Err(ProfileError::UnsafePath(path.to_path_buf()))
|
||||
}
|
||||
Ok(_) => Ok(()),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(ProfileError::Io(error)),
|
||||
}
|
||||
}
|
||||
|
||||
fn download_managed_file(
|
||||
client: &Client,
|
||||
expected: &ManagedFile,
|
||||
target: &Path,
|
||||
) -> Result<u64, ProfileError> {
|
||||
let checksum = Checksum::Sha256(expected.sha256.clone());
|
||||
download::download_verified(client, &expected.url, target, Some(expected.size), &checksum, |_, _| {}).map_err(|error| {
|
||||
ProfileError::Download { path: expected.path.clone(), source: error }
|
||||
download::download_verified(
|
||||
client,
|
||||
&expected.url,
|
||||
target,
|
||||
Some(expected.size),
|
||||
&checksum,
|
||||
|_, _| {},
|
||||
)
|
||||
.map_err(|error| ProfileError::Download {
|
||||
path: expected.path.clone(),
|
||||
source: error,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -118,7 +180,10 @@ mod tests {
|
||||
use super::inspect;
|
||||
use crate::manifest::{FilePolicy, Loader, ManagedFile, Manifest, Minecraft};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{fs, process, time::{SystemTime, UNIX_EPOCH}};
|
||||
use std::{
|
||||
fs, process,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
fn manifest(hash: String, size: u64) -> Manifest {
|
||||
Manifest {
|
||||
@@ -127,7 +192,10 @@ mod tests {
|
||||
display_name: "Aeronautics".into(),
|
||||
minecraft: Minecraft {
|
||||
version: "1.21.1".into(),
|
||||
loader: Loader { kind: "neoforge".into(), version: "21.1.248".into() },
|
||||
loader: Loader {
|
||||
kind: "neoforge".into(),
|
||||
version: "21.1.248".into(),
|
||||
},
|
||||
java_major: 21,
|
||||
},
|
||||
files: vec![ManagedFile {
|
||||
@@ -145,7 +213,10 @@ mod tests {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"shacraft-launcher-test-{}-{}",
|
||||
process::id(),
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos()
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
let bytes = b"ShaCraft test file";
|
||||
let digest = format!("{:x}", Sha256::digest(bytes));
|
||||
@@ -164,4 +235,44 @@ mod tests {
|
||||
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edited_seed_files_remain_up_to_date() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"shacraft-seed-test-{}-{}",
|
||||
process::id(),
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
fs::create_dir_all(root.join("mods")).unwrap();
|
||||
fs::write(root.join("mods/example.jar"), b"player's edits").unwrap();
|
||||
let mut expected = manifest("0".repeat(64), 42);
|
||||
expected.files[0].policy = FilePolicy::Seed;
|
||||
assert!(inspect(&root, &expected).unwrap().up_to_date);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn refuses_linked_profile_directories() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"shacraft-link-test-{}-{}",
|
||||
process::id(),
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
fs::create_dir_all(root.join("outside")).unwrap();
|
||||
fs::create_dir_all(root.join("profile")).unwrap();
|
||||
symlink(root.join("outside"), root.join("profile/mods")).unwrap();
|
||||
assert!(matches!(
|
||||
inspect(&root.join("profile"), &manifest("0".repeat(64), 42)),
|
||||
Err(super::ProfileError::UnsafePath(_))
|
||||
));
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
+155
-15
@@ -1,13 +1,20 @@
|
||||
use crate::manifest::{self, Manifest};
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
||||
use ed25519_dalek::{Signature, VerifyingKey};
|
||||
use reqwest::{blocking::Client, redirect::Policy};
|
||||
use serde::Deserialize;
|
||||
use std::{fmt, time::Duration};
|
||||
use std::{
|
||||
fmt,
|
||||
io::{self, Read},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
const AERONAUTICS_MANIFEST: &str =
|
||||
"https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest";
|
||||
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;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -24,7 +31,9 @@ pub enum RemoteError {
|
||||
Network(reqwest::Error),
|
||||
Status(reqwest::StatusCode),
|
||||
TooLarge,
|
||||
Read(io::Error),
|
||||
InvalidSignature,
|
||||
ProfileMismatch,
|
||||
InvalidManifest(manifest::ManifestError),
|
||||
}
|
||||
|
||||
@@ -35,8 +44,14 @@ impl fmt::Display for RemoteError {
|
||||
Self::Network(error) => write!(formatter, "Cannot load ShaCraft manifest: {error}"),
|
||||
Self::Status(status) => write!(formatter, "ShaCraft manifest request failed: {status}"),
|
||||
Self::TooLarge => formatter.write_str("ShaCraft manifest is too large"),
|
||||
Self::Read(error) => write!(formatter, "Cannot read ShaCraft manifest: {error}"),
|
||||
Self::InvalidSignature => formatter.write_str("ShaCraft manifest signature is invalid"),
|
||||
Self::InvalidManifest(error) => write!(formatter, "ShaCraft manifest is invalid: {error}"),
|
||||
Self::ProfileMismatch => {
|
||||
formatter.write_str("Signed manifest does not match the requested profile")
|
||||
}
|
||||
Self::InvalidManifest(error) => {
|
||||
write!(formatter, "ShaCraft manifest is invalid: {error}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,22 +70,147 @@ pub fn fetch_manifest(profile_id: &str) -> Result<Manifest, RemoteError> {
|
||||
if !response.status().is_success() {
|
||||
return Err(RemoteError::Status(response.status()));
|
||||
}
|
||||
if response.content_length().is_some_and(|size| size > 2 * 1024 * 1024) {
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|size| size > MAX_ENVELOPE_BYTES as u64)
|
||||
{
|
||||
return Err(RemoteError::TooLarge);
|
||||
}
|
||||
let source = response.text().map_err(RemoteError::Network)?;
|
||||
let envelope = serde_json::from_str::<SignedManifest>(&source)
|
||||
let source = read_envelope(response)?;
|
||||
let public_key_bytes = STANDARD
|
||||
.decode(MANIFEST_PUBLIC_KEY)
|
||||
.expect("embedded public key must be valid");
|
||||
let public_key = VerifyingKey::from_bytes(
|
||||
&public_key_bytes
|
||||
.try_into()
|
||||
.expect("embedded public key must be 32 bytes"),
|
||||
)
|
||||
.expect("embedded public key must be valid");
|
||||
verify_envelope(&source, profile_id, &public_key)
|
||||
}
|
||||
|
||||
fn read_envelope(source: impl Read) -> Result<Vec<u8>, RemoteError> {
|
||||
let mut bytes = Vec::new();
|
||||
source
|
||||
.take(MAX_ENVELOPE_BYTES as u64 + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(RemoteError::Read)?;
|
||||
if bytes.len() > MAX_ENVELOPE_BYTES {
|
||||
return Err(RemoteError::TooLarge);
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn verify_envelope(
|
||||
source: &[u8],
|
||||
profile_id: &str,
|
||||
public_key: &VerifyingKey,
|
||||
) -> Result<Manifest, RemoteError> {
|
||||
if source.len() > MAX_ENVELOPE_BYTES {
|
||||
return Err(RemoteError::TooLarge);
|
||||
}
|
||||
let envelope = serde_json::from_slice::<SignedManifest>(source)
|
||||
.map_err(|_| RemoteError::InvalidSignature)?;
|
||||
if envelope.schema_version != 1 || envelope.key_id != "2026-09-06" {
|
||||
if envelope.schema_version != 1 || envelope.key_id != MANIFEST_KEY_ID {
|
||||
return Err(RemoteError::InvalidSignature);
|
||||
}
|
||||
let payload = STANDARD.decode(envelope.payload).map_err(|_| RemoteError::InvalidSignature)?;
|
||||
let signature_bytes = STANDARD.decode(envelope.signature).map_err(|_| RemoteError::InvalidSignature)?;
|
||||
let public_key_bytes = STANDARD.decode(MANIFEST_PUBLIC_KEY).expect("embedded public key must be valid");
|
||||
let public_key = VerifyingKey::from_bytes(&public_key_bytes.try_into().expect("embedded public key must be 32 bytes"))
|
||||
.expect("embedded public key must be valid");
|
||||
let signature = Signature::from_slice(&signature_bytes).map_err(|_| RemoteError::InvalidSignature)?;
|
||||
public_key.verify(&payload, &signature).map_err(|_| RemoteError::InvalidSignature)?;
|
||||
let payload = STANDARD
|
||||
.decode(envelope.payload)
|
||||
.map_err(|_| RemoteError::InvalidSignature)?;
|
||||
let signature_bytes = STANDARD
|
||||
.decode(envelope.signature)
|
||||
.map_err(|_| RemoteError::InvalidSignature)?;
|
||||
let signature =
|
||||
Signature::from_slice(&signature_bytes).map_err(|_| RemoteError::InvalidSignature)?;
|
||||
public_key
|
||||
.verify_strict(&payload, &signature)
|
||||
.map_err(|_| RemoteError::InvalidSignature)?;
|
||||
let payload = String::from_utf8(payload).map_err(|_| RemoteError::InvalidSignature)?;
|
||||
manifest::validate_json(&payload).map_err(RemoteError::InvalidManifest)
|
||||
let manifest = manifest::validate_json(&payload).map_err(RemoteError::InvalidManifest)?;
|
||||
if manifest.id != profile_id {
|
||||
return Err(RemoteError::ProfileMismatch);
|
||||
}
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[test]
|
||||
#[ignore = "read-only check of the production signed manifest; requires network"]
|
||||
fn live_validates_production_aeronautics_manifest() {
|
||||
let manifest = fetch_manifest("aeronautics").unwrap();
|
||||
assert_eq!(manifest.id, "aeronautics");
|
||||
assert!(!manifest.files.is_empty());
|
||||
}
|
||||
|
||||
fn signed_fixture() -> (Value, VerifyingKey) {
|
||||
let key = SigningKey::from_bytes(&[17; 32]);
|
||||
let payload = serde_json::to_vec(&json!({
|
||||
"schemaVersion": 1, "id": "aeronautics", "displayName": "Aeronautics",
|
||||
"minecraft": {"version": "1.21.1", "loader": {"kind": "neoforge", "version": "21.1.248"}, "javaMajor": 21},
|
||||
"files": []
|
||||
})).unwrap();
|
||||
let envelope = json!({
|
||||
"schemaVersion": 1, "keyId": MANIFEST_KEY_ID,
|
||||
"payload": STANDARD.encode(&payload),
|
||||
"signature": STANDARD.encode(key.sign(&payload).to_bytes())
|
||||
});
|
||||
(envelope, key.verifying_key())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_valid_signature_and_binds_requested_profile() {
|
||||
let (envelope, key) = signed_fixture();
|
||||
let bytes = serde_json::to_vec(&envelope).unwrap();
|
||||
assert_eq!(
|
||||
verify_envelope(&bytes, "aeronautics", &key).unwrap().id,
|
||||
"aeronautics"
|
||||
);
|
||||
assert!(matches!(
|
||||
verify_envelope(&bytes, "another-profile", &key),
|
||||
Err(RemoteError::ProfileMismatch)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_modified_payload_signature_key_and_schema() {
|
||||
let (original, key) = signed_fixture();
|
||||
for (field, value) in [
|
||||
("payload", json!(STANDARD.encode(b"{}"))),
|
||||
("signature", json!(STANDARD.encode([0; 64]))),
|
||||
("keyId", json!("unknown")),
|
||||
("schemaVersion", json!(2)),
|
||||
] {
|
||||
let mut envelope = original.clone();
|
||||
envelope[field] = value;
|
||||
assert!(
|
||||
matches!(
|
||||
verify_envelope(&serde_json::to_vec(&envelope).unwrap(), "aeronautics", &key),
|
||||
Err(RemoteError::InvalidSignature)
|
||||
),
|
||||
"{field}"
|
||||
);
|
||||
}
|
||||
let other_key = SigningKey::from_bytes(&[18; 32]).verifying_key();
|
||||
assert!(matches!(
|
||||
verify_envelope(
|
||||
&serde_json::to_vec(&original).unwrap(),
|
||||
"aeronautics",
|
||||
&other_key
|
||||
),
|
||||
Err(RemoteError::InvalidSignature)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_stream_without_content_length() {
|
||||
assert!(matches!(
|
||||
read_envelope(io::repeat(b'x')),
|
||||
Err(RemoteError::TooLarge)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,12 +12,18 @@ use serde::Deserialize;
|
||||
use std::{fmt, fs, io, path::{Path, PathBuf}};
|
||||
|
||||
const ADOPTIUM_HOST: &str = "api.adoptium.net";
|
||||
const RUNTIME_HOSTS: [&str; 4] = [ADOPTIUM_HOST, "github.com", "objects.githubusercontent.com", "release-assets.githubusercontent.com"];
|
||||
|
||||
pub fn http_client() -> Result<Client, reqwest::Error> {
|
||||
crate::trusted_http::client(&RUNTIME_HOSTS, std::time::Duration::from_secs(10 * 60))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RuntimeError {
|
||||
Network(reqwest::Error),
|
||||
HttpStatus(reqwest::StatusCode),
|
||||
NoRelease,
|
||||
UntrustedPackage,
|
||||
UnexpectedArchiveLayout,
|
||||
Download(DownloadError),
|
||||
Io(io::Error),
|
||||
@@ -30,6 +36,7 @@ impl fmt::Display for RuntimeError {
|
||||
Self::Network(error) => write!(formatter, "network error: {error}"),
|
||||
Self::HttpStatus(status) => write!(formatter, "Adoptium returned {status}"),
|
||||
Self::NoRelease => formatter.write_str("Adoptium has no matching JRE release for this platform"),
|
||||
Self::UntrustedPackage => formatter.write_str("Adoptium package has an unsafe archive name, URL or checksum"),
|
||||
Self::UnexpectedArchiveLayout => formatter.write_str("Java archive did not contain a single top-level directory as expected"),
|
||||
Self::Download(error) => write!(formatter, "{error}"),
|
||||
Self::Io(error) => write!(formatter, "I/O error: {error}"),
|
||||
@@ -76,6 +83,18 @@ struct AdoptiumPackage {
|
||||
name: String,
|
||||
}
|
||||
|
||||
fn validate_package(package: &AdoptiumPackage) -> Result<(), RuntimeError> {
|
||||
if !crate::manifest::is_portable_component(&package.name)
|
||||
|| package.name.contains('/')
|
||||
|| !(package.name.ends_with(".tar.gz") || package.name.ends_with(".zip"))
|
||||
|| !crate::trusted_http::allows(&package.link, &RUNTIME_HOSTS)
|
||||
|| package.checksum.len() != 64
|
||||
|| !package.checksum.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||||
return Err(RuntimeError::UntrustedPackage);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn adoptium_os() -> &'static str {
|
||||
if cfg!(target_os = "windows") {
|
||||
"windows"
|
||||
@@ -138,6 +157,7 @@ pub fn ensure_runtime(client: &Client, runtime_root: &Path, major: u8, on_progre
|
||||
}
|
||||
let assets: Vec<AdoptiumAsset> = response.json()?;
|
||||
let package = assets.into_iter().next().map(|asset| asset.binary.package).ok_or(RuntimeError::NoRelease)?;
|
||||
validate_package(&package)?;
|
||||
|
||||
fs::create_dir_all(runtime_root)?;
|
||||
let archive_path = runtime_root.join(&package.name);
|
||||
@@ -210,6 +230,42 @@ mod tests {
|
||||
assert!(path.ends_with(java_executable_name()));
|
||||
}
|
||||
|
||||
fn package() -> AdoptiumPackage {
|
||||
AdoptiumPackage {
|
||||
name: "OpenJDK21U-jre_x64_linux_hotspot_21.0.8_9.tar.gz".into(),
|
||||
link: "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.8%2B9/runtime.tar.gz".into(),
|
||||
checksum: "a".repeat(64),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_only_portable_runtime_archive_names() {
|
||||
assert!(validate_package(&package()).is_ok());
|
||||
let mut windows = package();
|
||||
windows.name = "OpenJDK21U-jre_x64_windows_hotspot.zip".into();
|
||||
assert!(validate_package(&windows).is_ok());
|
||||
for name in ["../runtime.tar.gz", "/runtime.zip", "C:\\runtime.zip", "runtime.zip:stream", "CON.zip", "LPT1.zip", "runtime.zip.", "runtime.zip ", "runtime.exe"] {
|
||||
let mut malicious = package();
|
||||
malicious.name = name.into();
|
||||
assert!(matches!(validate_package(&malicious), Err(RuntimeError::UntrustedPackage)), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_untrusted_runtime_urls_and_invalid_hashes() {
|
||||
for link in ["http://github.com/runtime.zip", "https://evil.example/runtime.zip", "https://github.com.evil.example/runtime.zip", "https://user@github.com/runtime.zip"] {
|
||||
let mut malicious = package();
|
||||
malicious.link = link.into();
|
||||
assert!(validate_package(&malicious).is_err());
|
||||
}
|
||||
let mut malicious = package();
|
||||
malicious.checksum = "not-a-checksum".into();
|
||||
assert!(validate_package(&malicious).is_err());
|
||||
for host in RUNTIME_HOSTS {
|
||||
assert!(crate::trusted_http::allows(&format!("https://{host}/release.tar.gz"), &RUNTIME_HOSTS));
|
||||
}
|
||||
}
|
||||
|
||||
/// Live smoke test: resolves the current platform's latest Temurin 21
|
||||
/// JRE from Adoptium, downloads it, verifies the checksum, and extracts
|
||||
/// it. Not run by default; `cargo test -- --ignored ensure_runtime`.
|
||||
|
||||
+68
-16
@@ -38,7 +38,11 @@ fn default_nickname() -> String {
|
||||
|
||||
impl Default for LauncherSettings {
|
||||
fn default() -> Self {
|
||||
Self { memory_mb: DEFAULT_MEMORY_MB, nickname: DEFAULT_NICKNAME.into(), account_mode: AccountMode::Offline }
|
||||
Self {
|
||||
memory_mb: DEFAULT_MEMORY_MB,
|
||||
nickname: DEFAULT_NICKNAME.into(),
|
||||
account_mode: AccountMode::Offline,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,8 +59,13 @@ impl fmt::Display for SettingsError {
|
||||
match self {
|
||||
Self::Io(error) => write!(formatter, "Cannot access launcher settings: {error}"),
|
||||
Self::InvalidJson(error) => write!(formatter, "Cannot read launcher settings: {error}"),
|
||||
Self::InvalidMemory => write!(formatter, "Memory allocation must be between 3 and 12 GiB"),
|
||||
Self::InvalidNickname => write!(formatter, "Nickname must be 3-16 ASCII letters, numbers, or underscores"),
|
||||
Self::InvalidMemory => {
|
||||
write!(formatter, "Memory allocation must be between 3 and 12 GiB")
|
||||
}
|
||||
Self::InvalidNickname => write!(
|
||||
formatter,
|
||||
"Nickname must be 3-16 ASCII letters, numbers, or underscores"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,7 +74,9 @@ pub fn load(data_dir: &Path) -> Result<LauncherSettings, SettingsError> {
|
||||
let path = data_dir.join(SETTINGS_FILE);
|
||||
let source = match fs::read_to_string(path) {
|
||||
Ok(source) => source,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(LauncherSettings::default()),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {
|
||||
return Ok(LauncherSettings::default())
|
||||
}
|
||||
Err(error) => return Err(SettingsError::Io(error)),
|
||||
};
|
||||
let settings = serde_json::from_str(&source).map_err(SettingsError::InvalidJson)?;
|
||||
@@ -73,23 +84,31 @@ pub fn load(data_dir: &Path) -> Result<LauncherSettings, SettingsError> {
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
pub fn save(data_dir: &Path, settings: LauncherSettings) -> Result<LauncherSettings, SettingsError> {
|
||||
pub fn save(
|
||||
data_dir: &Path,
|
||||
settings: LauncherSettings,
|
||||
) -> Result<LauncherSettings, SettingsError> {
|
||||
validate(&settings)?;
|
||||
fs::create_dir_all(data_dir).map_err(SettingsError::Io)?;
|
||||
|
||||
let target = data_dir.join(SETTINGS_FILE);
|
||||
let temporary = data_dir.join(".settings.json.shacraft.part");
|
||||
let contents = serde_json::to_vec_pretty(&settings).expect("LauncherSettings is serializable");
|
||||
fs::write(&temporary, contents).map_err(SettingsError::Io)?;
|
||||
fs::rename(temporary, target).map_err(SettingsError::Io)?;
|
||||
crate::storage::write_atomic(&target, &contents).map_err(SettingsError::Io)?;
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
fn validate(settings: &LauncherSettings) -> Result<(), SettingsError> {
|
||||
if !(MIN_MEMORY_MB..=MAX_MEMORY_MB).contains(&settings.memory_mb) || settings.memory_mb % 1024 != 0 {
|
||||
if !(MIN_MEMORY_MB..=MAX_MEMORY_MB).contains(&settings.memory_mb)
|
||||
|| settings.memory_mb % 1024 != 0
|
||||
{
|
||||
return Err(SettingsError::InvalidMemory);
|
||||
}
|
||||
if !(3..=16).contains(&settings.nickname.len()) || !settings.nickname.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') {
|
||||
if !(3..=16).contains(&settings.nickname.len())
|
||||
|| !settings
|
||||
.nickname
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
|
||||
{
|
||||
return Err(SettingsError::InvalidNickname);
|
||||
}
|
||||
Ok(())
|
||||
@@ -98,13 +117,19 @@ fn validate(settings: &LauncherSettings) -> Result<(), SettingsError> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{load, save, AccountMode, LauncherSettings};
|
||||
use std::{fs, process, time::{SystemTime, UNIX_EPOCH}};
|
||||
use std::{
|
||||
fs, process,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
fn temporary_directory() -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"shacraft-settings-test-{}-{}",
|
||||
process::id(),
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos()
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
))
|
||||
}
|
||||
|
||||
@@ -116,9 +141,20 @@ mod tests {
|
||||
assert_eq!(default.nickname, "Emil");
|
||||
assert_eq!(default.account_mode, AccountMode::Offline);
|
||||
|
||||
let saved = save(&directory, LauncherSettings { memory_mb: 8 * 1024, nickname: "Emil".into(), account_mode: AccountMode::Microsoft }).unwrap();
|
||||
let saved = save(
|
||||
&directory,
|
||||
LauncherSettings {
|
||||
memory_mb: 8 * 1024,
|
||||
nickname: "Emil".into(),
|
||||
account_mode: AccountMode::Microsoft,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(saved.memory_mb, 8 * 1024);
|
||||
assert_eq!(load(&directory).unwrap().account_mode, AccountMode::Microsoft);
|
||||
assert_eq!(
|
||||
load(&directory).unwrap().account_mode,
|
||||
AccountMode::Microsoft
|
||||
);
|
||||
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
@@ -126,8 +162,24 @@ mod tests {
|
||||
#[test]
|
||||
fn rejects_unsafe_memory_values() {
|
||||
let directory = temporary_directory();
|
||||
assert!(save(&directory, LauncherSettings { memory_mb: 512, nickname: "Emil".into(), account_mode: AccountMode::Offline }).is_err());
|
||||
assert!(save(&directory, LauncherSettings { memory_mb: 6 * 1024, nickname: "невалидный".into(), account_mode: AccountMode::Offline }).is_err());
|
||||
assert!(save(
|
||||
&directory,
|
||||
LauncherSettings {
|
||||
memory_mb: 512,
|
||||
nickname: "Emil".into(),
|
||||
account_mode: AccountMode::Offline
|
||||
}
|
||||
)
|
||||
.is_err());
|
||||
assert!(save(
|
||||
&directory,
|
||||
LauncherSettings {
|
||||
memory_mb: 6 * 1024,
|
||||
nickname: "невалидный".into(),
|
||||
account_mode: AccountMode::Offline
|
||||
}
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
//! Same-directory atomic replacement shared by downloads and durable settings.
|
||||
use std::{
|
||||
ffi::OsString,
|
||||
fs::{self, File, OpenOptions},
|
||||
io::{self, Write},
|
||||
path::{Path, PathBuf},
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
static NEXT_TEMPORARY: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Owns a unique file until commit. Failed writes never replace the destination,
|
||||
/// and dropping the transaction removes only the temporary file it created.
|
||||
pub(crate) struct AtomicFile {
|
||||
temporary: PathBuf,
|
||||
target: PathBuf,
|
||||
file: Option<File>,
|
||||
committed: bool,
|
||||
}
|
||||
|
||||
impl AtomicFile {
|
||||
pub fn new(target: &Path) -> io::Result<Self> {
|
||||
let parent = target
|
||||
.parent()
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "target has no parent"))?;
|
||||
let name = target
|
||||
.file_name()
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "target has no filename"))?;
|
||||
fs::create_dir_all(parent)?;
|
||||
for _ in 0..128 {
|
||||
let sequence = NEXT_TEMPORARY.fetch_add(1, Ordering::Relaxed);
|
||||
let mut temporary_name = OsString::from(".");
|
||||
temporary_name.push(name);
|
||||
temporary_name.push(format!(".shacraft-{}-{sequence}.part", std::process::id()));
|
||||
let temporary = parent.join(temporary_name);
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
match options.open(&temporary) {
|
||||
Ok(file) => {
|
||||
return Ok(Self {
|
||||
temporary,
|
||||
target: target.to_path_buf(),
|
||||
file: Some(file),
|
||||
committed: false,
|
||||
})
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
"cannot allocate a unique temporary file",
|
||||
))
|
||||
}
|
||||
|
||||
pub fn writer(&mut self) -> &mut File {
|
||||
self.file
|
||||
.as_mut()
|
||||
.expect("atomic file is open until commit")
|
||||
}
|
||||
|
||||
pub fn commit(mut self) -> io::Result<()> {
|
||||
self.writer().sync_all()?;
|
||||
drop(self.file.take());
|
||||
fs::rename(&self.temporary, &self.target)?;
|
||||
self.committed = true;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AtomicFile {
|
||||
fn drop(&mut self) {
|
||||
drop(self.file.take());
|
||||
if !self.committed {
|
||||
let _ = fs::remove_file(&self.temporary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn write_atomic(target: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
let mut output = AtomicFile::new(target)?;
|
||||
output.writer().write_all(bytes)?;
|
||||
output.commit()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn competing_writers_do_not_share_temporary_files() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"shacraft-storage-test-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT_TEMPORARY.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
let target = root.join("settings.json");
|
||||
write_atomic(&target, b"original").unwrap();
|
||||
let mut first = AtomicFile::new(&target).unwrap();
|
||||
let mut second = AtomicFile::new(&target).unwrap();
|
||||
assert_ne!(first.temporary, second.temporary);
|
||||
first.writer().write_all(b"first").unwrap();
|
||||
second.writer().write_all(b"second").unwrap();
|
||||
first.commit().unwrap();
|
||||
assert_eq!(fs::read(&target).unwrap(), b"first");
|
||||
drop(second);
|
||||
assert_eq!(fs::read(&target).unwrap(), b"first");
|
||||
assert_eq!(fs::read_dir(&root).unwrap().count(), 1);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn persisted_secrets_are_owner_only() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"shacraft-secret-test-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT_TEMPORARY.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
let target = root.join("account.json");
|
||||
write_atomic(&target, b"token").unwrap();
|
||||
assert_eq!(
|
||||
target.metadata().unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//! The same origin policy applies to initial artifact URLs and every redirect.
|
||||
use reqwest::{blocking::Client, redirect::Policy};
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
|
||||
pub(crate) fn allows(value: &str, hosts: &[&str]) -> bool {
|
||||
let Ok(url) = Url::parse(value) else {
|
||||
return false;
|
||||
};
|
||||
url.scheme() == "https"
|
||||
&& url.username().is_empty()
|
||||
&& url.password().is_none()
|
||||
&& url.port_or_known_default() == Some(443)
|
||||
&& url.host_str().is_some_and(|host| hosts.contains(&host))
|
||||
}
|
||||
|
||||
pub(crate) fn client(
|
||||
hosts: &'static [&'static str],
|
||||
timeout: Duration,
|
||||
) -> Result<Client, reqwest::Error> {
|
||||
Client::builder()
|
||||
.https_only(true)
|
||||
.connect_timeout(Duration::from_secs(15))
|
||||
.timeout(timeout)
|
||||
.redirect(Policy::custom(move |attempt| {
|
||||
if attempt.previous().len() >= 10 {
|
||||
attempt.error("too many redirects")
|
||||
} else if allows(attempt.url().as_str(), hosts) {
|
||||
attempt.follow()
|
||||
} else {
|
||||
attempt.error("redirect leaves the trusted download hosts")
|
||||
}
|
||||
}))
|
||||
.build()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exact_https_origins_reject_authority_ambiguity_and_cross_domain_redirects() {
|
||||
let hosts = ["piston-meta.mojang.com"];
|
||||
assert!(allows("https://piston-meta.mojang.com/game.json", &hosts));
|
||||
for url in [
|
||||
"http://piston-meta.mojang.com/game.json",
|
||||
"https://piston-meta.mojang.com.attacker.test/game.json",
|
||||
"https://user@piston-meta.mojang.com/game.json",
|
||||
"https://piston-meta.mojang.com:444/game.json",
|
||||
"https://maven.neoforged.net/game.json",
|
||||
] {
|
||||
assert!(!allows(url, &hosts), "{url}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Library } from './components/Library'
|
||||
import { LoginModal } from './components/LoginModal'
|
||||
import { PlayDock } from './components/PlayDock'
|
||||
import { ServerStage } from './components/ServerStage'
|
||||
import { SettingsDrawer } from './components/SettingsDrawer'
|
||||
import { Titlebar } from './components/Titlebar'
|
||||
import { servers } from './data/servers'
|
||||
import { useAccount } from './hooks/useAccount'
|
||||
import { useLauncher } from './hooks/useLauncher'
|
||||
import { useSettings } from './hooks/useSettings'
|
||||
import { isNative } from './services/native'
|
||||
import { installStageLabels } from './state/game'
|
||||
|
||||
export function App() {
|
||||
const [selected, setSelected] = useState(servers[0])
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const preferences = useSettings()
|
||||
const session = useAccount()
|
||||
const launcher = useLauncher()
|
||||
const closeSettings = useCallback(() => setSettingsOpen(false), [])
|
||||
const desktop = isNative()
|
||||
const profile = selected.profileId ? launcher.profiles[selected.profileId] : undefined
|
||||
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
|
||||
|
||||
let label = 'Играть'
|
||||
if (selected.disabled) label = 'Недоступно'
|
||||
else 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 (preferences.saving) label = 'Сохраняем…'
|
||||
else if (accountLoading || !preferences.loaded) label = 'Загрузка…'
|
||||
else if (needsLogin) label = session.busy ? 'Ждём вход…' : 'Войти через Microsoft'
|
||||
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)
|
||||
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}>
|
||||
<PlayDock server={selected} operation={operation} profile={profile}
|
||||
memoryGb={preferences.settings.memoryMb / 1024} native={desktop} needsLogin={needsLogin}
|
||||
error={error} label={label} primaryDisabled={disabled} repairDisabled={repairDisabled}
|
||||
onPrimary={primary} onRepair={() => { if (!repairDisabled && selected.profileId) void launcher.repair(selected.profileId) }} />
|
||||
</ServerStage>
|
||||
</div>
|
||||
<SettingsDrawer open={settingsOpen && !session.code} locked={busy || session.busy} preferences={preferences}
|
||||
session={session} host={launcher.host} java={launcher.java} onClose={closeSettings} />
|
||||
<LoginModal code={session.code} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { ChevronRight, Library as LibraryIcon, LogOut, MessageCircle, Newspaper, Settings } from 'lucide-react'
|
||||
import { servers } from '../data/servers'
|
||||
import type { ProfileState } from '../state/profiles'
|
||||
import type { LauncherSettings, MinecraftProfile, Server } from '../types/launcher'
|
||||
|
||||
interface LibraryProps {
|
||||
selected: Server
|
||||
profiles: Record<string, ProfileState>
|
||||
settings: LauncherSettings
|
||||
account: MinecraftProfile | 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 ?? 'Не авторизован'
|
||||
|
||||
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>
|
||||
<aside className="library-panel">
|
||||
<div className="library-heading"><p>Сборки</p><span>1 доступна</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 ? 'Установлена' : 'Требуется проверка'
|
||||
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-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>
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Download, Gauge, Globe2, Play, RotateCcw, ShieldCheck, Wrench } from 'lucide-react'
|
||||
import type { ProfileState } from '../state/profiles'
|
||||
import { installPercent, installStageLabels } from '../state/game'
|
||||
import type { GameOperation } from '../state/game'
|
||||
import type { Server } from '../types/launcher'
|
||||
|
||||
interface PlayDockProps {
|
||||
server: Server
|
||||
operation: GameOperation
|
||||
profile: ProfileState | undefined
|
||||
memoryGb: number
|
||||
native: boolean
|
||||
needsLogin: boolean
|
||||
error: string | null
|
||||
label: string
|
||||
primaryDisabled: boolean
|
||||
repairDisabled: boolean
|
||||
onPrimary: () => void
|
||||
onRepair: () => void
|
||||
}
|
||||
|
||||
export function PlayDock(props: PlayDockProps) {
|
||||
const { server, operation, profile, memoryGb, needsLogin, error } = props
|
||||
const progress = operation.phase === 'installing' ? operation.progress : null
|
||||
const percent = installPercent(progress)
|
||||
const working = operation.phase === 'syncing' || operation.phase === 'installing' || operation.phase === 'launching'
|
||||
let title: string
|
||||
let detail: string
|
||||
if (operation.phase === 'installing') {
|
||||
title = progress ? installStageLabels[progress.stage] : 'Готовим установку'
|
||||
detail = percent === null ? 'Проверяем файлы…' : `${percent}%`
|
||||
} else if (operation.phase === 'launching') {
|
||||
title = 'Запускаем игру'; detail = 'Подготавливаем игровой процесс…'
|
||||
} else if (operation.phase === 'running') {
|
||||
title = 'Игра запущена'; detail = 'Вернитесь после завершения игры'
|
||||
} else if (operation.phase === 'syncing') {
|
||||
title = 'Синхронизируем сборку'; detail = 'Скачиваем и проверяем файлы'
|
||||
} else if (server.disabled) {
|
||||
title = 'Техобслуживание'; detail = 'Сообщим, когда сервер вернётся'
|
||||
} else if (!props.native) {
|
||||
title = 'Предпросмотр интерфейса'; detail = 'Установка и запуск доступны в приложении'
|
||||
} else {
|
||||
title = needsLogin ? 'Нужен вход' : profile?.status === 'checking' ? 'Проверяем сборку'
|
||||
: profile?.inspection?.upToDate ? 'Сборка готова' : 'Требуется проверка'
|
||||
detail = profile?.inspection ? `${profile.inspection.managedFiles} файлов под контролем` : 'Проверяем локальные файлы'
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="play-dock">
|
||||
<div className="build-state" aria-live="polite">
|
||||
<span className={`state-icon ${working ? 'downloading' : server.disabled ? 'muted' : ''}`}>
|
||||
{working ? <Download size={19} /> : server.disabled ? <Wrench size={19} /> : <ShieldCheck size={19} />}
|
||||
</span>
|
||||
<span><strong>{title}</strong><small className={error ? 'status-error' : undefined} title={error ?? undefined}>{error ?? detail}</small></span>
|
||||
{percent !== null && <div className="progress-track" role="progressbar" aria-label={title}
|
||||
aria-valuemin={0} aria-valuemax={100} aria-valuenow={percent}><i style={{ width: `${percent}%` }} /></div>}
|
||||
</div>
|
||||
<div className="build-facts">
|
||||
<span><Globe2 size={15} /> {server.version}</span>
|
||||
<span><Gauge size={15} /> {memoryGb} ГБ памяти</span>
|
||||
</div>
|
||||
<button className="repair-button" onClick={props.onRepair} disabled={props.repairDisabled} aria-label="Проверить файлы"><RotateCcw size={19} /></button>
|
||||
<button className="play-button" disabled={props.primaryDisabled} onClick={props.onPrimary}>
|
||||
<Play size={21} fill="currentColor" /><span>{props.label}</span>
|
||||
</button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Users } from 'lucide-react'
|
||||
import type { Server } from '../types/launcher'
|
||||
|
||||
export function ServerStage({ server, children }: { server: Server; children: ReactNode }) {
|
||||
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>
|
||||
<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>
|
||||
</section>
|
||||
{children}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { ChevronRight, FolderOpen, LogOut, Users, Wrench, X } from 'lucide-react'
|
||||
import type { useAccount } from '../hooks/useAccount'
|
||||
import type { useSettings } from '../hooks/useSettings'
|
||||
import type { JavaInstallation, NativeHost } from '../types/launcher'
|
||||
|
||||
interface SettingsDrawerProps {
|
||||
open: boolean
|
||||
locked: boolean
|
||||
host: NativeHost | null
|
||||
java: JavaInstallation | null | undefined
|
||||
preferences: ReturnType<typeof useSettings>
|
||||
session: ReturnType<typeof useAccount>
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function SettingsDrawer({ open, locked, host, java, preferences, session, onClose }: SettingsDrawerProps) {
|
||||
const { settings, nickname, loaded, saving, error, nicknameError } = preferences
|
||||
const closeButton = useRef<HTMLButtonElement>(null)
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const previous = document.activeElement
|
||||
closeButton.current?.focus()
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose()
|
||||
if (event.key !== 'Tab') return
|
||||
const elements = closeButton.current?.closest('aside')?.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled)')
|
||||
const first = elements?.[0]
|
||||
const last = elements?.[elements.length - 1]
|
||||
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus() }
|
||||
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus() }
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey)
|
||||
if (previous instanceof HTMLElement) previous.focus()
|
||||
}
|
||||
}, [open, onClose])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`drawer-backdrop ${open ? 'visible' : ''}`} onClick={onClose} />
|
||||
<aside className={`settings-drawer ${open ? 'open' : ''}`} inert={!open} aria-hidden={!open}
|
||||
role="dialog" aria-modal={open ? true : undefined} aria-labelledby="settings-title">
|
||||
<div className="drawer-title">
|
||||
<div><p>Настройки</p><h2 id="settings-title">Игра</h2></div>
|
||||
<button ref={closeButton} onClick={onClose} aria-label="Закрыть настройки"><X /></button>
|
||||
</div>
|
||||
<label className="range-setting">
|
||||
<span><strong>Оперативная память</strong><b>{settings.memoryMb / 1024} ГБ</b></span>
|
||||
<input type="range" min="3" max="12" step="1" value={settings.memoryMb / 1024} disabled={!loaded || locked}
|
||||
onChange={(event) => preferences.updateRam(Number(event.target.value))} />
|
||||
<small>Для Aeronautics рекомендуется 6 ГБ</small>
|
||||
</label>
|
||||
<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>
|
||||
)}
|
||||
<div className="setting-row static"><span><FolderOpen />Папка игры</span><small>{host ? 'В каталоге лаунчера' : 'Определяется…'}</small></div>
|
||||
<div className="setting-row static">
|
||||
<span><Wrench />Java</span>
|
||||
<small>{java === undefined ? host ? 'Проверяем…' : 'Проверяется в приложении'
|
||||
: java?.major === 21 ? 'Java 21 найдена' : java ? `Нужна Java 21 · найдена ${java.major}`
|
||||
: 'Лаунчер установит Java 21 автоматически'}</small>
|
||||
</div>
|
||||
<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>
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Minus, Square, X } from 'lucide-react'
|
||||
import logo from '../assets/shacraft-logo.png'
|
||||
import type { NativeHost } from '../types/launcher'
|
||||
|
||||
export function Titlebar({ host }: { host: NativeHost | null }) {
|
||||
return (
|
||||
<header className="titlebar">
|
||||
<div className="brand"><img src={logo} alt="" /><span>ShaCraft</span></div>
|
||||
<div className="titlebar-drag">{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>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Server } from '../types/launcher'
|
||||
|
||||
// Presentation metadata only. Rust reads install versions and managed files
|
||||
// from the signed manifest; this list cannot control downloads or launch args.
|
||||
export const servers: readonly [Server, ...Server[]] = [
|
||||
{
|
||||
id: 'aoc',
|
||||
kicker: 'All of Create / сборка 2.5',
|
||||
name: 'Aeronautics',
|
||||
subtitle: 'Строй корабли. Поднимай города в небо.',
|
||||
version: '1.21.1 · NeoForge',
|
||||
composition: '250 модов',
|
||||
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,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,80 @@
|
||||
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'
|
||||
|
||||
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 [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [eventsReady, setEventsReady] = useState(false)
|
||||
const pending = useRef(false)
|
||||
|
||||
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)
|
||||
}).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
|
||||
setAccount(null)
|
||||
setError(errorMessage(reason, 'Не удалось восстановить аккаунт'))
|
||||
})
|
||||
return () => { active = false; subscription.dispose() }
|
||||
}, [])
|
||||
|
||||
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'))
|
||||
}
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
if (!isNative() || pending.current) return
|
||||
pending.current = true
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await native.logout()
|
||||
setAccount(null)
|
||||
} catch (reason) {
|
||||
setError(errorMessage(reason, 'Не удалось выйти из аккаунта'))
|
||||
} finally {
|
||||
pending.current = false
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return { account, code, error, busy, eventsReady, login, logout }
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useReducer, useRef, useState } from 'react'
|
||||
import { servers } from '../data/servers'
|
||||
import { errorMessage } from '../services/async'
|
||||
import { isNative, native, watchGame } from '../services/native'
|
||||
import { gameReducer, initialGameState } from '../state/game'
|
||||
import { profilesReducer } from '../state/profiles'
|
||||
import type { JavaInstallation, NativeHost } from '../types/launcher'
|
||||
|
||||
export function useLauncher() {
|
||||
const [host, setHost] = useState<NativeHost | null>(null)
|
||||
const [java, setJava] = useState<JavaInstallation | null | undefined>(undefined)
|
||||
const [environmentError, setEnvironmentError] = useState<string | null>(null)
|
||||
const [profiles, updateProfile] = useReducer(profilesReducer, {})
|
||||
const [game, dispatch] = useReducer(gameReducer, initialGameState)
|
||||
const [eventsReady, setEventsReady] = useState(false)
|
||||
const busy = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNative()) return
|
||||
let active = true
|
||||
const subscription = watchGame({
|
||||
progress: (progress) => { if (active) dispatch({ type: 'progress', progress }) },
|
||||
exited: (result) => { if (active) dispatch({ type: 'exited', result }) },
|
||||
})
|
||||
void subscription.ready.then(() => {
|
||||
if (active) setEventsReady(true)
|
||||
}).catch((reason: unknown) => {
|
||||
if (active) setEnvironmentError(errorMessage(reason, 'Не удалось подключить события игры'))
|
||||
})
|
||||
void native.host().then((value) => {
|
||||
if (active) setHost(value)
|
||||
}).catch((reason: unknown) => {
|
||||
if (active) setEnvironmentError(errorMessage(reason, 'Не удалось определить каталог лаунчера'))
|
||||
})
|
||||
void native.detectJava().then((value) => {
|
||||
if (active) setJava(value)
|
||||
}).catch((reason: unknown) => {
|
||||
if (!active) return
|
||||
setJava(null)
|
||||
setEnvironmentError(errorMessage(reason, 'Не удалось проверить Java'))
|
||||
})
|
||||
for (const server of servers) {
|
||||
const profileId = server.profileId
|
||||
if (!profileId || server.disabled) continue
|
||||
updateProfile({ type: 'check', profileId })
|
||||
void native.inspectProfile(profileId).then((inspection) => {
|
||||
if (active) updateProfile({ type: 'checked', profileId, inspection })
|
||||
}).catch((reason: unknown) => {
|
||||
if (active) updateProfile({ type: 'failed', profileId, error: errorMessage(reason, 'Не удалось проверить сборку') })
|
||||
})
|
||||
}
|
||||
return () => { active = false; subscription.dispose() }
|
||||
}, [])
|
||||
|
||||
const repair = async (profileId: string) => {
|
||||
if (!isNative() || busy.current || game.operation.phase !== 'idle' || profiles[profileId]?.status === 'checking') return
|
||||
busy.current = true
|
||||
dispatch({ type: 'sync', profileId })
|
||||
// A repair may replace only some files before failing. Never keep an older
|
||||
// up-to-date inspection as permission to launch that partial installation.
|
||||
updateProfile({ type: 'check', profileId })
|
||||
try {
|
||||
const result = await native.syncProfile(profileId)
|
||||
updateProfile({ type: 'checked', profileId,
|
||||
inspection: { root: result.root, managedFiles: result.downloadedFiles + result.reusedFiles,
|
||||
missingFiles: 0, mismatchedFiles: 0, upToDate: true },
|
||||
})
|
||||
dispatch({ type: 'synced', profileId })
|
||||
} catch (reason) {
|
||||
const error = errorMessage(reason, 'Не удалось синхронизировать сборку')
|
||||
updateProfile({ type: 'failed', profileId, error })
|
||||
dispatch({ type: 'failed', error })
|
||||
} finally {
|
||||
busy.current = false
|
||||
}
|
||||
}
|
||||
|
||||
const launch = async (profileId: string) => {
|
||||
if (!isNative() || !eventsReady || busy.current || game.operation.phase !== 'idle') return
|
||||
busy.current = true
|
||||
dispatch({ type: 'install', profileId })
|
||||
try {
|
||||
await native.installGame(profileId)
|
||||
dispatch({ type: 'launch', profileId })
|
||||
await native.launchGame(profileId)
|
||||
dispatch({ type: 'started', profileId })
|
||||
} catch (reason) {
|
||||
dispatch({ type: 'failed', error: errorMessage(reason, 'Не удалось запустить игру') })
|
||||
} finally {
|
||||
busy.current = false
|
||||
}
|
||||
}
|
||||
|
||||
return { host, java, environmentError, profiles, game, eventsReady, repair, launch }
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
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'
|
||||
|
||||
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)
|
||||
const revision = useRef(0)
|
||||
const queue = useRef(createSerialQueue())
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNative()) return
|
||||
let active = true
|
||||
setError(null)
|
||||
native.loadSettings().then((value) => {
|
||||
if (!active) return
|
||||
current.current = value
|
||||
durable.current = value
|
||||
setSettings(value)
|
||||
setNickname(value.nickname)
|
||||
setLoaded(true)
|
||||
}).catch((reason: unknown) => {
|
||||
if (active) setError(errorMessage(reason, 'Не удалось прочитать настройки'))
|
||||
})
|
||||
return () => { active = false }
|
||||
}, [loadAttempt])
|
||||
|
||||
const save = (patch: Partial<LauncherSettings>) => {
|
||||
if (!loaded) return
|
||||
const next = { ...current.current, ...patch }
|
||||
current.current = next
|
||||
setSettings(next)
|
||||
setError(null)
|
||||
if (!isNative()) return
|
||||
const requestRevision = ++revision.current
|
||||
setSaving(true)
|
||||
void queue.current.enqueue(() => native.saveSettings(next)).then((value) => {
|
||||
durable.current = value
|
||||
if (revision.current === requestRevision) {
|
||||
current.current = value
|
||||
setSettings(value)
|
||||
}
|
||||
}).catch((reason: unknown) => {
|
||||
if (revision.current !== requestRevision) return
|
||||
current.current = durable.current
|
||||
setSettings(durable.current)
|
||||
setError(errorMessage(reason, 'Не удалось сохранить настройки'))
|
||||
}).finally(() => {
|
||||
if (revision.current === requestRevision) setSaving(false)
|
||||
})
|
||||
}
|
||||
|
||||
const saveNickname = () => {
|
||||
if (!isValidNickname(nickname)) {
|
||||
setNicknameError('Ник: от 3 до 16 латинских букв, цифр или символов _')
|
||||
return
|
||||
}
|
||||
setNicknameError(null)
|
||||
save({ nickname })
|
||||
}
|
||||
|
||||
return {
|
||||
settings, nickname, loaded, saving, error, nicknameError,
|
||||
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({})
|
||||
},
|
||||
}
|
||||
}
|
||||
+5
-532
@@ -1,536 +1,9 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
import {
|
||||
ChevronRight,
|
||||
Download,
|
||||
FolderOpen,
|
||||
Gauge,
|
||||
Globe2,
|
||||
Library,
|
||||
LogOut,
|
||||
MessageCircle,
|
||||
Minus,
|
||||
Newspaper,
|
||||
Play,
|
||||
RotateCcw,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Square,
|
||||
Users,
|
||||
Wrench,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import logo from './assets/shacraft-logo.png'
|
||||
import { App } from './App'
|
||||
import './styles.css'
|
||||
|
||||
type Server = {
|
||||
id: string
|
||||
kicker: string
|
||||
name: string
|
||||
subtitle: string
|
||||
players: string
|
||||
version: string
|
||||
memory: string
|
||||
installed: boolean
|
||||
profileId?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
const root = document.getElementById('root')
|
||||
if (!root) throw new Error('Launcher root element is missing')
|
||||
|
||||
type NativeHost = {
|
||||
platform: string
|
||||
dataDir: string
|
||||
launcherVersion: string
|
||||
}
|
||||
|
||||
type NativeSettings = {
|
||||
memoryMb: number
|
||||
nickname: string
|
||||
accountMode: 'microsoft' | 'offline'
|
||||
}
|
||||
|
||||
type JavaInstallation = {
|
||||
executable: string
|
||||
major: number
|
||||
version: string
|
||||
}
|
||||
|
||||
type ProfileInspection = {
|
||||
managedFiles: number
|
||||
missingFiles: number
|
||||
mismatchedFiles: number
|
||||
upToDate: boolean
|
||||
}
|
||||
|
||||
type SyncResult = {
|
||||
downloadedFiles: number
|
||||
reusedFiles: number
|
||||
downloadedBytes: number
|
||||
}
|
||||
|
||||
type MinecraftProfile = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
type DeviceCodePayload = {
|
||||
verificationUri: string
|
||||
userCode: string
|
||||
expiresInSeconds: number
|
||||
}
|
||||
|
||||
type LoginResultPayload = {
|
||||
ok: boolean
|
||||
profile?: MinecraftProfile
|
||||
error?: string
|
||||
}
|
||||
|
||||
type InstallProgressPayload = {
|
||||
stage: 'java' | 'neoforge' | 'libraries' | 'assets'
|
||||
currentBytes: number
|
||||
totalBytes: number
|
||||
}
|
||||
|
||||
const INSTALL_STAGE_LABEL: Record<InstallProgressPayload['stage'], string> = {
|
||||
java: 'Готовим Java',
|
||||
neoforge: 'Устанавливаем NeoForge',
|
||||
libraries: 'Скачиваем библиотеки',
|
||||
assets: 'Скачиваем ресурсы игры',
|
||||
}
|
||||
|
||||
const servers: Server[] = [
|
||||
{
|
||||
id: 'aoc',
|
||||
kicker: 'Основная сборка',
|
||||
name: 'Aeronautics',
|
||||
subtitle: 'Строй корабли. Поднимай города в небо.',
|
||||
players: '7 / 20',
|
||||
version: '1.21.1 · NeoForge',
|
||||
memory: '6 ГБ',
|
||||
installed: true,
|
||||
profileId: 'aeronautics',
|
||||
},
|
||||
{
|
||||
id: 'create',
|
||||
kicker: 'На техобслуживании',
|
||||
name: 'Create',
|
||||
subtitle: 'Механизмы, фабрики и большие идеи.',
|
||||
players: 'Сервер остановлен',
|
||||
version: '1.21.1 · NeoForge',
|
||||
memory: '4 ГБ',
|
||||
installed: false,
|
||||
disabled: true,
|
||||
},
|
||||
]
|
||||
|
||||
function isTauri() {
|
||||
return '__TAURI_INTERNALS__' in window
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [selected, setSelected] = useState(servers[0])
|
||||
const [progress, setProgress] = useState<number | null>(null)
|
||||
const [ready, setReady] = useState(true)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [ram, setRam] = useState(6)
|
||||
const [nickname, setNickname] = useState('Emil')
|
||||
const [accountMode, setAccountMode] = useState<'microsoft' | 'offline'>('offline')
|
||||
const [nativeHost, setNativeHost] = useState<NativeHost | null>(null)
|
||||
const [java, setJava] = useState<JavaInstallation | null | undefined>(undefined)
|
||||
const [profile, setProfile] = useState<ProfileInspection | null>(null)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [syncError, setSyncError] = useState<string | null>(null)
|
||||
|
||||
// undefined = still checking for a saved session; null = signed out.
|
||||
const [account, setAccount] = useState<MinecraftProfile | null | undefined>(undefined)
|
||||
const [loginCode, setLoginCode] = useState<DeviceCodePayload | null>(null)
|
||||
const [loginError, setLoginError] = useState<string | null>(null)
|
||||
const [loggingIn, setLoggingIn] = useState(false)
|
||||
const [installing, setInstalling] = useState(false)
|
||||
const [installProgress, setInstallProgress] = useState<InstallProgressPayload | null>(null)
|
||||
const [launchError, setLaunchError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (progress === null) return
|
||||
if (progress >= 100) {
|
||||
const done = window.setTimeout(() => {
|
||||
setProgress(null)
|
||||
setReady(true)
|
||||
}, 650)
|
||||
return () => window.clearTimeout(done)
|
||||
}
|
||||
const timer = window.setTimeout(() => setProgress(Math.min(100, progress + 2)), 55)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [progress])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri()) return
|
||||
invoke<NativeHost>('native_host').then(setNativeHost).catch(() => setNativeHost(null))
|
||||
invoke<NativeSettings>('load_settings')
|
||||
.then((settings) => {
|
||||
setRam(settings.memoryMb / 1024)
|
||||
setNickname(settings.nickname)
|
||||
setAccountMode(settings.accountMode)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
invoke<JavaInstallation | null>('detect_java')
|
||||
.then(setJava)
|
||||
.catch(() => setJava(null))
|
||||
invoke<ProfileInspection>('inspect_remote_profile', { profileId: 'aeronautics' })
|
||||
.then((inspection) => {
|
||||
setProfile(inspection)
|
||||
setReady(inspection.upToDate)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
invoke<MinecraftProfile | null>('get_account')
|
||||
.then(setAccount)
|
||||
.catch(() => setAccount(null))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri()) return
|
||||
const unlisten = [
|
||||
listen<DeviceCodePayload>('msa-login-code', (event) => setLoginCode(event.payload)),
|
||||
listen<LoginResultPayload>('msa-login-result', (event) => {
|
||||
setLoggingIn(false)
|
||||
setLoginCode(null)
|
||||
if (event.payload.ok && event.payload.profile) {
|
||||
setAccount(event.payload.profile)
|
||||
setLoginError(null)
|
||||
} else {
|
||||
setLoginError(event.payload.error ?? 'Не удалось войти через Microsoft')
|
||||
}
|
||||
}),
|
||||
listen<InstallProgressPayload>('game-install-progress', (event) => setInstallProgress(event.payload)),
|
||||
listen('game-exited', () => setInstalling(false)),
|
||||
]
|
||||
return () => {
|
||||
unlisten.forEach((promise) => promise.then((off) => off()))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const saveSettings = (memoryGb = ram, nick = nickname, mode = accountMode) => {
|
||||
if (isTauri()) {
|
||||
invoke<NativeSettings>('save_settings', { settings: { memoryMb: memoryGb * 1024, nickname: nick, accountMode: mode } }).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const updateRam = (memoryGb: number) => {
|
||||
setRam(memoryGb)
|
||||
saveSettings(memoryGb)
|
||||
}
|
||||
|
||||
const saveNickname = () => {
|
||||
if (/^[A-Za-z0-9_]{3,16}$/.test(nickname)) {
|
||||
saveSettings(ram, nickname)
|
||||
}
|
||||
}
|
||||
|
||||
const setMode = (mode: 'microsoft' | 'offline') => {
|
||||
setAccountMode(mode)
|
||||
saveSettings(ram, nickname, mode)
|
||||
}
|
||||
|
||||
const repair = async () => {
|
||||
if (selected.disabled) return
|
||||
if (isTauri() && selected.profileId) {
|
||||
setSyncError(null)
|
||||
setSyncing(true)
|
||||
setReady(false)
|
||||
try {
|
||||
const result = await invoke<SyncResult>('sync_remote_profile', { profileId: selected.profileId })
|
||||
setProfile({ managedFiles: result.downloadedFiles + result.reusedFiles, missingFiles: 0, mismatchedFiles: 0, upToDate: true })
|
||||
setReady(true)
|
||||
} catch (error) {
|
||||
setSyncError(error instanceof Error ? error.message : 'Не удалось синхронизировать сборку')
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
setReady(false)
|
||||
setProgress(0)
|
||||
}
|
||||
|
||||
const startLogin = async () => {
|
||||
if (!isTauri()) return
|
||||
setLoginError(null)
|
||||
setLoggingIn(true)
|
||||
try {
|
||||
await invoke('start_microsoft_login')
|
||||
} catch (error) {
|
||||
setLoggingIn(false)
|
||||
setLoginError(error instanceof Error ? error.message : 'Не удалось начать вход через Microsoft')
|
||||
}
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
if (!isTauri()) return
|
||||
await invoke('logout').catch(() => undefined)
|
||||
setAccount(null)
|
||||
}
|
||||
|
||||
const playOrLogin = async () => {
|
||||
if (selected.disabled || !isTauri() || !selected.profileId) return
|
||||
// In offline mode we can launch without any Microsoft session. In
|
||||
// Microsoft mode a signed-in account is still required first.
|
||||
if (accountMode === 'microsoft' && (account === null || account === undefined)) {
|
||||
await startLogin()
|
||||
return
|
||||
}
|
||||
setLaunchError(null)
|
||||
setInstalling(true)
|
||||
setInstallProgress(null)
|
||||
try {
|
||||
await invoke('ensure_game_installed', { profileId: selected.profileId })
|
||||
await invoke('launch_game', { profileId: selected.profileId })
|
||||
} catch (error) {
|
||||
setLaunchError(error instanceof Error ? error.message : 'Не удалось запустить игру')
|
||||
setInstalling(false)
|
||||
}
|
||||
}
|
||||
|
||||
const playLabel = () => {
|
||||
if (selected.disabled) return 'Недоступно'
|
||||
if (accountMode === 'microsoft' && account === undefined) return 'Загрузка…'
|
||||
if (accountMode === 'microsoft' && account === null) return loggingIn ? 'Ждём вход…' : 'Войти через Microsoft'
|
||||
if (installing) return installProgress ? `${INSTALL_STAGE_LABEL[installProgress.stage]}…` : 'Подготовка…'
|
||||
if (syncing || progress !== null) return 'Обновление'
|
||||
return ready ? 'Играть' : 'Проверить'
|
||||
}
|
||||
|
||||
const installPercent = installProgress && installProgress.totalBytes > 0 ? Math.min(100, Math.round((installProgress.currentBytes / installProgress.totalBytes) * 100)) : null
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="titlebar">
|
||||
<div className="brand">
|
||||
<img src={logo} alt="" />
|
||||
<span>ShaCraft</span>
|
||||
</div>
|
||||
<div className="titlebar-drag">{nativeHost ? `Лаунчер · ${nativeHost.platform}` : 'Лаунчер'}</div>
|
||||
<div className="window-actions" aria-label="Управление окном">
|
||||
<button aria-label="Свернуть"><Minus size={15} /></button>
|
||||
<button aria-label="Развернуть"><Square size={12} /></button>
|
||||
<button className="close" aria-label="Закрыть"><X size={15} /></button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="workspace">
|
||||
<nav className="rail" aria-label="Основное меню">
|
||||
<div className="rail-main">
|
||||
<button className="rail-button active" aria-label="Сборки"><Library /></button>
|
||||
<button className="rail-button" aria-label="Новости"><Newspaper /></button>
|
||||
<button className="rail-button" aria-label="Сообщество"><MessageCircle /></button>
|
||||
</div>
|
||||
<button className="rail-button" aria-label="Настройки" onClick={() => setSettingsOpen(true)}>
|
||||
<Settings />
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<aside className="library-panel">
|
||||
<div className="library-heading">
|
||||
<p>Сборки</p>
|
||||
<span>1 доступна</span>
|
||||
</div>
|
||||
<div className="server-list">
|
||||
{servers.map((server) => (
|
||||
<button
|
||||
key={server.id}
|
||||
className={`server-row ${selected.id === server.id ? 'selected' : ''}`}
|
||||
onClick={() => {
|
||||
setSelected(server)
|
||||
setReady(server.installed)
|
||||
setProgress(null)
|
||||
}}
|
||||
>
|
||||
<span className={`server-glyph ${server.id}`} aria-hidden="true">
|
||||
{server.id === 'aoc' ? 'A' : 'C'}
|
||||
</span>
|
||||
<span className="server-copy">
|
||||
<strong>{server.name}</strong>
|
||||
<small>{server.disabled ? 'На паузе' : 'Установлена'}</small>
|
||||
</span>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="account-chip">
|
||||
<span className="avatar">{accountMode === 'offline' ? nickname.slice(0, 2).toUpperCase() : (account ? account.name.slice(0, 2).toUpperCase() : '?')}</span>
|
||||
<span>
|
||||
<strong>{accountMode === 'offline' ? nickname : (account === undefined ? 'Проверяем…' : account === null ? 'Не авторизован' : account.name)}</strong>
|
||||
<small>{accountMode === 'offline' ? 'Offline-аккаунт' : (account ? 'Microsoft-аккаунт' : 'Войдите, чтобы играть')}</small>
|
||||
</span>
|
||||
{accountMode === 'microsoft' && account ? (
|
||||
<button aria-label="Выйти из аккаунта" onClick={logout} style={{ background: 'transparent', border: 0, cursor: 'pointer', color: 'inherit' }}>
|
||||
<LogOut size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<ChevronRight size={16} />
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className={`stage stage-${selected.id}`}>
|
||||
<div className="stage-top">
|
||||
<div className={`live-pill ${selected.disabled ? 'offline' : ''}`}>
|
||||
<span /> {selected.disabled ? 'Не в сети' : 'Сервер работает'}
|
||||
</div>
|
||||
<div className="players"><Users size={16} /> {selected.players}</div>
|
||||
</div>
|
||||
|
||||
<section className="hero-copy">
|
||||
<p>{selected.id === 'aoc' ? 'All of Create / сборка 2.5' : selected.kicker}</p>
|
||||
<h1>{selected.name}</h1>
|
||||
<h2>{selected.subtitle}</h2>
|
||||
<dl className="hero-meta">
|
||||
<div><dt>Состав</dt><dd>{selected.id === 'aoc' ? '250 модов' : '41 мод'}</dd></div>
|
||||
<div><dt>Загрузчик</dt><dd>{selected.id === 'aoc' ? 'NeoForge 21.1.248' : 'NeoForge 21.1.249'}</dd></div>
|
||||
<div><dt>Java</dt><dd>Версия 21</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="play-dock">
|
||||
<div className="build-state">
|
||||
{installing ? (
|
||||
<>
|
||||
<span className="state-icon downloading"><Download size={19} /></span>
|
||||
<span>
|
||||
<strong>{installProgress ? INSTALL_STAGE_LABEL[installProgress.stage] : 'Готовим установку'}</strong>
|
||||
<small>{installPercent !== null ? `${installPercent}%` : 'Проверяем файлы…'}</small>
|
||||
</span>
|
||||
</>
|
||||
) : syncing ? (
|
||||
<>
|
||||
<span className="state-icon downloading"><Download size={19} /></span>
|
||||
<span><strong>Синхронизируем сборку</strong><small>Скачиваем и проверяем файлы</small></span>
|
||||
</>
|
||||
) : progress !== null ? (
|
||||
<>
|
||||
<span className="state-icon downloading"><Download size={19} /></span>
|
||||
<span>
|
||||
<strong>Проверяем сборку</strong>
|
||||
<small>Файлы и обновления · {progress}%</small>
|
||||
</span>
|
||||
</>
|
||||
) : selected.disabled ? (
|
||||
<>
|
||||
<span className="state-icon muted"><Wrench size={19} /></span>
|
||||
<span><strong>Техобслуживание</strong><small>Сообщим, когда сервер вернётся</small></span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="state-icon"><ShieldCheck size={19} /></span>
|
||||
<span>
|
||||
<strong>{accountMode === 'microsoft' && account === null ? 'Нужен вход' : ready ? 'Сборка готова' : 'Требуется проверка'}</strong>
|
||||
<small>{launchError || syncError || loginError || (profile ? `${profile.managedFiles} файлов под контролем` : 'Проверяем локальные файлы')}</small>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{progress !== null && <div className="progress-track"><i style={{ width: `${progress}%` }} /></div>}
|
||||
{installPercent !== null && <div className="progress-track"><i style={{ width: `${installPercent}%` }} /></div>}
|
||||
</div>
|
||||
|
||||
<div className="build-facts">
|
||||
<span><Globe2 size={15} /> {selected.version}</span>
|
||||
<span><Gauge size={15} /> {ram} ГБ памяти</span>
|
||||
</div>
|
||||
|
||||
<button className="repair-button" onClick={repair} disabled={progress !== null || syncing || installing || selected.disabled} aria-label="Проверить файлы">
|
||||
<RotateCcw size={19} />
|
||||
</button>
|
||||
<button
|
||||
className="play-button"
|
||||
disabled={progress !== null || syncing || installing || selected.disabled || (accountMode === 'microsoft' && account === undefined) || loggingIn}
|
||||
onClick={playOrLogin}
|
||||
>
|
||||
<Play size={21} fill="currentColor" />
|
||||
<span>{playLabel()}</span>
|
||||
</button>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div className={`drawer-backdrop ${loginCode ? 'visible' : ''}`} />
|
||||
{loginCode && (
|
||||
<div className="login-modal" role="dialog" aria-modal="true">
|
||||
<h2>Вход через Microsoft</h2>
|
||||
<p>Откройте страницу и введите код, чтобы подтвердить вход в аккаунт с лицензией Minecraft.</p>
|
||||
<div className="login-code">{loginCode.userCode}</div>
|
||||
<p className="login-url">{loginCode.verificationUri}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`drawer-backdrop ${settingsOpen ? 'visible' : ''}`} onClick={() => setSettingsOpen(false)} />
|
||||
<aside className={`settings-drawer ${settingsOpen ? 'open' : ''}`} aria-hidden={!settingsOpen}>
|
||||
<div className="drawer-title">
|
||||
<div><p>Настройки</p><h2>Игра</h2></div>
|
||||
<button onClick={() => setSettingsOpen(false)} aria-label="Закрыть настройки"><X /></button>
|
||||
</div>
|
||||
<label className="range-setting">
|
||||
<span><strong>Оперативная память</strong><b>{ram} ГБ</b></span>
|
||||
<input type="range" min="3" max="12" value={ram} onChange={(e) => updateRam(Number(e.target.value))} />
|
||||
<small>Для Aeronautics рекомендуется 6 ГБ</small>
|
||||
</label>
|
||||
<div className="setting-row static">
|
||||
<span><Users />Аккаунт</span>
|
||||
<small>{accountMode === 'offline' ? 'Offline' : (account ? account.name : 'Не авторизован')}</small>
|
||||
</div>
|
||||
{accountMode === 'offline' && (
|
||||
<label className="text-setting">
|
||||
<span><strong>Игровой ник</strong><small>Offline-профиль</small></span>
|
||||
<input value={nickname} maxLength={16} onChange={(event) => setNickname(event.target.value)} onBlur={saveNickname} placeholder="Player" />
|
||||
<small>Латинские буквы, цифры и _ · от 3 до 16 символов</small>
|
||||
</label>
|
||||
)}
|
||||
<div className="setting-row">
|
||||
<span>Тип аккаунта</span>
|
||||
<select
|
||||
value={accountMode}
|
||||
onChange={(e) => setMode(e.target.value as 'microsoft' | 'offline')}
|
||||
style={{ background: 'transparent', border: 0, color: 'inherit', textAlign: 'right' }}
|
||||
>
|
||||
<option value="offline">Offline</option>
|
||||
<option value="microsoft">Microsoft</option>
|
||||
</select>
|
||||
</div>
|
||||
{accountMode === 'microsoft' && account && (
|
||||
<button className="setting-row" onClick={logout}>
|
||||
<span><LogOut />Выйти из Microsoft</span>
|
||||
</button>
|
||||
)}
|
||||
{accountMode === 'microsoft' && !account && (
|
||||
<button className="setting-row" onClick={startLogin}>
|
||||
<span><LogOut />Войти через Microsoft</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="setting-row static">
|
||||
<span><FolderOpen />Папка игры</span>
|
||||
<small>{nativeHost ? 'В каталоге лаунчера' : 'Определяется…'}</small>
|
||||
</div>
|
||||
<div className="setting-row static">
|
||||
<span><Wrench />Java</span>
|
||||
<small>
|
||||
{java === undefined
|
||||
? 'Проверяем…'
|
||||
: java && java.major >= 21
|
||||
? `Java ${java.major} найдена`
|
||||
: java
|
||||
? `Нужна Java 21 · найдена ${java.major}`
|
||||
: 'Лаунчер установит Java 21 автоматически'}
|
||||
</small>
|
||||
</div>
|
||||
<button className="setting-row"><span><Wrench />Дополнительные параметры</span><ChevronRight /></button>
|
||||
<div className="drawer-note">
|
||||
{nativeHost ? `Данные лаунчера: ${nativeHost.dataDir}` : 'Java 21 будет управляться лаунчером автоматически.'}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode><App /></React.StrictMode>,
|
||||
)
|
||||
createRoot(root).render(<StrictMode><App /></StrictMode>)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { deepStrictEqual, equal, rejects } from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { createSerialQueue, createSubscription, errorMessage, singleFlight } from './async.ts'
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason: unknown) => void
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise; reject = rejectPromise
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
test('settings writes are serialized even while earlier requests are pending', async () => {
|
||||
const queue = createSerialQueue()
|
||||
const first = deferred<number>()
|
||||
const order: number[] = []
|
||||
const savedFirst = queue.enqueue(async () => { order.push(1); return first.promise })
|
||||
const savedSecond = queue.enqueue(async () => { order.push(2); return 2 })
|
||||
await Promise.resolve()
|
||||
deepStrictEqual(order, [1])
|
||||
first.resolve(1)
|
||||
equal(await savedFirst, 1)
|
||||
equal(await savedSecond, 2)
|
||||
deepStrictEqual(order, [1, 2])
|
||||
})
|
||||
|
||||
test('a failed settings write does not poison later saves', async () => {
|
||||
const queue = createSerialQueue()
|
||||
const failed = queue.enqueue(async () => { throw new Error('disk full') })
|
||||
const retried = queue.enqueue(async () => 'saved')
|
||||
await rejects(failed, /disk full/)
|
||||
equal(await retried, 'saved')
|
||||
await queue.settled()
|
||||
})
|
||||
|
||||
test('unmount before native registration still unregisters the late listener once', async () => {
|
||||
const registration = deferred<() => void>()
|
||||
let cleanupCount = 0
|
||||
const subscription = createSubscription([registration.promise])
|
||||
subscription.dispose()
|
||||
registration.resolve(() => { cleanupCount += 1 })
|
||||
await subscription.ready
|
||||
subscription.dispose()
|
||||
equal(cleanupCount, 1)
|
||||
})
|
||||
|
||||
test('partial listener failure cleans up successful and late registrations', async () => {
|
||||
const failed = deferred<() => void>()
|
||||
const late = deferred<() => void>()
|
||||
let cleanupCount = 0
|
||||
const subscription = createSubscription([
|
||||
Promise.resolve(() => { cleanupCount += 1 }), failed.promise, late.promise,
|
||||
])
|
||||
failed.reject(new Error('listen failed'))
|
||||
await rejects(subscription.ready, /listen failed/)
|
||||
equal(cleanupCount, 1)
|
||||
late.resolve(() => { cleanupCount += 1 })
|
||||
await Promise.resolve()
|
||||
equal(cleanupCount, 2)
|
||||
subscription.dispose()
|
||||
equal(cleanupCount, 2)
|
||||
})
|
||||
|
||||
test('Rust string errors remain visible instead of being replaced with generic copy', () => {
|
||||
equal(errorMessage('Invalid signature', 'fallback'), 'Invalid signature')
|
||||
equal(errorMessage(new Error('Disk full'), 'fallback'), 'Disk full')
|
||||
equal(errorMessage(null, 'fallback'), 'fallback')
|
||||
equal(errorMessage('', 'fallback'), 'fallback')
|
||||
})
|
||||
|
||||
test('overlapping account restores share one native request and do not cache the session', async () => {
|
||||
const first = deferred<string>()
|
||||
let calls = 0
|
||||
const restore = singleFlight(() => { calls += 1; return first.promise })
|
||||
const firstMount = restore()
|
||||
const strictModeRemount = restore()
|
||||
equal(firstMount, strictModeRemount)
|
||||
await Promise.resolve()
|
||||
equal(calls, 1)
|
||||
first.resolve('profile')
|
||||
equal(await strictModeRemount, 'profile')
|
||||
await restore()
|
||||
equal(calls, 2)
|
||||
})
|
||||
|
||||
test('failed account restore can be retried', async () => {
|
||||
let calls = 0
|
||||
const restore = singleFlight(async () => {
|
||||
calls += 1
|
||||
if (calls === 1) throw new Error('network unavailable')
|
||||
return 'profile'
|
||||
})
|
||||
await rejects(restore(), /network unavailable/)
|
||||
equal(await restore(), 'profile')
|
||||
equal(calls, 2)
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
export function errorMessage(error: unknown, fallback: string): string {
|
||||
if (error instanceof Error && error.message) return error.message
|
||||
// Tauri rejects commands with the Rust error string, not an Error instance.
|
||||
if (typeof error === 'string' && error.trim()) return error
|
||||
return fallback
|
||||
}
|
||||
|
||||
/** Share a pending restore across React StrictMode's effect restart. */
|
||||
export function singleFlight<T>(operation: () => Promise<T>): () => Promise<T> {
|
||||
let pending: Promise<T> | null = null
|
||||
return () => {
|
||||
if (pending) return pending
|
||||
const request = Promise.resolve().then(operation)
|
||||
pending = request
|
||||
const clear = () => { if (pending === request) pending = null }
|
||||
void request.then(clear, clear)
|
||||
return request
|
||||
}
|
||||
}
|
||||
|
||||
/** Serializes writes so a slow older save cannot overwrite a newer choice. */
|
||||
export function createSerialQueue() {
|
||||
let tail: Promise<unknown> = Promise.resolve()
|
||||
return {
|
||||
enqueue<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = tail.then(operation)
|
||||
// A failed write must not poison all subsequent retries.
|
||||
tail = result.catch(() => undefined)
|
||||
return result
|
||||
},
|
||||
settled: () => tail,
|
||||
}
|
||||
}
|
||||
|
||||
/** Handles unmount before asynchronous native listener registration finishes. */
|
||||
export function createSubscription(
|
||||
registrations: readonly Promise<() => void>[],
|
||||
) {
|
||||
let disposed = false
|
||||
const cleanups = new Set<() => void>()
|
||||
const dispose = () => {
|
||||
disposed = true
|
||||
cleanups.forEach((cleanup) => cleanup())
|
||||
cleanups.clear()
|
||||
}
|
||||
const ready = Promise.all(registrations.map(async (registration) => {
|
||||
const cleanup = await registration
|
||||
if (disposed) cleanup()
|
||||
else cleanups.add(cleanup)
|
||||
})).then(() => undefined).catch((error: unknown) => {
|
||||
dispose()
|
||||
throw error
|
||||
})
|
||||
return { ready, dispose }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { invoke, isTauri } from '@tauri-apps/api/core'
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
import { createSubscription, singleFlight } from './async'
|
||||
import type {
|
||||
DeviceCodePayload, GameExitedPayload, InstallProgressPayload,
|
||||
JavaInstallation, LauncherSettings, LoginResultPayload, MinecraftProfile,
|
||||
NativeHost, ProfileInspection, SyncResult,
|
||||
} from '../types/launcher'
|
||||
|
||||
export const isNative = () => typeof window !== 'undefined' && isTauri()
|
||||
const restoreAccount = singleFlight(() => invoke<MinecraftProfile | null>('get_account'))
|
||||
|
||||
// Keep the IPC contract in one place. UI components never invoke native
|
||||
// commands directly and cannot pass arbitrary URLs or filesystem paths.
|
||||
export const native = {
|
||||
host: () => invoke<NativeHost>('native_host'),
|
||||
loadSettings: () => invoke<LauncherSettings>('load_settings'),
|
||||
saveSettings: (settings: LauncherSettings) => invoke<LauncherSettings>('save_settings', { settings }),
|
||||
detectJava: () => invoke<JavaInstallation | null>('detect_java'),
|
||||
inspectProfile: (profileId: string) => invoke<ProfileInspection>('inspect_remote_profile', { profileId }),
|
||||
syncProfile: (profileId: string) => invoke<SyncResult>('sync_remote_profile', { profileId }),
|
||||
getAccount: restoreAccount,
|
||||
startLogin: () => invoke<void>('start_microsoft_login'),
|
||||
logout: () => invoke<void>('logout'),
|
||||
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 function watchGame(handlers: {
|
||||
progress: (payload: InstallProgressPayload) => void
|
||||
exited: (payload: GameExitedPayload) => void
|
||||
}) {
|
||||
return createSubscription([
|
||||
listen<InstallProgressPayload>('game-install-progress', ({ payload }) => handlers.progress(payload)),
|
||||
listen<GameExitedPayload>('game-exited', ({ payload }) => handlers.exited(payload)),
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { deepStrictEqual, equal, match } from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { gameReducer, initialGameState, installPercent } from './game.ts'
|
||||
|
||||
const profileId = 'aeronautics'
|
||||
const installing = gameReducer(initialGameState, { type: 'install', profileId })
|
||||
const launching = gameReducer(installing, { type: 'launch', profileId })
|
||||
|
||||
test('installation, running game and exit have distinct states', () => {
|
||||
const progressed = gameReducer(installing, { type: 'progress', progress: { stage: 'assets', currentBytes: 12, totalBytes: 24 } })
|
||||
equal(progressed.operation.phase, 'installing')
|
||||
const running = gameReducer(launching, { type: 'started', profileId })
|
||||
equal(running.operation.phase, 'running')
|
||||
deepStrictEqual(gameReducer(running, { type: 'exited', result: { profileId, exitCode: 0 } }), initialGameState)
|
||||
})
|
||||
|
||||
test('a fast child exit cannot be overwritten by a late launch acknowledgement', () => {
|
||||
const exited = gameReducer(launching, { type: 'exited', result: { profileId, exitCode: 1 } })
|
||||
const lateAcknowledgement = gameReducer(exited, { type: 'started', profileId })
|
||||
equal(lateAcknowledgement.operation.phase, 'idle')
|
||||
match(lateAcknowledgement.error ?? '', /кодом 1/)
|
||||
})
|
||||
|
||||
test('foreign exit events and late install progress cannot unlock a running game', () => {
|
||||
const running = gameReducer(launching, { type: 'started', profileId })
|
||||
equal(gameReducer(running, { type: 'exited', result: { profileId: 'other', exitCode: 0 } }), running)
|
||||
equal(gameReducer(running, { type: 'progress', progress: { stage: 'assets', currentBytes: 1, totalBytes: 1 } }), running)
|
||||
equal(gameReducer(running, { type: 'sync', profileId }), running)
|
||||
})
|
||||
|
||||
test('sync completion does not complete a different operation', () => {
|
||||
equal(gameReducer(installing, { type: 'synced', profileId }), installing)
|
||||
const syncing = gameReducer(initialGameState, { type: 'sync', profileId })
|
||||
equal(gameReducer(syncing, { type: 'synced', profileId: 'other' }), syncing)
|
||||
equal(gameReducer(syncing, { type: 'synced', profileId }), initialGameState)
|
||||
})
|
||||
|
||||
test('an operation failure releases the UI and a retry clears the error', () => {
|
||||
const failed = gameReducer(installing, { type: 'failed', error: 'Network failed' })
|
||||
equal(failed.operation.phase, 'idle')
|
||||
equal(failed.error, 'Network failed')
|
||||
equal(gameReducer(failed, { type: 'install', profileId }).error, null)
|
||||
})
|
||||
|
||||
test('percent is bounded and unknown or invalid totals stay indeterminate', () => {
|
||||
equal(installPercent(null), null)
|
||||
equal(installPercent({ stage: 'java', currentBytes: 1, totalBytes: 0 }), null)
|
||||
equal(installPercent({ stage: 'assets', currentBytes: NaN, totalBytes: 10 }), null)
|
||||
equal(installPercent({ stage: 'assets', currentBytes: 15, totalBytes: 10 }), 100)
|
||||
equal(installPercent({ stage: 'assets', currentBytes: -5, totalBytes: 10 }), 0)
|
||||
equal(installPercent({ stage: 'assets', currentBytes: 5, totalBytes: 20 }), 25)
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { GameExitedPayload, InstallProgressPayload } from '../types/launcher'
|
||||
|
||||
export type GameOperation =
|
||||
| { phase: 'idle' }
|
||||
| { phase: 'syncing' | 'launching' | 'running'; profileId: string }
|
||||
| { phase: 'installing'; profileId: string; progress: InstallProgressPayload | null }
|
||||
|
||||
export interface GameState {
|
||||
operation: GameOperation
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export type GameAction =
|
||||
| { type: 'sync'; profileId: string }
|
||||
| { type: 'synced'; profileId: string }
|
||||
| { type: 'install'; profileId: string }
|
||||
| { type: 'progress'; progress: InstallProgressPayload }
|
||||
| { type: 'launch'; profileId: string }
|
||||
| { type: 'started'; profileId: string }
|
||||
| { type: 'exited'; result: GameExitedPayload }
|
||||
| { type: 'failed'; error: string }
|
||||
|
||||
export const initialGameState: GameState = { operation: { phase: 'idle' }, error: null }
|
||||
|
||||
export function gameReducer(state: GameState, action: GameAction): GameState {
|
||||
const operation = state.operation
|
||||
switch (action.type) {
|
||||
case 'sync':
|
||||
case 'install':
|
||||
if (operation.phase !== 'idle') return state
|
||||
return {
|
||||
operation: action.type === 'sync'
|
||||
? { phase: 'syncing', profileId: action.profileId }
|
||||
: { phase: 'installing', profileId: action.profileId, progress: null },
|
||||
error: null,
|
||||
}
|
||||
case 'synced':
|
||||
return operation.phase === 'syncing' && operation.profileId === action.profileId
|
||||
? initialGameState : state
|
||||
case 'progress':
|
||||
return operation.phase === 'installing'
|
||||
? { ...state, operation: { ...operation, progress: action.progress } } : state
|
||||
case 'launch':
|
||||
return operation.phase === 'installing' && operation.profileId === action.profileId
|
||||
? { ...state, operation: { phase: 'launching', profileId: action.profileId } } : state
|
||||
case 'started':
|
||||
// A fast-exiting child can emit game-exited before invoke resolves.
|
||||
return operation.phase === 'launching' && operation.profileId === action.profileId
|
||||
? { ...state, operation: { phase: 'running', profileId: action.profileId } } : state
|
||||
case 'exited':
|
||||
if ((operation.phase !== 'launching' && operation.phase !== 'running') ||
|
||||
operation.profileId !== action.result.profileId) return state
|
||||
return {
|
||||
operation: { phase: 'idle' },
|
||||
error: action.result.exitCode === 0 ? null
|
||||
: action.result.exitCode === null ? 'Игра завершилась без кода выхода. Проверьте журнал игры.'
|
||||
: `Игра завершилась с кодом ${action.result.exitCode}. Проверьте журнал игры.`,
|
||||
}
|
||||
case 'failed':
|
||||
return { operation: { phase: 'idle' }, error: action.error }
|
||||
}
|
||||
}
|
||||
|
||||
export const installStageLabels: Record<InstallProgressPayload['stage'], string> = {
|
||||
java: 'Готовим Java',
|
||||
neoforge: 'Устанавливаем NeoForge',
|
||||
libraries: 'Скачиваем библиотеки',
|
||||
assets: 'Скачиваем ресурсы игры',
|
||||
}
|
||||
|
||||
export function installPercent(progress: InstallProgressPayload | null): number | null {
|
||||
if (!progress || progress.totalBytes <= 0 || !Number.isFinite(progress.totalBytes) ||
|
||||
!Number.isFinite(progress.currentBytes)) return null
|
||||
return Math.max(0, Math.min(100, Math.round(progress.currentBytes / progress.totalBytes * 100)))
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { equal } from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { profilesReducer } from './profiles.ts'
|
||||
|
||||
test('a failed repair invalidates an old ready inspection and permits retry', () => {
|
||||
const profileId = 'aeronautics'
|
||||
const inspection = { root: '/profiles/aeronautics', managedFiles: 251, missingFiles: 0, mismatchedFiles: 0, upToDate: true }
|
||||
const ready = profilesReducer({}, { type: 'checked', profileId, inspection })
|
||||
equal(ready[profileId]?.inspection?.upToDate, true)
|
||||
const repairing = profilesReducer(ready, { type: 'check', profileId })
|
||||
equal(repairing[profileId]?.inspection, null)
|
||||
const failed = profilesReducer(repairing, { type: 'failed', profileId, error: 'Download interrupted' })
|
||||
equal(failed[profileId]?.status, 'error')
|
||||
equal(failed[profileId]?.inspection, null)
|
||||
const retrying = profilesReducer(failed, { type: 'check', profileId })
|
||||
const repaired = profilesReducer(retrying, { type: 'checked', profileId, inspection })
|
||||
equal(repaired[profileId]?.inspection?.upToDate, true)
|
||||
equal(repaired[profileId]?.error, null)
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ProfileInspection } from '../types/launcher'
|
||||
|
||||
export type ProfileState =
|
||||
| { status: 'checking'; inspection: null; error: null }
|
||||
| { status: 'checked'; inspection: ProfileInspection; error: null }
|
||||
| { status: 'error'; inspection: null; error: string }
|
||||
|
||||
type ProfileAction =
|
||||
| { type: 'check'; profileId: string }
|
||||
| { type: 'checked'; profileId: string; inspection: ProfileInspection }
|
||||
| { type: 'failed'; profileId: string; error: string }
|
||||
|
||||
export function profilesReducer(
|
||||
profiles: Record<string, ProfileState>, action: ProfileAction,
|
||||
): Record<string, ProfileState> {
|
||||
const next: ProfileState = action.type === 'check'
|
||||
? { status: 'checking', inspection: null, error: null }
|
||||
: action.type === 'checked'
|
||||
? { status: 'checked', inspection: action.inspection, error: null }
|
||||
: { status: 'error', inspection: null, error: action.error }
|
||||
return { ...profiles, [action.profileId]: next }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { LauncherSettings } from '../types/launcher'
|
||||
|
||||
export const defaultSettings: LauncherSettings = {
|
||||
memoryMb: 6 * 1024,
|
||||
nickname: 'Emil',
|
||||
accountMode: 'offline',
|
||||
}
|
||||
|
||||
export const isValidNickname = (nickname: string) => /^[A-Za-z0-9_]{3,16}$/.test(nickname)
|
||||
+13
-5
@@ -1,6 +1,7 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Unbounded:wght@500;600&display=swap');
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: 'Manrope', sans-serif;
|
||||
color: #eef2ed;
|
||||
background: #090c0a;
|
||||
@@ -17,10 +18,11 @@
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
button, input { font: inherit; }
|
||||
button, input, select { font: inherit; }
|
||||
button { color: inherit; }
|
||||
body { margin: 0; min-width: 1040px; min-height: 680px; overflow: hidden; }
|
||||
button:focus-visible, input:focus-visible { outline: 2px solid var(--green); outline-offset: 2px; }
|
||||
button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid var(--green); outline-offset: 2px; }
|
||||
button:disabled { cursor: default; }
|
||||
|
||||
.app-shell {
|
||||
height: 100vh;
|
||||
@@ -77,6 +79,7 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--green); out
|
||||
.account-chip strong { font-size: 12px; }
|
||||
.account-chip small { color: #6d756e; font-size: 9px; }
|
||||
.account-chip svg { color: #555e56; }
|
||||
.account-logout { padding: 0; background: transparent; border: 0; cursor: pointer; color: inherit; }
|
||||
|
||||
.stage { position: relative; overflow: hidden; isolation: isolate; background: #16231b; }
|
||||
.stage::before { content: ''; position: absolute; inset: 0; z-index: -5; background:
|
||||
@@ -125,7 +128,7 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--green); out
|
||||
|
||||
.drawer-backdrop { position: fixed; inset: 48px 0 0; background: rgba(0,0,0,.44); opacity: 0; pointer-events: none; transition: opacity .2s; z-index: 20; }
|
||||
.drawer-backdrop.visible { opacity: 1; pointer-events: auto; }
|
||||
.settings-drawer { position: fixed; top: 48px; right: 0; bottom: 0; width: 390px; background: #121713; border-left: 1px solid var(--line); z-index: 21; padding: 28px; transform: translateX(100%); transition: transform .24s cubic-bezier(.2,.8,.2,1); box-shadow: -30px 0 70px rgba(0,0,0,.35); }
|
||||
.settings-drawer { position: fixed; top: 48px; right: 0; bottom: 0; width: 390px; overflow-y: auto; background: #121713; border-left: 1px solid var(--line); z-index: 21; padding: 28px; transform: translateX(100%); transition: transform .24s cubic-bezier(.2,.8,.2,1); box-shadow: -30px 0 70px rgba(0,0,0,.35); }
|
||||
.settings-drawer.open { transform: translateX(0); }
|
||||
.drawer-title { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 34px; }
|
||||
.drawer-title p { color: #737c74; font-size: 11px; margin: 0 0 5px; }
|
||||
@@ -144,13 +147,18 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--green); out
|
||||
.setting-row.static { cursor: default; }
|
||||
.setting-row.static:hover { color: #abb3ac; }
|
||||
.setting-row small { color: #7f8a80; font-size: 11px; }
|
||||
.setting-row select { background: #121713; border: 0; color: inherit; text-align: right; }
|
||||
.text-setting { display: grid; gap: 9px; padding: 18px 0; border-bottom: 1px solid var(--line); }
|
||||
.text-setting > span { display: flex; align-items: baseline; justify-content: space-between; }
|
||||
.text-setting strong { font-size: 12px; }
|
||||
.text-setting span small, .text-setting > small { color: #7f8a80; font-size: 11px; }
|
||||
.text-setting input { width: 100%; box-sizing: border-box; background: #0d110e; color: #eef2ed; border: 1px solid #384239; padding: 10px 11px; font: 600 13px/1 Manrope, sans-serif; outline: none; }
|
||||
.text-setting input:focus { border-color: var(--accent); }
|
||||
.drawer-note { position: absolute; left: 28px; right: 28px; bottom: 26px; padding: 13px 15px; background: #17231a; color: #8fb193; border-radius: 10px; font-size: 10px; }
|
||||
.text-setting input:focus { border-color: var(--green); }
|
||||
.drawer-note { margin-top: 24px; overflow-wrap: anywhere; padding: 13px 15px; background: #17231a; color: #8fb193; border-radius: 10px; font-size: 10px; }
|
||||
.settings-feedback { font-size: 11px; color: var(--muted); }
|
||||
.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; }
|
||||
|
||||
.login-modal { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 22; width: 360px; background: #121713; border: 1px solid var(--line); border-radius: 14px; padding: 26px; text-align: center; box-shadow: 0 30px 80px rgba(0,0,0,.5); }
|
||||
.login-modal h2 { margin: 0 0 10px; font-family: 'Unbounded'; font-size: 18px; }
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
export type AccountMode = 'microsoft' | 'offline'
|
||||
|
||||
export interface Server {
|
||||
id: string
|
||||
kicker: string
|
||||
name: string
|
||||
subtitle: string
|
||||
version: string
|
||||
composition: string
|
||||
loader: string
|
||||
profileId?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface NativeHost {
|
||||
platform: string
|
||||
dataDir: string
|
||||
launcherVersion: string
|
||||
}
|
||||
|
||||
export interface LauncherSettings {
|
||||
memoryMb: number
|
||||
nickname: string
|
||||
accountMode: AccountMode
|
||||
}
|
||||
|
||||
export interface JavaInstallation {
|
||||
executable: string
|
||||
major: number
|
||||
version: string
|
||||
}
|
||||
|
||||
export interface ProfileInspection {
|
||||
root: string
|
||||
managedFiles: number
|
||||
missingFiles: number
|
||||
mismatchedFiles: number
|
||||
upToDate: boolean
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
root: string
|
||||
downloadedFiles: number
|
||||
reusedFiles: number
|
||||
downloadedBytes: number
|
||||
}
|
||||
|
||||
export interface MinecraftProfile {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface DeviceCodePayload {
|
||||
verificationUri: string
|
||||
userCode: string
|
||||
expiresInSeconds: number
|
||||
}
|
||||
|
||||
export interface LoginResultPayload {
|
||||
ok: boolean
|
||||
profile: MinecraftProfile | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface InstallProgressPayload {
|
||||
stage: 'java' | 'neoforge' | 'libraries' | 'assets'
|
||||
currentBytes: number
|
||||
totalBytes: number
|
||||
}
|
||||
|
||||
export interface GameExitedPayload {
|
||||
profileId: string
|
||||
exitCode: number | null
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["node", "vite/client"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user