refactor: modularize launcher UI and native services with verified IO

This commit is contained in:
emil28092005
2026-09-09 12:55:15 +03:00
parent cc19a24e45
commit cde13c4489
51 changed files with 3307 additions and 1118 deletions
+20 -3
View File
@@ -3,6 +3,9 @@ name: Cross-platform build
on: on:
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
jobs: jobs:
build: build:
name: ${{ matrix.name }} name: ${{ matrix.name }}
@@ -14,13 +17,13 @@ jobs:
os: ubuntu-22.04 os: ubuntu-22.04
args: --bundles appimage,deb args: --bundles appimage,deb
- name: Windows x64 - name: Windows x64
os: windows-latest os: windows-2022
args: --bundles nsis,msi args: --bundles nsis,msi
- name: macOS Apple Silicon - name: macOS Apple Silicon
os: macos-14 os: macos-15
args: --target aarch64-apple-darwin --bundles dmg args: --target aarch64-apple-darwin --bundles dmg
- name: macOS Intel - name: macOS Intel
os: macos-13 os: macos-15-intel
args: --target x86_64-apple-darwin --bundles dmg args: --target x86_64-apple-darwin --bundles dmg
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
@@ -32,5 +35,19 @@ jobs:
node-version: 22 node-version: 22
cache: npm cache: npm
- uses: dtolnay/rust-toolchain@stable - 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 ci
- run: npm test
- run: cargo test --locked --manifest-path src-tauri/Cargo.toml
- run: npm run tauri:build -- ${{ matrix.args }} - 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/**
+32
View File
@@ -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
+21 -3
View File
@@ -48,9 +48,20 @@ payload are in `/root/shacraft` on the ShaCraft host; see
## Layout ## Layout
- `src/main.tsx` — UI state and Tauri command calls; do not put privileged - `src/main.tsx` — React entrypoint; `src/App.tsx` composes the screen.
operations in the web layer. - `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. - `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, - `download.rs` — shared verified-download helper (temp file, hash,
atomic rename, progress callback); `manifest.rs`/`profile.rs` (ShaCraft atomic rename, progress callback); `manifest.rs`/`profile.rs` (ShaCraft
mods) and `mojang.rs`/`neoforge.rs`/`runtime.rs` (the game itself) all 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). (mods/config only).
- `docs/game-trust-boundary.md` — the Mojang/NeoForge/Microsoft/Adoptium - `docs/game-trust-boundary.md` — the Mojang/NeoForge/Microsoft/Adoptium
trust domains used to install and run the game itself. 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 ## Verification
Run from repository root: Run from repository root:
```bash ```bash
npm ci
npm test
npm run build npm run build
(cd src-tauri && /home/emil/.cargo/bin/cargo test) (cd src-tauri && /home/emil/.cargo/bin/cargo test)
npm run tauri:dev 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 `tauri:dev` is for local desktop testing. A successful web build alone does
not prove Tauri commands work. 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 ## Working conventions
- Keep UI copy in Russian; code, identifiers and errors may remain English. - Keep UI copy in Russian; code, identifiers and errors may remain English.
+30
View File
@@ -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.
Не менять этот протокол незаметно в клиентском рефакторинге.
+32 -24
View File
@@ -1,41 +1,49 @@
# ShaCraft Launcher # ShaCraft Launcher
Кроссплатформенный лаунчер для сети Minecraft-серверов ShaCraft. Кроссплатформенный Tauri 2 лаунчер для [ShaCraft](https://shacraft.ru/):
React/TypeScript интерфейс, Rust — файлы, сеть и запуск процессов.
Сейчас реализовано: Реализованы подписанная синхронизация Aeronautics, проверка/восстановление
модов и конфигурации, Java discovery/provisioning, bootstrap Minecraft и
NeoForge, настройки памяти и ника, обработка установки/запуска/выхода.
- интерактивный интерфейс профилей и настроек; Режим аккаунта выбирается явно: offline-профиль или Microsoft. Код Microsoft
- Tauri 2 native shell с безопасной командой `native_host`; OAuth/проверки владения готов, но **вход ещё требует собственного client ID и
- сохранение памяти профиля в локальном каталоге данных приложения; одобрения Minecraft API**. Offline не подставляется при ошибке Microsoft.
- безопасное обнаружение установленной Java (включая `JAVA_HOME`) перед запуском;
- интеграция с фиксированным ShaCraft launcher v2 manifest для Aeronautics;
- адаптивное окно для Windows, Linux и macOS;
- вход через настоящий аккаунт Microsoft (без него игра не устанавливается
и не запускается — так владение игрой проверяется по-настоящему);
- установка Minecraft и NeoForge версии, которую задаёт manifest, и запуск
игры.
## Локальная разработка ## Разработка
```bash ```bash
npm install npm ci
npm run dev npm run dev
``` ```
Для нативного приложения нужен Rust: Это браузерный preview — он не устанавливает и не запускает игру.
Для приложения нужен Rust и системные зависимости Tauri:
```bash ```bash
npm run tauri:dev 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 уже работают. Вход Build включает строгий TypeScript. GitHub Actions проверяет UI и Rust на
через Microsoft пока не активен на боевой сборке: нужна собственная push/PR; ручной workflow собирает пакеты Windows x64, Linux x64, macOS Intel
регистрация приложения ShaCraft в Azure AD и её одобрение Microsoft для и Apple Silicon и сохраняет артефакты. Подпись релиза/автообновления ещё впереди.
доступа к Minecraft API — см. комментарий к `MSA_CLIENT_ID` в Используемые macOS runners соответствуют [списку GitHub](https://docs.github.com/en/actions/reference/runners/github-hosted-runners).
`src-tauri/src/msa.rs`.
## Навигация
- [Архитектура](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 токены, ключи, пользовательские данные или пакеты игры.
+31 -4
View File
@@ -28,7 +28,7 @@ Game itself (never controlled by the manifest above)
-> Java 21 via Adoptium if none installed (runtime.rs) -> Java 21 via Adoptium if none installed (runtime.rs)
-> NeoForge's own installer, run headlessly (neoforge.rs) -> NeoForge's own installer, run headlessly (neoforge.rs)
-> generic inheritsFrom merge of the two version JSONs (mojang.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) -> 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 - ShaCraft download files: HTTPS only, exact hosts `shacraft.ru` and
`cdn.shacraft.ru`. `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 ## Planned but not implemented
1. User-selectable profile directory and structured launcher logs. 1. User-selectable profile directory and structured launcher logs.
2. "Reset managed files only" recovery action that doesn't touch player 2. "Reset managed files only" recovery action that doesn't touch player
worlds/screenshots/resourcepacks. worlds/screenshots/resourcepacks.
3. Signed, cross-platform release builds of the launcher itself. 3. Signed, cross-platform release builds of the launcher itself.
4. Real per-stage byte progress for the Java/NeoForge install steps 4. Cancellation, structured logs and full cold-install/recovery beta on
(currently start/done only — the dominant, user-visible wait, asset every target OS. Current install progress reports actual stage work;
downloading, already reports real bytes). bytes and installer completion counts are not interchangeable units.
Do not represent these as completed features in UI or release notes. Do not represent these as completed features in UI or release notes.
+587 -37
View File
File diff suppressed because it is too large Load Diff
+14 -10
View File
@@ -5,23 +5,27 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "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", "preview": "vite preview",
"tauri": "tauri", "tauri": "tauri",
"tauri:dev": "tauri dev", "tauri:dev": "tauri dev",
"tauri:build": "tauri build" "tauri:build": "tauri build"
}, },
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.11.1", "@tauri-apps/api": "2.11.1",
"@vitejs/plugin-react": "latest", "lucide-react": "1.41.0",
"@tauri-apps/cli": "^2.9.4", "react": "19.2.8",
"lucide-react": "latest", "react-dom": "19.2.8"
"react": "latest",
"react-dom": "latest",
"typescript": "latest",
"vite": "latest"
}, },
"devDependencies": { "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"
} }
} }
+168
View File
@@ -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)
);
}
}
+234
View File
@@ -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}"))?
}
+38
View File
@@ -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())
}
+15
View File
@@ -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}"))
}
+24
View File
@@ -0,0 +1,24 @@
use super::data_dir;
use crate::settings;
use tauri::AppHandle;
#[tauri::command]
pub(crate) async fn load_settings(app: AppHandle) -> Result<settings::LauncherSettings, String> {
let data_dir = data_dir(&app)?;
tauri::async_runtime::spawn_blocking(move || settings::load(&data_dir))
.await
.map_err(|error| format!("Settings task failed: {error}"))?
.map_err(|error| error.to_string())
}
#[tauri::command]
pub(crate) async fn save_settings(
app: AppHandle,
settings: settings::LauncherSettings,
) -> Result<settings::LauncherSettings, String> {
let data_dir = data_dir(&app)?;
tauri::async_runtime::spawn_blocking(move || settings::save(&data_dir, settings))
.await
.map_err(|error| format!("Settings task failed: {error}"))?
.map_err(|error| error.to_string())
}
+38
View File
@@ -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
View File
@@ -1,11 +1,12 @@
use reqwest::blocking::{Client, Response}; use crate::storage::AtomicFile;
use reqwest::blocking::Client;
use sha1::Sha1; use sha1::Sha1;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::{ use std::{
fmt, fmt,
fs::{self, File}, fs::{self, File},
io::{self, Read, Write}, io::{self, Read, Write},
path::{Path, PathBuf}, path::Path,
sync::Arc, sync::Arc,
}; };
@@ -73,12 +74,19 @@ pub fn file_hashes(path: &Path) -> io::Result<(String, String)> {
sha1.update(&buffer[..read]); sha1.update(&buffer[..read]);
sha256.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 /// True if `path` already exists, matches `expected_size` (when given) and
/// `checksum`. Used to skip re-downloading files that are already current. /// `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() { let metadata = match path.metadata() {
Ok(metadata) => metadata, Ok(metadata) => metadata,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), 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)) 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 /// Downloads `url` to `target`, verifying size (if known ahead of time) and
/// `checksum` before atomically renaming the temporary file into place. /// `checksum` before atomically renaming the temporary file into place.
/// `on_progress(downloaded_bytes, total_bytes)` is called after every chunk; /// `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()); let total = expected_size.or_else(|| response.content_length());
if let (Some(expected), Some(length)) = (expected_size, response.content_length()) { if let (Some(expected), Some(length)) = (expected_size, response.content_length()) {
if expected != length { if expected != length {
return Err(DownloadError::SizeMismatch { expected, actual: length }); return Err(DownloadError::SizeMismatch {
expected,
actual: length,
});
} }
} }
let temporary = temp_path(target)?; let mut output = AtomicFile::new(target).map_err(DownloadError::Io)?;
let result = write_and_verify(&mut response, &temporary, expected_size, checksum, total, &mut on_progress); let bytes = write_and_verify(
if let Err(error) = result { &mut response,
let _ = fs::remove_file(&temporary); output.writer(),
return Err(error); expected_size,
} checksum,
let bytes = result.unwrap(); total,
fs::rename(&temporary, target).map_err(DownloadError::Io)?; &mut on_progress,
)?;
output.commit().map_err(DownloadError::Io)?;
Ok(bytes) Ok(bytes)
} }
fn write_and_verify( fn write_and_verify(
response: &mut Response, response: &mut impl Read,
temporary: &Path, output: &mut impl Write,
expected_size: Option<u64>, expected_size: Option<u64>,
checksum: &Checksum, checksum: &Checksum,
total: Option<u64>, total: Option<u64>,
on_progress: &mut impl FnMut(u64, Option<u64>), on_progress: &mut impl FnMut(u64, Option<u64>),
) -> Result<u64, DownloadError> { ) -> Result<u64, DownloadError> {
let mut output = File::create(temporary).map_err(DownloadError::Io)?;
let mut sha1 = Sha1::new(); let mut sha1 = Sha1::new();
let mut sha256 = Sha256::new(); let mut sha256 = Sha256::new();
let mut bytes = 0_u64; let mut bytes = 0_u64;
@@ -160,17 +164,29 @@ fn write_and_verify(
if read == 0 { if read == 0 {
break; 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]); sha1.update(&buffer[..read]);
sha256.update(&buffer[..read]); sha256.update(&buffer[..read]);
bytes += read as u64;
on_progress(bytes, total); on_progress(bytes, total);
} }
output.sync_all().map_err(DownloadError::Io)?;
if let Some(expected) = expected_size { if let Some(expected) = expected_size {
if bytes != expected { if bytes != expected {
return Err(DownloadError::SizeMismatch { expected, actual: bytes }); return Err(DownloadError::SizeMismatch {
expected,
actual: bytes,
});
} }
} }
let sha1_hex = format!("{:x}", sha1.finalize()); let sha1_hex = format!("{:x}", sha1.finalize());
@@ -184,13 +200,19 @@ fn write_and_verify(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{file_hashes, is_current, Checksum}; 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 { fn temp_file(contents: &[u8]) -> std::path::PathBuf {
let path = std::env::temp_dir().join(format!( let path = std::env::temp_dir().join(format!(
"shacraft-download-test-{}-{}", "shacraft-download-test-{}-{}",
process::id(), 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(); fs::write(&path, contents).unwrap();
path path
@@ -201,7 +223,10 @@ mod tests {
let path = temp_file(b"hello shacraft"); let path = temp_file(b"hello shacraft");
let (sha1_hex, sha256_hex) = file_hashes(&path).unwrap(); let (sha1_hex, sha256_hex) = file_hashes(&path).unwrap();
assert_eq!(sha1_hex, "124b319646ec08b4fb2a2b65bbd21c0431b4eaf4"); assert_eq!(sha1_hex, "124b319646ec08b4fb2a2b65bbd21c0431b4eaf4");
assert_eq!(sha256_hex, "d34eb8ea6396e8492109813c717f7eefd0437c10ff55a7b11949cfae900c946d"); assert_eq!(
sha256_hex,
"d34eb8ea6396e8492109813c717f7eefd0437c10ff55a7b11949cfae900c946d"
);
fs::remove_file(path).unwrap(); fs::remove_file(path).unwrap();
} }
@@ -220,4 +245,57 @@ mod tests {
let path = std::env::temp_dir().join("shacraft-download-test-missing-file-xyz"); let path = std::env::temp_dir().join("shacraft-download-test-missing-file-xyz");
assert!(!is_current(&path, None, &Checksum::Sha256("0".repeat(64))).unwrap()); 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
View File
@@ -10,399 +10,28 @@ mod remote;
mod runtime; mod runtime;
mod session; mod session;
mod settings; mod settings;
mod storage;
mod trusted_http;
use reqwest::blocking::Client; mod commands;
use serde::Serialize; mod operations;
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}"))?
}
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.manage(operations::LauncherOperations::default())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
native_host, commands::host::native_host,
detect_java, commands::host::detect_java,
validate_manifest, commands::host::validate_manifest,
inspect_profile, commands::profiles::inspect_remote_profile,
sync_profile, commands::profiles::sync_remote_profile,
inspect_remote_profile, commands::preferences::load_settings,
sync_remote_profile, commands::preferences::save_settings,
load_settings, commands::account::start_microsoft_login,
save_settings, commands::account::get_account,
start_microsoft_login, commands::account::logout,
get_account, commands::game::ensure_game_installed,
logout, commands::game::launch_game
ensure_game_installed,
launch_game
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running ShaCraft Launcher"); .expect("error while running ShaCraft Launcher");
+104 -23
View File
@@ -1,6 +1,5 @@
use serde::Deserialize; use serde::Deserialize;
use std::{collections::HashSet, fmt}; use std::{collections::HashSet, fmt};
use url::Url;
const MAX_MANIFEST_BYTES: usize = 2 * 1024 * 1024; const MAX_MANIFEST_BYTES: usize = 2 * 1024 * 1024;
const CURRENT_SCHEMA_VERSION: u32 = 1; const CURRENT_SCHEMA_VERSION: u32 = 1;
@@ -82,19 +81,27 @@ fn validate(manifest: &Manifest) -> Result<(), ManifestError> {
))); )));
} }
if !is_identifier(&manifest.id) { 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() { 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.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) { 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(); let mut paths = HashSet::new();
@@ -103,19 +110,35 @@ fn validate(manifest: &Manifest) -> Result<(), ManifestError> {
FilePolicy::Managed | FilePolicy::Seed => {} FilePolicy::Managed | FilePolicy::Seed => {}
} }
if !is_safe_relative_path(&file.path) { 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) { // A manifest must resolve to the same distinct files on Windows/macOS.
return Err(ManifestError::Invalid(format!("Duplicate file path: {}", file.path))); if !paths.insert(file.path.to_lowercase()) {
return Err(ManifestError::Invalid(format!(
"Duplicate file path: {}",
file.path
)));
} }
if !is_allowed_download_url(&file.url) { 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 { 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()) { 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(()) Ok(())
@@ -124,22 +147,49 @@ fn validate(manifest: &Manifest) -> Result<(), ManifestError> {
fn is_identifier(value: &str) -> bool { fn is_identifier(value: &str) -> bool {
!value.is_empty() !value.is_empty()
&& value.len() <= 48 && 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 { fn is_safe_relative_path(value: &str) -> bool {
!value.is_empty() !value.is_empty() && value.split('/').all(is_portable_component)
&& !value.starts_with('/') }
&& !value.starts_with('\\')
&& !value.contains('\\') pub(crate) fn is_portable_component(value: &str) -> bool {
&& !value.split('/').any(|part| part.is_empty() || part == "." || part == "..") 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 { pub(crate) fn is_allowed_download_url(value: &str) -> bool {
let Ok(url) = Url::parse(value) else { crate::trusted_http::allows(value, &DOWNLOAD_HOSTS)
return false;
};
url.scheme() == "https" && url.host_str().is_some_and(|host| DOWNLOAD_HOSTS.contains(&host))
} }
#[cfg(test)] #[cfg(test)]
@@ -179,4 +229,35 @@ mod tests {
fn rejects_third_party_download_hosts() { fn rejects_third_party_download_hosts() {
assert!(validate_json(&VALID.replace("cdn.shacraft.ru", "example.com")).is_err()); 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());
}
}
} }
+5 -2
View File
@@ -25,7 +25,6 @@ use std::{
Arc, Mutex, Arc, Mutex,
}, },
}; };
use url::Url;
const VERSION_MANIFEST_URL: &str = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"; const VERSION_MANIFEST_URL: &str = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json";
const MOJANG_HOSTS: [&str; 4] = [ const MOJANG_HOSTS: [&str; 4] = [
@@ -41,7 +40,11 @@ const MOJANG_HOSTS: [&str; 4] = [
const ASSET_WORKERS: usize = 48; const ASSET_WORKERS: usize = 48;
pub fn is_allowed_host(url: &str) -> bool { 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)] #[derive(Debug)]
+8 -8
View File
@@ -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 MINECRAFT_PROFILE_URL: &str = "https://api.minecraftservices.com/minecraft/profile";
const ACCOUNT_FILE: &str = "account.json"; 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)] #[derive(Debug)]
pub enum MsaError { pub enum MsaError {
NotConfigured, 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 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 target = data_dir.join(ACCOUNT_FILE);
let temporary = data_dir.join(".account.json.shacraft.part"); crate::storage::write_atomic(&target, &contents)
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)
} }
pub fn load_refresh_token(data_dir: &Path) -> Option<String> { pub fn load_refresh_token(data_dir: &Path) -> Option<String> {
+5 -2
View File
@@ -39,7 +39,6 @@ use std::{
}, },
thread, thread,
}; };
use url::Url;
const NEOFORGE_HOST: &str = "maven.neoforged.net"; const NEOFORGE_HOST: &str = "maven.neoforged.net";
@@ -89,7 +88,11 @@ impl From<io::Error> for NeoForgeError {
} }
fn is_allowed_host(url: &str) -> bool { 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 { fn installer_jar_url(loader_version: &str) -> String {
+51
View File
@@ -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
View File
@@ -2,7 +2,11 @@ use crate::download::{self, Checksum, DownloadError};
use crate::manifest::{is_allowed_download_url, FilePolicy, ManagedFile, Manifest}; use crate::manifest::{is_allowed_download_url, FilePolicy, ManagedFile, Manifest};
use reqwest::{blocking::Client, redirect::Policy}; use reqwest::{blocking::Client, redirect::Policy};
use serde::Serialize; use serde::Serialize;
use std::{fmt, io, path::Path}; use std::{
fmt, fs, io,
path::{Path, PathBuf},
time::Duration,
};
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -28,14 +32,22 @@ pub enum ProfileError {
Io(io::Error), Io(io::Error),
Network(reqwest::Error), Network(reqwest::Error),
Download { path: String, source: DownloadError }, Download { path: String, source: DownloadError },
UnsafePath(PathBuf),
} }
impl fmt::Display for ProfileError { impl fmt::Display for ProfileError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { 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::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; let mut mismatched_files = 0;
for expected in &manifest.files { for expected in &manifest.files {
let path = root.join(&expected.path); let path = managed_target(root, &expected.path)?;
if !path.exists() { if !path.try_exists().map_err(ProfileError::Io)? {
missing_files += 1; missing_files += 1;
continue; continue;
} }
if matches!(expected.policy, FilePolicy::Seed) && path.is_file() {
continue;
}
let checksum = Checksum::Sha256(expected.sha256.clone()); let checksum = Checksum::Sha256(expected.sha256.clone());
if !download::is_current(&path, Some(expected.size), &checksum).map_err(ProfileError::Io)? { if !download::is_current(&path, Some(expected.size), &checksum).map_err(ProfileError::Io)? {
mismatched_files += 1; 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> { pub fn sync(root: &Path, manifest: &Manifest) -> Result<SyncResult, ProfileError> {
let client = Client::builder() let client = Client::builder()
.connect_timeout(Duration::from_secs(15))
.timeout(Duration::from_secs(10 * 60))
.redirect(Policy::custom(|attempt| { .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() attempt.follow()
} else { } else {
attempt.stop() attempt.stop()
@@ -82,13 +101,15 @@ pub fn sync(root: &Path, manifest: &Manifest) -> Result<SyncResult, ProfileError
let mut downloaded_bytes = 0; let mut downloaded_bytes = 0;
for expected in &manifest.files { for expected in &manifest.files {
let target = root.join(&expected.path); let target = managed_target(root, &expected.path)?;
if matches!(expected.policy, FilePolicy::Seed) && target.exists() { if matches!(expected.policy, FilePolicy::Seed) && target.is_file() {
reused_files += 1; reused_files += 1;
continue; continue;
} }
let checksum = Checksum::Sha256(expected.sha256.clone()); 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; reused_files += 1;
continue; 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()); let checksum = Checksum::Sha256(expected.sha256.clone());
download::download_verified(client, &expected.url, target, Some(expected.size), &checksum, |_, _| {}).map_err(|error| { download::download_verified(
ProfileError::Download { path: expected.path.clone(), source: error } 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 super::inspect;
use crate::manifest::{FilePolicy, Loader, ManagedFile, Manifest, Minecraft}; use crate::manifest::{FilePolicy, Loader, ManagedFile, Manifest, Minecraft};
use sha2::{Digest, Sha256}; 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 { fn manifest(hash: String, size: u64) -> Manifest {
Manifest { Manifest {
@@ -127,7 +192,10 @@ mod tests {
display_name: "Aeronautics".into(), display_name: "Aeronautics".into(),
minecraft: Minecraft { minecraft: Minecraft {
version: "1.21.1".into(), 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, java_major: 21,
}, },
files: vec![ManagedFile { files: vec![ManagedFile {
@@ -145,7 +213,10 @@ mod tests {
let root = std::env::temp_dir().join(format!( let root = std::env::temp_dir().join(format!(
"shacraft-launcher-test-{}-{}", "shacraft-launcher-test-{}-{}",
process::id(), 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 bytes = b"ShaCraft test file";
let digest = format!("{:x}", Sha256::digest(bytes)); let digest = format!("{:x}", Sha256::digest(bytes));
@@ -164,4 +235,44 @@ mod tests {
fs::remove_dir_all(root).unwrap(); 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
View File
@@ -1,13 +1,20 @@
use crate::manifest::{self, Manifest}; use crate::manifest::{self, Manifest};
use base64::{engine::general_purpose::STANDARD, Engine}; 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 reqwest::{blocking::Client, redirect::Policy};
use serde::Deserialize; use serde::Deserialize;
use std::{fmt, time::Duration}; use std::{
fmt,
io::{self, Read},
time::Duration,
};
const AERONAUTICS_MANIFEST: &str = const AERONAUTICS_MANIFEST: &str =
"https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest"; "https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest";
const MANIFEST_PUBLIC_KEY: &str = "2S3FRdZj4Xw5nJpZ3IhqVITBg3nTH9AtGSo1Ew9+qVQ="; 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)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -24,7 +31,9 @@ pub enum RemoteError {
Network(reqwest::Error), Network(reqwest::Error),
Status(reqwest::StatusCode), Status(reqwest::StatusCode),
TooLarge, TooLarge,
Read(io::Error),
InvalidSignature, InvalidSignature,
ProfileMismatch,
InvalidManifest(manifest::ManifestError), InvalidManifest(manifest::ManifestError),
} }
@@ -35,8 +44,14 @@ impl fmt::Display for RemoteError {
Self::Network(error) => write!(formatter, "Cannot load ShaCraft manifest: {error}"), Self::Network(error) => write!(formatter, "Cannot load ShaCraft manifest: {error}"),
Self::Status(status) => write!(formatter, "ShaCraft manifest request failed: {status}"), Self::Status(status) => write!(formatter, "ShaCraft manifest request failed: {status}"),
Self::TooLarge => formatter.write_str("ShaCraft manifest is too large"), 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::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() { if !response.status().is_success() {
return Err(RemoteError::Status(response.status())); 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); return Err(RemoteError::TooLarge);
} }
let source = response.text().map_err(RemoteError::Network)?; let source = read_envelope(response)?;
let envelope = serde_json::from_str::<SignedManifest>(&source) 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)?; .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); return Err(RemoteError::InvalidSignature);
} }
let payload = STANDARD.decode(envelope.payload).map_err(|_| RemoteError::InvalidSignature)?; let payload = STANDARD
let signature_bytes = STANDARD.decode(envelope.signature).map_err(|_| RemoteError::InvalidSignature)?; .decode(envelope.payload)
let public_key_bytes = STANDARD.decode(MANIFEST_PUBLIC_KEY).expect("embedded public key must be valid"); .map_err(|_| RemoteError::InvalidSignature)?;
let public_key = VerifyingKey::from_bytes(&public_key_bytes.try_into().expect("embedded public key must be 32 bytes")) let signature_bytes = STANDARD
.expect("embedded public key must be valid"); .decode(envelope.signature)
let signature = Signature::from_slice(&signature_bytes).map_err(|_| RemoteError::InvalidSignature)?; .map_err(|_| RemoteError::InvalidSignature)?;
public_key.verify(&payload, &signature).map_err(|_| RemoteError::InvalidSignature)?; let signature =
Signature::from_slice(&signature_bytes).map_err(|_| RemoteError::InvalidSignature)?;
public_key
.verify_strict(&payload, &signature)
.map_err(|_| RemoteError::InvalidSignature)?;
let payload = String::from_utf8(payload).map_err(|_| RemoteError::InvalidSignature)?; let 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)
));
}
} }
+56
View File
@@ -12,12 +12,18 @@ use serde::Deserialize;
use std::{fmt, fs, io, path::{Path, PathBuf}}; use std::{fmt, fs, io, path::{Path, PathBuf}};
const ADOPTIUM_HOST: &str = "api.adoptium.net"; 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)] #[derive(Debug)]
pub enum RuntimeError { pub enum RuntimeError {
Network(reqwest::Error), Network(reqwest::Error),
HttpStatus(reqwest::StatusCode), HttpStatus(reqwest::StatusCode),
NoRelease, NoRelease,
UntrustedPackage,
UnexpectedArchiveLayout, UnexpectedArchiveLayout,
Download(DownloadError), Download(DownloadError),
Io(io::Error), Io(io::Error),
@@ -30,6 +36,7 @@ impl fmt::Display for RuntimeError {
Self::Network(error) => write!(formatter, "network error: {error}"), Self::Network(error) => write!(formatter, "network error: {error}"),
Self::HttpStatus(status) => write!(formatter, "Adoptium returned {status}"), Self::HttpStatus(status) => write!(formatter, "Adoptium returned {status}"),
Self::NoRelease => formatter.write_str("Adoptium has no matching JRE release for this platform"), 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::UnexpectedArchiveLayout => formatter.write_str("Java archive did not contain a single top-level directory as expected"),
Self::Download(error) => write!(formatter, "{error}"), Self::Download(error) => write!(formatter, "{error}"),
Self::Io(error) => write!(formatter, "I/O error: {error}"), Self::Io(error) => write!(formatter, "I/O error: {error}"),
@@ -76,6 +83,18 @@ struct AdoptiumPackage {
name: String, 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 { fn adoptium_os() -> &'static str {
if cfg!(target_os = "windows") { if cfg!(target_os = "windows") {
"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 assets: Vec<AdoptiumAsset> = response.json()?;
let package = assets.into_iter().next().map(|asset| asset.binary.package).ok_or(RuntimeError::NoRelease)?; let package = assets.into_iter().next().map(|asset| asset.binary.package).ok_or(RuntimeError::NoRelease)?;
validate_package(&package)?;
fs::create_dir_all(runtime_root)?; fs::create_dir_all(runtime_root)?;
let archive_path = runtime_root.join(&package.name); let archive_path = runtime_root.join(&package.name);
@@ -210,6 +230,42 @@ mod tests {
assert!(path.ends_with(java_executable_name())); 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 /// Live smoke test: resolves the current platform's latest Temurin 21
/// JRE from Adoptium, downloads it, verifies the checksum, and extracts /// JRE from Adoptium, downloads it, verifies the checksum, and extracts
/// it. Not run by default; `cargo test -- --ignored ensure_runtime`. /// it. Not run by default; `cargo test -- --ignored ensure_runtime`.
+68 -16
View File
@@ -38,7 +38,11 @@ fn default_nickname() -> String {
impl Default for LauncherSettings { impl Default for LauncherSettings {
fn default() -> Self { 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 { match self {
Self::Io(error) => write!(formatter, "Cannot access launcher settings: {error}"), Self::Io(error) => write!(formatter, "Cannot access launcher settings: {error}"),
Self::InvalidJson(error) => write!(formatter, "Cannot read 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::InvalidMemory => {
Self::InvalidNickname => write!(formatter, "Nickname must be 3-16 ASCII letters, numbers, or underscores"), 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 path = data_dir.join(SETTINGS_FILE);
let source = match fs::read_to_string(path) { let source = match fs::read_to_string(path) {
Ok(source) => source, 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)), Err(error) => return Err(SettingsError::Io(error)),
}; };
let settings = serde_json::from_str(&source).map_err(SettingsError::InvalidJson)?; 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) 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)?; validate(&settings)?;
fs::create_dir_all(data_dir).map_err(SettingsError::Io)?; fs::create_dir_all(data_dir).map_err(SettingsError::Io)?;
let target = data_dir.join(SETTINGS_FILE); 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"); let contents = serde_json::to_vec_pretty(&settings).expect("LauncherSettings is serializable");
fs::write(&temporary, contents).map_err(SettingsError::Io)?; crate::storage::write_atomic(&target, &contents).map_err(SettingsError::Io)?;
fs::rename(temporary, target).map_err(SettingsError::Io)?;
Ok(settings) Ok(settings)
} }
fn validate(settings: &LauncherSettings) -> Result<(), SettingsError> { 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); 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); return Err(SettingsError::InvalidNickname);
} }
Ok(()) Ok(())
@@ -98,13 +117,19 @@ fn validate(settings: &LauncherSettings) -> Result<(), SettingsError> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{load, save, AccountMode, LauncherSettings}; 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 { fn temporary_directory() -> std::path::PathBuf {
std::env::temp_dir().join(format!( std::env::temp_dir().join(format!(
"shacraft-settings-test-{}-{}", "shacraft-settings-test-{}-{}",
process::id(), 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.nickname, "Emil");
assert_eq!(default.account_mode, AccountMode::Offline); 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!(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(); fs::remove_dir_all(directory).unwrap();
} }
@@ -126,8 +162,24 @@ mod tests {
#[test] #[test]
fn rejects_unsafe_memory_values() { fn rejects_unsafe_memory_values() {
let directory = temporary_directory(); let directory = temporary_directory();
assert!(save(&directory, LauncherSettings { memory_mb: 512, nickname: "Emil".into(), account_mode: AccountMode::Offline }).is_err()); assert!(save(
assert!(save(&directory, LauncherSettings { memory_mb: 6 * 1024, nickname: "невалидный".into(), account_mode: AccountMode::Offline }).is_err()); &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] #[test]
+135
View File
@@ -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();
}
}
+55
View File
@@ -0,0 +1,55 @@
//! The same origin policy applies to initial artifact URLs and every redirect.
use reqwest::{blocking::Client, redirect::Policy};
use std::time::Duration;
use url::Url;
pub(crate) fn allows(value: &str, hosts: &[&str]) -> bool {
let Ok(url) = Url::parse(value) else {
return false;
};
url.scheme() == "https"
&& url.username().is_empty()
&& url.password().is_none()
&& url.port_or_known_default() == Some(443)
&& url.host_str().is_some_and(|host| hosts.contains(&host))
}
pub(crate) fn client(
hosts: &'static [&'static str],
timeout: Duration,
) -> Result<Client, reqwest::Error> {
Client::builder()
.https_only(true)
.connect_timeout(Duration::from_secs(15))
.timeout(timeout)
.redirect(Policy::custom(move |attempt| {
if attempt.previous().len() >= 10 {
attempt.error("too many redirects")
} else if allows(attempt.url().as_str(), hosts) {
attempt.follow()
} else {
attempt.error("redirect leaves the trusted download hosts")
}
}))
.build()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exact_https_origins_reject_authority_ambiguity_and_cross_domain_redirects() {
let hosts = ["piston-meta.mojang.com"];
assert!(allows("https://piston-meta.mojang.com/game.json", &hosts));
for url in [
"http://piston-meta.mojang.com/game.json",
"https://piston-meta.mojang.com.attacker.test/game.json",
"https://user@piston-meta.mojang.com/game.json",
"https://piston-meta.mojang.com:444/game.json",
"https://maven.neoforged.net/game.json",
] {
assert!(!allows(url, &hosts), "{url}");
}
}
}
+75
View File
@@ -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>
)
}
+61
View File
@@ -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>
</>
)
}
+16
View File
@@ -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>
</>
)
}
+68
View File
@@ -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>
)
}
+23
View File
@@ -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>
)
}
+95
View File
@@ -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>
</>
)
}
+17
View File
@@ -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>
)
}
+26
View File
@@ -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,
},
]
+80
View File
@@ -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 }
}
+95
View File
@@ -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 }
}
+83
View File
@@ -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
View File
@@ -1,536 +1,9 @@
import React, { useEffect, useState } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { invoke } from '@tauri-apps/api/core' import { App } from './App'
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 './styles.css' import './styles.css'
type Server = { const root = document.getElementById('root')
id: string if (!root) throw new Error('Launcher root element is missing')
kicker: string
name: string
subtitle: string
players: string
version: string
memory: string
installed: boolean
profileId?: string
disabled?: boolean
}
type NativeHost = { createRoot(root).render(<StrictMode><App /></StrictMode>)
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>,
)
+97
View File
@@ -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)
})
+55
View File
@@ -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 }
}
+47
View File
@@ -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)),
])
}
+52
View File
@@ -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)
})
+75
View File
@@ -0,0 +1,75 @@
import type { GameExitedPayload, InstallProgressPayload } from '../types/launcher'
export type GameOperation =
| { phase: 'idle' }
| { phase: 'syncing' | 'launching' | 'running'; profileId: string }
| { phase: 'installing'; profileId: string; progress: InstallProgressPayload | null }
export interface GameState {
operation: GameOperation
error: string | null
}
export type GameAction =
| { type: 'sync'; profileId: string }
| { type: 'synced'; profileId: string }
| { type: 'install'; profileId: string }
| { type: 'progress'; progress: InstallProgressPayload }
| { type: 'launch'; profileId: string }
| { type: 'started'; profileId: string }
| { type: 'exited'; result: GameExitedPayload }
| { type: 'failed'; error: string }
export const initialGameState: GameState = { operation: { phase: 'idle' }, error: null }
export function gameReducer(state: GameState, action: GameAction): GameState {
const operation = state.operation
switch (action.type) {
case 'sync':
case 'install':
if (operation.phase !== 'idle') return state
return {
operation: action.type === 'sync'
? { phase: 'syncing', profileId: action.profileId }
: { phase: 'installing', profileId: action.profileId, progress: null },
error: null,
}
case 'synced':
return operation.phase === 'syncing' && operation.profileId === action.profileId
? initialGameState : state
case 'progress':
return operation.phase === 'installing'
? { ...state, operation: { ...operation, progress: action.progress } } : state
case 'launch':
return operation.phase === 'installing' && operation.profileId === action.profileId
? { ...state, operation: { phase: 'launching', profileId: action.profileId } } : state
case 'started':
// A fast-exiting child can emit game-exited before invoke resolves.
return operation.phase === 'launching' && operation.profileId === action.profileId
? { ...state, operation: { phase: 'running', profileId: action.profileId } } : state
case 'exited':
if ((operation.phase !== 'launching' && operation.phase !== 'running') ||
operation.profileId !== action.result.profileId) return state
return {
operation: { phase: 'idle' },
error: action.result.exitCode === 0 ? null
: action.result.exitCode === null ? 'Игра завершилась без кода выхода. Проверьте журнал игры.'
: `Игра завершилась с кодом ${action.result.exitCode}. Проверьте журнал игры.`,
}
case 'failed':
return { operation: { phase: 'idle' }, error: action.error }
}
}
export const installStageLabels: Record<InstallProgressPayload['stage'], string> = {
java: 'Готовим Java',
neoforge: 'Устанавливаем NeoForge',
libraries: 'Скачиваем библиотеки',
assets: 'Скачиваем ресурсы игры',
}
export function installPercent(progress: InstallProgressPayload | null): number | null {
if (!progress || progress.totalBytes <= 0 || !Number.isFinite(progress.totalBytes) ||
!Number.isFinite(progress.currentBytes)) return null
return Math.max(0, Math.min(100, Math.round(progress.currentBytes / progress.totalBytes * 100)))
}
+19
View File
@@ -0,0 +1,19 @@
import { equal } from 'node:assert/strict'
import { test } from 'node:test'
import { profilesReducer } from './profiles.ts'
test('a failed repair invalidates an old ready inspection and permits retry', () => {
const profileId = 'aeronautics'
const inspection = { root: '/profiles/aeronautics', managedFiles: 251, missingFiles: 0, mismatchedFiles: 0, upToDate: true }
const ready = profilesReducer({}, { type: 'checked', profileId, inspection })
equal(ready[profileId]?.inspection?.upToDate, true)
const repairing = profilesReducer(ready, { type: 'check', profileId })
equal(repairing[profileId]?.inspection, null)
const failed = profilesReducer(repairing, { type: 'failed', profileId, error: 'Download interrupted' })
equal(failed[profileId]?.status, 'error')
equal(failed[profileId]?.inspection, null)
const retrying = profilesReducer(failed, { type: 'check', profileId })
const repaired = profilesReducer(retrying, { type: 'checked', profileId, inspection })
equal(repaired[profileId]?.inspection?.upToDate, true)
equal(repaired[profileId]?.error, null)
})
+22
View File
@@ -0,0 +1,22 @@
import type { ProfileInspection } from '../types/launcher'
export type ProfileState =
| { status: 'checking'; inspection: null; error: null }
| { status: 'checked'; inspection: ProfileInspection; error: null }
| { status: 'error'; inspection: null; error: string }
type ProfileAction =
| { type: 'check'; profileId: string }
| { type: 'checked'; profileId: string; inspection: ProfileInspection }
| { type: 'failed'; profileId: string; error: string }
export function profilesReducer(
profiles: Record<string, ProfileState>, action: ProfileAction,
): Record<string, ProfileState> {
const next: ProfileState = action.type === 'check'
? { status: 'checking', inspection: null, error: null }
: action.type === 'checked'
? { status: 'checked', inspection: action.inspection, error: null }
: { status: 'error', inspection: null, error: action.error }
return { ...profiles, [action.profileId]: next }
}
+9
View File
@@ -0,0 +1,9 @@
import type { LauncherSettings } from '../types/launcher'
export const defaultSettings: LauncherSettings = {
memoryMb: 6 * 1024,
nickname: 'Emil',
accountMode: 'offline',
}
export const isValidNickname = (nickname: string) => /^[A-Za-z0-9_]{3,16}$/.test(nickname)
+13 -5
View File
@@ -1,6 +1,7 @@
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Unbounded:wght@500;600&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Unbounded:wght@500;600&display=swap');
:root { :root {
color-scheme: dark;
font-family: 'Manrope', sans-serif; font-family: 'Manrope', sans-serif;
color: #eef2ed; color: #eef2ed;
background: #090c0a; background: #090c0a;
@@ -17,10 +18,11 @@
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
button, input { font: inherit; } button, input, select { font: inherit; }
button { color: inherit; } button { color: inherit; }
body { margin: 0; min-width: 1040px; min-height: 680px; overflow: hidden; } 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 { .app-shell {
height: 100vh; 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 strong { font-size: 12px; }
.account-chip small { color: #6d756e; font-size: 9px; } .account-chip small { color: #6d756e; font-size: 9px; }
.account-chip svg { color: #555e56; } .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 { position: relative; overflow: hidden; isolation: isolate; background: #16231b; }
.stage::before { content: ''; position: absolute; inset: 0; z-index: -5; background: .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 { 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; } .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); } .settings-drawer.open { transform: translateX(0); }
.drawer-title { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 34px; } .drawer-title { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 34px; }
.drawer-title p { color: #737c74; font-size: 11px; margin: 0 0 5px; } .drawer-title 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 { cursor: default; }
.setting-row.static:hover { color: #abb3ac; } .setting-row.static:hover { color: #abb3ac; }
.setting-row small { color: #7f8a80; font-size: 11px; } .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 { 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 > span { display: flex; align-items: baseline; justify-content: space-between; }
.text-setting strong { font-size: 12px; } .text-setting strong { font-size: 12px; }
.text-setting span small, .text-setting > small { color: #7f8a80; font-size: 11px; } .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 { 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); } .text-setting input:focus { border-color: var(--green); }
.drawer-note { position: absolute; left: 28px; right: 28px; bottom: 26px; padding: 13px 15px; background: #17231a; color: #8fb193; border-radius: 10px; font-size: 10px; } .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 { 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; } .login-modal h2 { margin: 0 0 10px; font-family: 'Unbounded'; font-size: 18px; }
+74
View File
@@ -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
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+21
View File
@@ -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"]
}