From cde13c4489dceca6de6271fb80bb7cdd21db84b0 Mon Sep 17 00:00:00 2001 From: emil28092005 Date: Wed, 9 Sep 2026 12:55:15 +0300 Subject: [PATCH] refactor: modularize launcher UI and native services with verified IO --- .github/workflows/build.yml | 23 +- .github/workflows/check.yml | 32 ++ AGENTS.md | 24 +- PLAN.md | 30 ++ README.md | 56 ++- docs/launcher-architecture.md | 35 +- package-lock.json | 624 ++++++++++++++++++++++++-- package.json | 24 +- src-tauri/src/commands/account.rs | 168 +++++++ src-tauri/src/commands/game.rs | 234 ++++++++++ src-tauri/src/commands/host.rs | 38 ++ src-tauri/src/commands/mod.rs | 15 + src-tauri/src/commands/preferences.rs | 24 + src-tauri/src/commands/profiles.rs | 38 ++ src-tauri/src/download.rs | 140 ++++-- src-tauri/src/lib.rs | 405 +---------------- src-tauri/src/manifest.rs | 127 +++++- src-tauri/src/mojang.rs | 7 +- src-tauri/src/msa.rs | 16 +- src-tauri/src/neoforge.rs | 7 +- src-tauri/src/operations.rs | 51 +++ src-tauri/src/profile.rs | 141 +++++- src-tauri/src/remote.rs | 170 ++++++- src-tauri/src/runtime.rs | 56 +++ src-tauri/src/settings.rs | 84 +++- src-tauri/src/storage.rs | 135 ++++++ src-tauri/src/trusted_http.rs | 55 +++ src/App.tsx | 75 ++++ src/components/Library.tsx | 61 +++ src/components/LoginModal.tsx | 16 + src/components/PlayDock.tsx | 68 +++ src/components/ServerStage.tsx | 23 + src/components/SettingsDrawer.tsx | 95 ++++ src/components/Titlebar.tsx | 17 + src/data/servers.ts | 26 ++ src/hooks/useAccount.ts | 80 ++++ src/hooks/useLauncher.ts | 95 ++++ src/hooks/useSettings.ts | 83 ++++ src/main.tsx | 537 +--------------------- src/services/async.test.ts | 97 ++++ src/services/async.ts | 55 +++ src/services/native.ts | 47 ++ src/state/game.test.ts | 52 +++ src/state/game.ts | 75 ++++ src/state/profiles.test.ts | 19 + src/state/profiles.ts | 22 + src/state/settings.ts | 9 + src/styles.css | 18 +- src/types/launcher.ts | 74 +++ src/vite-env.d.ts | 1 + tsconfig.json | 21 + 51 files changed, 3307 insertions(+), 1118 deletions(-) create mode 100644 .github/workflows/check.yml create mode 100644 PLAN.md create mode 100644 src-tauri/src/commands/account.rs create mode 100644 src-tauri/src/commands/game.rs create mode 100644 src-tauri/src/commands/host.rs create mode 100644 src-tauri/src/commands/mod.rs create mode 100644 src-tauri/src/commands/preferences.rs create mode 100644 src-tauri/src/commands/profiles.rs create mode 100644 src-tauri/src/operations.rs create mode 100644 src-tauri/src/storage.rs create mode 100644 src-tauri/src/trusted_http.rs create mode 100644 src/App.tsx create mode 100644 src/components/Library.tsx create mode 100644 src/components/LoginModal.tsx create mode 100644 src/components/PlayDock.tsx create mode 100644 src/components/ServerStage.tsx create mode 100644 src/components/SettingsDrawer.tsx create mode 100644 src/components/Titlebar.tsx create mode 100644 src/data/servers.ts create mode 100644 src/hooks/useAccount.ts create mode 100644 src/hooks/useLauncher.ts create mode 100644 src/hooks/useSettings.ts create mode 100644 src/services/async.test.ts create mode 100644 src/services/async.ts create mode 100644 src/services/native.ts create mode 100644 src/state/game.test.ts create mode 100644 src/state/game.ts create mode 100644 src/state/profiles.test.ts create mode 100644 src/state/profiles.ts create mode 100644 src/state/settings.ts create mode 100644 src/types/launcher.ts create mode 100644 src/vite-env.d.ts create mode 100644 tsconfig.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5eb8325..1beff6c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,6 +3,9 @@ name: Cross-platform build on: workflow_dispatch: +permissions: + contents: read + jobs: build: name: ${{ matrix.name }} @@ -14,13 +17,13 @@ jobs: os: ubuntu-22.04 args: --bundles appimage,deb - name: Windows x64 - os: windows-latest + os: windows-2022 args: --bundles nsis,msi - name: macOS Apple Silicon - os: macos-14 + os: macos-15 args: --target aarch64-apple-darwin --bundles dmg - name: macOS Intel - os: macos-13 + os: macos-15-intel args: --target x86_64-apple-darwin --bundles dmg runs-on: ${{ matrix.os }} @@ -32,5 +35,19 @@ jobs: node-version: 22 cache: npm - uses: dtolnay/rust-toolchain@stable + - name: Install Linux desktop dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf - run: npm ci + - run: npm test + - run: cargo test --locked --manifest-path src-tauri/Cargo.toml - run: npm run tauri:build -- ${{ matrix.args }} + - uses: actions/upload-artifact@v4 + with: + name: shacraft-${{ matrix.os }} + if-no-files-found: error + path: | + src-tauri/target/release/bundle/** + src-tauri/target/*/release/bundle/** diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..ccc3db0 --- /dev/null +++ b/.github/workflows/check.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index e275ad1..911c632 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,9 +48,20 @@ payload are in `/root/shacraft` on the ShaCraft host; see ## Layout -- `src/main.tsx` — UI state and Tauri command calls; do not put privileged - operations in the web layer. +- `src/main.tsx` — React entrypoint; `src/App.tsx` composes the screen. +- `src/components/` — presentational UI; `src/hooks/` — lifecycle/settings/account. +- `src/services/native.ts` — typed IPC and event subscriptions; keep schemas + aligned with Rust. `src/services/async.ts` — serialized writes, single-flight + account restore and listener disposal. `src/state/` — tested reducers. +- Native filesystem/network/process operations never belong in the web layer. - `src-tauri/src/` — native commands and security-sensitive logic. + - `lib.rs` — module/command registration only; `commands/` holds adapters + for account/game/host/preferences/profiles. Unsigned sync/inspect IPC was + removed; only verified remote manifests may drive profile mutations. + - `operations.rs` — process-local install/account permits owned by workers. + Offline launch must not acquire the Microsoft refresh permit. + - `storage.rs` — unique same-directory atomic writes, owner-only Unix files. + - `trusted_http.rs` — HTTPS and exact-host redirect policy per game provider. - `download.rs` — shared verified-download helper (temp file, hash, atomic rename, progress callback); `manifest.rs`/`profile.rs` (ShaCraft mods) and `mojang.rs`/`neoforge.rs`/`runtime.rs` (the game itself) all @@ -70,13 +81,17 @@ payload are in `/root/shacraft` on the ShaCraft host; see (mods/config only). - `docs/game-trust-boundary.md` — the Mojang/NeoForge/Microsoft/Adoptium trust domains used to install and run the game itself. -- `.github/workflows/build.yml` — manual cross-platform build matrix. +- `.github/workflows/check.yml` — push/PR UI checks and Linux Rust tests. +- `.github/workflows/build.yml` — manual cross-platform builds with artifacts; + not a signed release or updater publication. ## Verification Run from repository root: ```bash +npm ci +npm test npm run build (cd src-tauri && /home/emil/.cargo/bin/cargo test) npm run tauri:dev @@ -85,6 +100,9 @@ npm run tauri:dev `tauri:dev` is for local desktop testing. A successful web build alone does not prove Tauri commands work. +See `PLAN.md` for known gaps. Never label browser preview or unit tests as +a successful cold game install / Microsoft OAuth / Windows/macOS beta test. + ## Working conventions - Keep UI copy in Russian; code, identifiers and errors may remain English. diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..4540334 --- /dev/null +++ b/PLAN.md @@ -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. +Не менять этот протокол незаметно в клиентском рефакторинге. diff --git a/README.md b/README.md index 116728c..584d6cf 100644 --- a/README.md +++ b/README.md @@ -1,41 +1,49 @@ # ShaCraft Launcher -Кроссплатформенный лаунчер для сети Minecraft-серверов ShaCraft. +Кроссплатформенный Tauri 2 лаунчер для [ShaCraft](https://shacraft.ru/): +React/TypeScript интерфейс, Rust — файлы, сеть и запуск процессов. -Сейчас реализовано: +Реализованы подписанная синхронизация Aeronautics, проверка/восстановление +модов и конфигурации, Java discovery/provisioning, bootstrap Minecraft и +NeoForge, настройки памяти и ника, обработка установки/запуска/выхода. -- интерактивный интерфейс профилей и настроек; -- Tauri 2 native shell с безопасной командой `native_host`; -- сохранение памяти профиля в локальном каталоге данных приложения; -- безопасное обнаружение установленной Java (включая `JAVA_HOME`) перед запуском; -- интеграция с фиксированным ShaCraft launcher v2 manifest для Aeronautics; -- адаптивное окно для Windows, Linux и macOS; -- вход через настоящий аккаунт Microsoft (без него игра не устанавливается - и не запускается — так владение игрой проверяется по-настоящему); -- установка Minecraft и NeoForge версии, которую задаёт manifest, и запуск - игры. +Режим аккаунта выбирается явно: offline-профиль или Microsoft. Код Microsoft +OAuth/проверки владения готов, но **вход ещё требует собственного client ID и +одобрения Minecraft API**. Offline не подставляется при ошибке Microsoft. -## Локальная разработка +## Разработка ```bash -npm install +npm ci npm run dev ``` -Для нативного приложения нужен Rust: +Это браузерный preview — он не устанавливает и не запускает игру. +Для приложения нужен Rust и системные зависимости Tauri: ```bash npm run tauri:dev ``` -На Ubuntu для сборки Tauri также потребуются системные пакеты WebKit/GTK и -DBus development headers. Их установка описана в официальной документации -Tauri и требует прав администратора. +## Проверка -## Статус +```bash +npm test +npm run build +cargo test --locked --manifest-path src-tauri/Cargo.toml +``` -Вход через Microsoft, установка и запуск Aeronautics уже работают. Вход -через Microsoft пока не активен на боевой сборке: нужна собственная -регистрация приложения ShaCraft в Azure AD и её одобрение Microsoft для -доступа к Minecraft API — см. комментарий к `MSA_CLIENT_ID` в -`src-tauri/src/msa.rs`. +Build включает строгий TypeScript. GitHub Actions проверяет UI и Rust на +push/PR; ручной workflow собирает пакеты Windows x64, Linux x64, macOS Intel +и Apple Silicon и сохраняет артефакты. Подпись релиза/автообновления ещё впереди. +Используемые macOS runners соответствуют [списку GitHub](https://docs.github.com/en/actions/reference/runners/github-hosted-runners). + +## Навигация + +- [Архитектура](docs/launcher-architecture.md) — компоненты, данные, IPC. +- [Trust boundaries](docs/game-trust-boundary.md) — доверенные источники игры. +- [Manifest](docs/manifest-v1.md) — подписанный контракт модпака. +- [PLAN.md](PLAN.md) — ограничения и следующие шаги. +- [AGENTS.md](AGENTS.md) — инструкции для следующего разработчика/агента. + +Не хранить в Git токены, ключи, пользовательские данные или пакеты игры. diff --git a/docs/launcher-architecture.md b/docs/launcher-architecture.md index b025464..3fe51ab 100644 --- a/docs/launcher-architecture.md +++ b/docs/launcher-architecture.md @@ -28,7 +28,7 @@ Game itself (never controlled by the manifest above) -> Java 21 via Adoptium if none installed (runtime.rs) -> NeoForge's own installer, run headlessly (neoforge.rs) -> generic inheritsFrom merge of the two version JSONs (mojang.rs) - -> real Microsoft/Xbox/Minecraft Services login (msa.rs) + -> explicit Microsoft session (msa.rs) OR offline identity (session.rs) -> java process spawned with the merged classpath/args (launch.rs) ``` @@ -53,14 +53,41 @@ be the system `.minecraft` directory. - ShaCraft download files: HTTPS only, exact hosts `shacraft.ru` and `cdn.shacraft.ru`. +## Module boundaries (2026-09-09) + +React entrypoint → App/components → hooks → typed native service. Pure +reducers own game and profile states; IPC failures retain their real message. +Settings writes are serialized, and account restoration is single-flight +even under React StrictMode. A failed repair invalidates profile readiness. +Game exit may arrive before launch acknowledgement; the reducer handles both. +Browser preview cannot install/launch and does not simulate download progress. + +Rust `lib.rs` registers commands from `commands/`. Install/account permits in +`operations.rs` stay owned by blocking workers until completion. These are +process-local guards, not cross-process locks or cancellation support. +`storage.rs` provides unique temporary files and atomic replacement; Unix +account files are created owner-only rather than chmodded after writing. +`trusted_http.rs` constrains initial provider URLs and every redirect. +Manifest profile identity, size, signature, portable paths and existing +symlinks are checked before managed file writes. Local same-user TOCTOU is +outside this protection; do not describe it as an OS sandbox. + +## Verification and distribution + +`npm test` covers asynchronous helpers and state transitions; +`npm run build` runs strict TypeScript before Vite. `cargo test --locked` +covers native policy and storage. Push/PR CI repeats these checks on Linux. +Manual `build.yml` builds Windows x64, Linux x64 and both macOS architectures +and uploads bundles. Packages are not yet signed release artifacts. + ## Planned but not implemented 1. User-selectable profile directory and structured launcher logs. 2. "Reset managed files only" recovery action that doesn't touch player worlds/screenshots/resourcepacks. 3. Signed, cross-platform release builds of the launcher itself. -4. Real per-stage byte progress for the Java/NeoForge install steps - (currently start/done only — the dominant, user-visible wait, asset - downloading, already reports real bytes). +4. Cancellation, structured logs and full cold-install/recovery beta on + every target OS. Current install progress reports actual stage work; + bytes and installer completion counts are not interchangeable units. Do not represent these as completed features in UI or release notes. diff --git a/package-lock.json b/package-lock.json index f0eac88..e05c8e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,22 +8,442 @@ "name": "shacraft-launcher-ui", "version": "0.1.0", "dependencies": { - "@tauri-apps/api": "^2.11.1", - "@vitejs/plugin-react": "latest", - "lucide-react": "latest", - "react": "latest", - "react-dom": "latest", - "typescript": "latest", - "vite": "latest" + "@tauri-apps/api": "2.11.1", + "lucide-react": "1.41.0", + "react": "19.2.8", + "react-dom": "19.2.8" }, "devDependencies": { - "@tauri-apps/cli": "^2.11.4" + "@tauri-apps/cli": "2.11.4", + "@types/node": "^22.19.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "tsx": "4.23.13", + "typescript": "7.0.2", + "vite": "8.2.2" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, "node_modules/@oxc-project/types": { "version": "0.148.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz", "integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==", + "dev": true, "funding": { "url": "https://github.com/sponsors/oxc-project" } @@ -35,6 +455,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "android" @@ -50,6 +471,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "android" @@ -65,6 +487,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -80,6 +503,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -95,6 +519,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -110,6 +535,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "linux" @@ -125,6 +551,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -140,6 +567,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -155,6 +583,7 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -170,6 +599,7 @@ "cpu": [ "s390x" ], + "dev": true, "optional": true, "os": [ "linux" @@ -185,6 +615,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -200,6 +631,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -215,6 +647,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "openharmony" @@ -230,6 +663,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -245,6 +679,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -256,7 +691,8 @@ "node_modules/@rolldown/pluginutils": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==" + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true }, "node_modules/@tauri-apps/api": { "version": "2.11.1", @@ -472,6 +908,33 @@ "node": ">= 10" } }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==", + "dev": true, + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, "node_modules/@typescript/typescript-aix-ppc64": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", @@ -479,6 +942,7 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "aix" @@ -494,6 +958,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -509,6 +974,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -524,6 +990,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -539,6 +1006,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -554,6 +1022,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "linux" @@ -569,6 +1038,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -584,6 +1054,7 @@ "cpu": [ "loong64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -599,6 +1070,7 @@ "cpu": [ "mips64el" ], + "dev": true, "optional": true, "os": [ "linux" @@ -614,6 +1086,7 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -629,6 +1102,7 @@ "cpu": [ "riscv64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -644,6 +1118,7 @@ "cpu": [ "s390x" ], + "dev": true, "optional": true, "os": [ "linux" @@ -659,6 +1134,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -674,6 +1150,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "netbsd" @@ -689,6 +1166,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "netbsd" @@ -704,6 +1182,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "openbsd" @@ -719,6 +1198,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "openbsd" @@ -734,6 +1214,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "sunos" @@ -749,6 +1230,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -764,6 +1246,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -772,46 +1255,67 @@ "node": ">=16.20.0" } }, - "node_modules/@vitejs/plugin-react": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", - "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", - "dependencies": { - "@rolldown/pluginutils": "^1.0.1" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", - "babel-plugin-react-compiler": "^1.0.0", - "oxc-transform-react": "^0.145.0", - "vite": "^8.0.0" - }, - "peerDependenciesMeta": { - "@rolldown/plugin-babel": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - }, - "oxc-transform-react": { - "optional": true - } - } + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, "engines": { "node": ">=8" } }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "engines": { "node": ">=12.0.0" }, @@ -828,6 +1332,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -841,6 +1346,7 @@ "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -872,6 +1378,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "android" @@ -891,6 +1398,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -910,6 +1418,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -929,6 +1438,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -948,6 +1458,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "linux" @@ -967,6 +1478,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -986,6 +1498,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1005,6 +1518,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1024,6 +1538,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1043,6 +1558,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -1062,6 +1578,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -1086,6 +1603,7 @@ "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, "funding": [ { "type": "github", @@ -1102,12 +1620,14 @@ "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true }, "node_modules/picomatch": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, "engines": { "node": ">=12" }, @@ -1119,6 +1639,7 @@ "version": "8.5.28", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, "funding": [ { "type": "opencollective", @@ -1165,6 +1686,7 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz", "integrity": "sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==", + "dev": true, "dependencies": { "@oxc-project/types": "=0.148.0", "@rolldown/pluginutils": "^1.0.0" @@ -1202,6 +1724,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -1210,6 +1733,7 @@ "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" @@ -1221,10 +1745,29 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tsx": { + "version": "4.23.13", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", + "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", + "dev": true, + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/typescript": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, "bin": { "tsc": "bin/tsc" }, @@ -1254,10 +1797,17 @@ "@typescript/typescript-win32-x64": "7.0.2" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true + }, "node_modules/vite": { "version": "8.2.2", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "dev": true, "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", diff --git a/package.json b/package.json index a6c02a2..b0f7fd2 100644 --- a/package.json +++ b/package.json @@ -5,23 +5,27 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vite build", + "build": "npm run typecheck && vite build", + "typecheck": "tsc --noEmit", + "test": "tsx --test src/services/async.test.ts src/state/game.test.ts src/state/profiles.test.ts", "preview": "vite preview", "tauri": "tauri", "tauri:dev": "tauri dev", "tauri:build": "tauri build" }, "dependencies": { - "@tauri-apps/api": "^2.11.1", - "@vitejs/plugin-react": "latest", - "@tauri-apps/cli": "^2.9.4", - "lucide-react": "latest", - "react": "latest", - "react-dom": "latest", - "typescript": "latest", - "vite": "latest" + "@tauri-apps/api": "2.11.1", + "lucide-react": "1.41.0", + "react": "19.2.8", + "react-dom": "19.2.8" }, "devDependencies": { - "@tauri-apps/cli": "^2.11.4" + "@tauri-apps/cli": "2.11.4", + "@types/node": "^22.19.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "tsx": "4.23.13", + "typescript": "7.0.2", + "vite": "8.2.2" } } diff --git a/src-tauri/src/commands/account.rs b/src-tauri/src/commands/account.rs new file mode 100644 index 0000000..8fadbed --- /dev/null +++ b/src-tauri/src/commands/account.rs @@ -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, + error: Option, +} + +/// 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 { + 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, 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 { + 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) + ); + } +} diff --git a/src-tauri/src/commands/game.rs b/src-tauri/src/commands/game.rs new file mode 100644 index 0000000..d770dfa --- /dev/null +++ b/src-tauri/src/commands/game.rs @@ -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 { + 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, +} + +/// 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}"))? +} diff --git a/src-tauri/src/commands/host.rs b/src-tauri/src/commands/host.rs new file mode 100644 index 0000000..a877e97 --- /dev/null +++ b/src-tauri/src/commands/host.rs @@ -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 { + 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::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()) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs new file mode 100644 index 0000000..3731958 --- /dev/null +++ b/src-tauri/src/commands/mod.rs @@ -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 { + app.path() + .app_data_dir() + .map_err(|error| format!("Cannot resolve launcher data directory: {error}")) +} diff --git a/src-tauri/src/commands/preferences.rs b/src-tauri/src/commands/preferences.rs new file mode 100644 index 0000000..1979170 --- /dev/null +++ b/src-tauri/src/commands/preferences.rs @@ -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 { + 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 { + 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()) +} diff --git a/src-tauri/src/commands/profiles.rs b/src-tauri/src/commands/profiles.rs new file mode 100644 index 0000000..83d64b1 --- /dev/null +++ b/src-tauri/src/commands/profiles.rs @@ -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 { + 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 { + 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}"))? +} diff --git a/src-tauri/src/download.rs b/src-tauri/src/download.rs index 7093bf3..f38656e 100644 --- a/src-tauri/src/download.rs +++ b/src-tauri/src/download.rs @@ -1,11 +1,12 @@ -use reqwest::blocking::{Client, Response}; +use crate::storage::AtomicFile; +use reqwest::blocking::Client; use sha1::Sha1; use sha2::{Digest, Sha256}; use std::{ fmt, fs::{self, File}, io::{self, Read, Write}, - path::{Path, PathBuf}, + path::Path, sync::Arc, }; @@ -73,12 +74,19 @@ pub fn file_hashes(path: &Path) -> io::Result<(String, String)> { sha1.update(&buffer[..read]); sha256.update(&buffer[..read]); } - Ok((format!("{:x}", sha1.finalize()), format!("{:x}", sha256.finalize()))) + Ok(( + format!("{:x}", sha1.finalize()), + format!("{:x}", sha256.finalize()), + )) } /// True if `path` already exists, matches `expected_size` (when given) and /// `checksum`. Used to skip re-downloading files that are already current. -pub fn is_current(path: &Path, expected_size: Option, checksum: &Checksum) -> io::Result { +pub fn is_current( + path: &Path, + expected_size: Option, + checksum: &Checksum, +) -> io::Result { let metadata = match path.metadata() { Ok(metadata) => metadata, Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), @@ -96,14 +104,6 @@ pub fn is_current(path: &Path, expected_size: Option, checksum: &Checksum) Ok(checksum.matches(&sha1_hex, &sha256_hex)) } -fn temp_path(target: &Path) -> Result { - let file_name = target - .file_name() - .and_then(|name| name.to_str()) - .ok_or(DownloadError::InvalidTargetPath)?; - Ok(target.with_file_name(format!(".{file_name}.shacraft.part"))) -} - /// Downloads `url` to `target`, verifying size (if known ahead of time) and /// `checksum` before atomically renaming the temporary file into place. /// `on_progress(downloaded_bytes, total_bytes)` is called after every chunk; @@ -126,30 +126,34 @@ pub fn download_verified( let total = expected_size.or_else(|| response.content_length()); if let (Some(expected), Some(length)) = (expected_size, response.content_length()) { if expected != length { - return Err(DownloadError::SizeMismatch { expected, actual: length }); + return Err(DownloadError::SizeMismatch { + expected, + actual: length, + }); } } - let temporary = temp_path(target)?; - let result = write_and_verify(&mut response, &temporary, expected_size, checksum, total, &mut on_progress); - if let Err(error) = result { - let _ = fs::remove_file(&temporary); - return Err(error); - } - let bytes = result.unwrap(); - fs::rename(&temporary, target).map_err(DownloadError::Io)?; + let mut output = AtomicFile::new(target).map_err(DownloadError::Io)?; + let bytes = write_and_verify( + &mut response, + output.writer(), + expected_size, + checksum, + total, + &mut on_progress, + )?; + output.commit().map_err(DownloadError::Io)?; Ok(bytes) } fn write_and_verify( - response: &mut Response, - temporary: &Path, + response: &mut impl Read, + output: &mut impl Write, expected_size: Option, checksum: &Checksum, total: Option, on_progress: &mut impl FnMut(u64, Option), ) -> Result { - let mut output = File::create(temporary).map_err(DownloadError::Io)?; let mut sha1 = Sha1::new(); let mut sha256 = Sha256::new(); let mut bytes = 0_u64; @@ -160,17 +164,29 @@ fn write_and_verify( if read == 0 { break; } - output.write_all(&buffer[..read]).map_err(DownloadError::Io)?; + bytes += read as u64; + if let Some(expected) = expected_size { + if bytes > expected { + return Err(DownloadError::SizeMismatch { + expected, + actual: bytes, + }); + } + } + output + .write_all(&buffer[..read]) + .map_err(DownloadError::Io)?; sha1.update(&buffer[..read]); sha256.update(&buffer[..read]); - bytes += read as u64; on_progress(bytes, total); } - output.sync_all().map_err(DownloadError::Io)?; if let Some(expected) = expected_size { if bytes != expected { - return Err(DownloadError::SizeMismatch { expected, actual: bytes }); + return Err(DownloadError::SizeMismatch { + expected, + actual: bytes, + }); } } let sha1_hex = format!("{:x}", sha1.finalize()); @@ -184,13 +200,19 @@ fn write_and_verify( #[cfg(test)] mod tests { use super::{file_hashes, is_current, Checksum}; - use std::{fs, process, time::{SystemTime, UNIX_EPOCH}}; + use std::{ + fs, process, + time::{SystemTime, UNIX_EPOCH}, + }; fn temp_file(contents: &[u8]) -> std::path::PathBuf { let path = std::env::temp_dir().join(format!( "shacraft-download-test-{}-{}", process::id(), - SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() )); fs::write(&path, contents).unwrap(); path @@ -201,7 +223,10 @@ mod tests { let path = temp_file(b"hello shacraft"); let (sha1_hex, sha256_hex) = file_hashes(&path).unwrap(); assert_eq!(sha1_hex, "124b319646ec08b4fb2a2b65bbd21c0431b4eaf4"); - assert_eq!(sha256_hex, "d34eb8ea6396e8492109813c717f7eefd0437c10ff55a7b11949cfae900c946d"); + assert_eq!( + sha256_hex, + "d34eb8ea6396e8492109813c717f7eefd0437c10ff55a7b11949cfae900c946d" + ); fs::remove_file(path).unwrap(); } @@ -220,4 +245,57 @@ mod tests { let path = std::env::temp_dir().join("shacraft-download-test-missing-file-xyz"); assert!(!is_current(&path, None, &Checksum::Sha256("0".repeat(64))).unwrap()); } + + #[test] + fn failed_download_keeps_existing_file_and_cleans_temporary() { + use std::{ + io::{Read, Write}, + net::TcpListener, + }; + let path = temp_file(b"previous version"); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/test.jar", listener.local_addr().unwrap()); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0_u8; 4096]; + let _ = stream.read(&mut request).unwrap(); + stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Length: 7\r\nConnection: close\r\n\r\ncorrupt", + ) + .unwrap(); + }); + let error = super::download_verified( + &reqwest::blocking::Client::new(), + &url, + &path, + Some(7), + &Checksum::Sha256("0".repeat(64)), + |_, _| {}, + ) + .unwrap_err(); + assert!(matches!(error, super::DownloadError::ChecksumMismatch)); + assert_eq!(fs::read(&path).unwrap(), b"previous version"); + server.join().unwrap(); + fs::remove_file(path).unwrap(); + } + + #[test] + fn oversized_body_is_stopped_before_writing_excess() { + let mut output = Vec::new(); + let error = super::write_and_verify( + &mut std::io::repeat(b'x'), + &mut output, + Some(2), + &Checksum::Sha256("0".repeat(64)), + Some(2), + &mut |_, _| {}, + ) + .unwrap_err(); + assert!(matches!( + error, + super::DownloadError::SizeMismatch { expected: 2, .. } + )); + assert!(output.is_empty()); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4dbf863..5618b27 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,399 +10,28 @@ mod remote; mod runtime; mod session; mod settings; +mod storage; +mod trusted_http; -use reqwest::blocking::Client; -use serde::Serialize; -use std::{path::Path, sync::Arc, time::SystemTime}; -use tauri::{AppHandle, Emitter, Manager}; - -fn http_client() -> Client { - Client::new() -} - -fn game_dir(app: &AppHandle) -> Result { - 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 { - 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 { - 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::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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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, - error: Option, -} - -/// 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, 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 { - 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 { - 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, -} - -/// Launches `profile_id` as the account chosen in settings (`account_mode`). -/// In `Microsoft` mode a real session is required (see `resolve_identity`); -/// in `Offline` mode the local nickname from settings is used, so no -/// Microsoft account is needed. Spawns the game detached; watches it on a -/// background thread only to emit `game-exited` when it eventually closes. -#[tauri::command] -async fn launch_game(app: AppHandle, profile_id: String) -> Result<(), String> { - let game_dir = game_dir(&app)?; - let profile_dir = profile_dir(&app, &profile_id)?; - let data_dir = app.path().app_data_dir().map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?; - - tauri::async_runtime::spawn_blocking(move || -> Result<(), String> { - let client = http_client(); - let identity = resolve_identity(&client, &data_dir)?; - let manifest = remote::fetch_manifest(&profile_id).map_err(|error| error.to_string())?; - let settings = settings::load(&data_dir).map_err(|error| error.to_string())?; - - // Everything here should already be installed by `ensure_game_installed`, - // so these are expected to hit their fast paths; no progress to show. - let no_progress: mojang::ProgressCallback = Arc::new(|_, _| {}); - let java_install = java::ensure_java(&client, &game_dir.join("runtime"), manifest.minecraft.java_major, &no_progress).map_err(|error| error.to_string())?; - let merged = resolve_merged_version(&client, &manifest, Path::new(&java_install.executable), &game_dir, &game_dir.join("cache"), &no_progress)?; - - let timestamp = SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs(); - let log_dir = data_dir.join("logs"); - std::fs::create_dir_all(&log_dir).map_err(|error| error.to_string())?; - let log_path = log_dir.join(format!("{profile_id}-{timestamp}.log")); - - let request = launch::LaunchRequest { - java_executable: Path::new(&java_install.executable), - game_dir: &game_dir, - profile_dir: &profile_dir, - merged: &merged, - identity: &identity, - memory_mb: settings.memory_mb, - log_path: &log_path, - }; - let mut child = launch::launch(&request).map_err(|error| error.to_string())?; - - let watch_app = app.clone(); - let watch_profile_id = profile_id.clone(); - std::thread::spawn(move || { - let exit_code = child.wait().ok().and_then(|status| status.code()); - let _ = watch_app.emit("game-exited", GameExited { profile_id: watch_profile_id, exit_code }); - }); - - Ok(()) - }) - .await - .map_err(|error| format!("Launch task failed: {error}"))? -} +mod commands; +mod operations; pub fn run() { tauri::Builder::default() + .manage(operations::LauncherOperations::default()) .invoke_handler(tauri::generate_handler![ - native_host, - detect_java, - validate_manifest, - inspect_profile, - sync_profile, - inspect_remote_profile, - sync_remote_profile, - load_settings, - save_settings, - start_microsoft_login, - get_account, - logout, - ensure_game_installed, - launch_game + commands::host::native_host, + commands::host::detect_java, + commands::host::validate_manifest, + commands::profiles::inspect_remote_profile, + commands::profiles::sync_remote_profile, + commands::preferences::load_settings, + commands::preferences::save_settings, + commands::account::start_microsoft_login, + commands::account::get_account, + commands::account::logout, + commands::game::ensure_game_installed, + commands::game::launch_game ]) .run(tauri::generate_context!()) .expect("error while running ShaCraft Launcher"); diff --git a/src-tauri/src/manifest.rs b/src-tauri/src/manifest.rs index d4d8ab1..fb296e4 100644 --- a/src-tauri/src/manifest.rs +++ b/src-tauri/src/manifest.rs @@ -1,6 +1,5 @@ use serde::Deserialize; use std::{collections::HashSet, fmt}; -use url::Url; const MAX_MANIFEST_BYTES: usize = 2 * 1024 * 1024; const CURRENT_SCHEMA_VERSION: u32 = 1; @@ -82,19 +81,27 @@ fn validate(manifest: &Manifest) -> Result<(), ManifestError> { ))); } if !is_identifier(&manifest.id) { - return Err(ManifestError::Invalid("Profile id must contain only lowercase letters, numbers and hyphens".into())); + return Err(ManifestError::Invalid( + "Profile id must contain only lowercase letters, numbers and hyphens".into(), + )); } if manifest.display_name.trim().is_empty() { - return Err(ManifestError::Invalid("Profile displayName cannot be empty".into())); + return Err(ManifestError::Invalid( + "Profile displayName cannot be empty".into(), + )); } - if manifest.minecraft.version.trim().is_empty() + if !is_version(&manifest.minecraft.version) || manifest.minecraft.loader.kind.trim().is_empty() - || manifest.minecraft.loader.version.trim().is_empty() + || !is_version(&manifest.minecraft.loader.version) { - return Err(ManifestError::Invalid("Minecraft version and loader must be specified".into())); + return Err(ManifestError::Invalid( + "Minecraft version and loader must be specified".into(), + )); } if !(8..=25).contains(&manifest.minecraft.java_major) { - return Err(ManifestError::Invalid("Unsupported Java major version".into())); + return Err(ManifestError::Invalid( + "Unsupported Java major version".into(), + )); } let mut paths = HashSet::new(); @@ -103,19 +110,35 @@ fn validate(manifest: &Manifest) -> Result<(), ManifestError> { FilePolicy::Managed | FilePolicy::Seed => {} } if !is_safe_relative_path(&file.path) { - return Err(ManifestError::Invalid(format!("Unsafe file path: {}", file.path))); + return Err(ManifestError::Invalid(format!( + "Unsafe file path: {}", + file.path + ))); } - if !paths.insert(&file.path) { - return Err(ManifestError::Invalid(format!("Duplicate file path: {}", file.path))); + // A manifest must resolve to the same distinct files on Windows/macOS. + if !paths.insert(file.path.to_lowercase()) { + return Err(ManifestError::Invalid(format!( + "Duplicate file path: {}", + file.path + ))); } if !is_allowed_download_url(&file.url) { - return Err(ManifestError::Invalid(format!("File URL must use HTTPS and a ShaCraft host: {}", file.path))); + return Err(ManifestError::Invalid(format!( + "File URL must use HTTPS and a ShaCraft host: {}", + file.path + ))); } if file.size == 0 { - return Err(ManifestError::Invalid(format!("File has zero size: {}", file.path))); + return Err(ManifestError::Invalid(format!( + "File has zero size: {}", + file.path + ))); } if file.sha256.len() != 64 || !file.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) { - return Err(ManifestError::Invalid(format!("Invalid SHA-256 for {}", file.path))); + return Err(ManifestError::Invalid(format!( + "Invalid SHA-256 for {}", + file.path + ))); } } Ok(()) @@ -124,22 +147,49 @@ fn validate(manifest: &Manifest) -> Result<(), ManifestError> { fn is_identifier(value: &str) -> bool { !value.is_empty() && value.len() <= 48 - && value.bytes().all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') +} + +fn is_version(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value != "." + && value != ".." + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b".-_".contains(&byte)) } fn is_safe_relative_path(value: &str) -> bool { - !value.is_empty() - && !value.starts_with('/') - && !value.starts_with('\\') - && !value.contains('\\') - && !value.split('/').any(|part| part.is_empty() || part == "." || part == "..") + !value.is_empty() && value.split('/').all(is_portable_component) +} + +pub(crate) fn is_portable_component(value: &str) -> bool { + if value.is_empty() + || value.ends_with(['.', ' ']) + || value + .chars() + .any(|ch| ch.is_control() || "\\:<>\"|?*".contains(ch)) + { + return false; + } + let stem = value + .split('.') + .next() + .unwrap_or_default() + .to_ascii_uppercase(); + !matches!( + stem.as_str(), + "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$" + ) && !(stem.len() == 4 + && (stem.starts_with("COM") || stem.starts_with("LPT")) + && matches!(stem.as_bytes()[3], b'1'..=b'9')) } pub(crate) fn is_allowed_download_url(value: &str) -> bool { - let Ok(url) = Url::parse(value) else { - return false; - }; - url.scheme() == "https" && url.host_str().is_some_and(|host| DOWNLOAD_HOSTS.contains(&host)) + crate::trusted_http::allows(value, &DOWNLOAD_HOSTS) } #[cfg(test)] @@ -179,4 +229,35 @@ mod tests { fn rejects_third_party_download_hosts() { assert!(validate_json(&VALID.replace("cdn.shacraft.ru", "example.com")).is_err()); } + + #[test] + fn rejects_nonportable_paths_and_version_traversal() { + for path in [ + "C:/escape.jar", + "mods/file.jar:stream", + "mods/CON.jar", + "mods/LPT1", + "mods/file.jar.", + "mods/file.jar ", + "mods//file.jar", + "mods/../file.jar", + ] { + assert!( + validate_json(&VALID.replace("mods/example.jar", path)).is_err(), + "{path}" + ); + } + assert!(validate_json(&VALID.replace("21.1.248", "../../escape")).is_err()); + } + + #[test] + fn rejects_ambiguous_download_authorities() { + for host in [ + "user@cdn.shacraft.ru", + "cdn.shacraft.ru:444", + "cdn.shacraft.ru.evil.example", + ] { + assert!(validate_json(&VALID.replace("cdn.shacraft.ru", host)).is_err()); + } + } } diff --git a/src-tauri/src/mojang.rs b/src-tauri/src/mojang.rs index ed81f44..26ac2a0 100644 --- a/src-tauri/src/mojang.rs +++ b/src-tauri/src/mojang.rs @@ -25,7 +25,6 @@ use std::{ Arc, Mutex, }, }; -use url::Url; const VERSION_MANIFEST_URL: &str = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"; const MOJANG_HOSTS: [&str; 4] = [ @@ -41,7 +40,11 @@ const MOJANG_HOSTS: [&str; 4] = [ const ASSET_WORKERS: usize = 48; pub fn is_allowed_host(url: &str) -> bool { - Url::parse(url).ok().and_then(|parsed| parsed.host_str().map(|host| MOJANG_HOSTS.contains(&host))).unwrap_or(false) + crate::trusted_http::allows(url, &MOJANG_HOSTS) +} + +pub fn http_client() -> Result { + crate::trusted_http::client(&MOJANG_HOSTS, std::time::Duration::from_secs(10 * 60)) } #[derive(Debug)] diff --git a/src-tauri/src/msa.rs b/src-tauri/src/msa.rs index 75a5878..64fa513 100644 --- a/src-tauri/src/msa.rs +++ b/src-tauri/src/msa.rs @@ -45,6 +45,13 @@ const MINECRAFT_LOGIN_URL: &str = "https://api.minecraftservices.com/authenticat const MINECRAFT_PROFILE_URL: &str = "https://api.minecraftservices.com/minecraft/profile"; const ACCOUNT_FILE: &str = "account.json"; +pub fn http_client() -> Result { + crate::trusted_http::client(&[ + "login.microsoftonline.com", "user.auth.xboxlive.com", + "xsts.auth.xboxlive.com", "api.minecraftservices.com", + ], Duration::from_secs(30)) +} + #[derive(Debug)] pub enum MsaError { NotConfigured, @@ -405,14 +412,7 @@ pub fn save_refresh_token(data_dir: &Path, refresh_token: &str) -> io::Result<() let contents = serde_json::to_vec_pretty(&StoredAccount { refresh_token: refresh_token.to_string(), saved_at_unix }).expect("StoredAccount is serializable"); let target = data_dir.join(ACCOUNT_FILE); - let temporary = data_dir.join(".account.json.shacraft.part"); - fs::write(&temporary, contents)?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600))?; - } - fs::rename(temporary, target) + crate::storage::write_atomic(&target, &contents) } pub fn load_refresh_token(data_dir: &Path) -> Option { diff --git a/src-tauri/src/neoforge.rs b/src-tauri/src/neoforge.rs index 32d5e02..34af82b 100644 --- a/src-tauri/src/neoforge.rs +++ b/src-tauri/src/neoforge.rs @@ -39,7 +39,6 @@ use std::{ }, thread, }; -use url::Url; const NEOFORGE_HOST: &str = "maven.neoforged.net"; @@ -89,7 +88,11 @@ impl From for NeoForgeError { } fn is_allowed_host(url: &str) -> bool { - Url::parse(url).ok().and_then(|parsed| parsed.host_str().map(|host| host == NEOFORGE_HOST)).unwrap_or(false) + crate::trusted_http::allows(url, &[NEOFORGE_HOST]) +} + +pub fn http_client() -> Result { + crate::trusted_http::client(&[NEOFORGE_HOST], std::time::Duration::from_secs(10 * 60)) } fn installer_jar_url(loader_version: &str) -> String { diff --git a/src-tauri/src/operations.rs b/src-tauri/src/operations.rs new file mode 100644 index 0000000..11cc177 --- /dev/null +++ b/src-tauri/src/operations.rs @@ -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); + +impl Operation { + pub fn acquire(&self, label: &str) -> Result { + 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); + +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()); + } +} diff --git a/src-tauri/src/profile.rs b/src-tauri/src/profile.rs index 23ea3ad..c5705a6 100644 --- a/src-tauri/src/profile.rs +++ b/src-tauri/src/profile.rs @@ -2,7 +2,11 @@ use crate::download::{self, Checksum, DownloadError}; use crate::manifest::{is_allowed_download_url, FilePolicy, ManagedFile, Manifest}; use reqwest::{blocking::Client, redirect::Policy}; use serde::Serialize; -use std::{fmt, io, path::Path}; +use std::{ + fmt, fs, io, + path::{Path, PathBuf}, + time::Duration, +}; #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] @@ -28,14 +32,22 @@ pub enum ProfileError { Io(io::Error), Network(reqwest::Error), Download { path: String, source: DownloadError }, + UnsafePath(PathBuf), } impl fmt::Display for ProfileError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Io(error) => write!(formatter, "Cannot inspect profile: {error}"), + Self::Io(error) => write!(formatter, "Cannot access profile: {error}"), Self::Network(error) => write!(formatter, "Cannot download profile file: {error}"), - Self::Download { path, source } => write!(formatter, "Download failed for {path}: {source}"), + Self::Download { path, source } => { + write!(formatter, "Download failed for {path}: {source}") + } + Self::UnsafePath(path) => write!( + formatter, + "Profile path contains a symbolic link: {}", + path.display() + ), } } } @@ -45,11 +57,14 @@ pub fn inspect(root: &Path, manifest: &Manifest) -> Result Result Result { let client = Client::builder() + .connect_timeout(Duration::from_secs(15)) + .timeout(Duration::from_secs(10 * 60)) .redirect(Policy::custom(|attempt| { - if is_allowed_download_url(attempt.url().as_str()) { + if attempt.previous().len() >= 10 { + attempt.error("too many redirects") + } else if is_allowed_download_url(attempt.url().as_str()) { attempt.follow() } else { attempt.stop() @@ -82,13 +101,15 @@ pub fn sync(root: &Path, manifest: &Manifest) -> Result Result Result { +/// 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 { + 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 { let checksum = Checksum::Sha256(expected.sha256.clone()); - download::download_verified(client, &expected.url, target, Some(expected.size), &checksum, |_, _| {}).map_err(|error| { - ProfileError::Download { path: expected.path.clone(), source: error } + download::download_verified( + client, + &expected.url, + target, + Some(expected.size), + &checksum, + |_, _| {}, + ) + .map_err(|error| ProfileError::Download { + path: expected.path.clone(), + source: error, }) } @@ -118,7 +180,10 @@ mod tests { use super::inspect; use crate::manifest::{FilePolicy, Loader, ManagedFile, Manifest, Minecraft}; use sha2::{Digest, Sha256}; - use std::{fs, process, time::{SystemTime, UNIX_EPOCH}}; + use std::{ + fs, process, + time::{SystemTime, UNIX_EPOCH}, + }; fn manifest(hash: String, size: u64) -> Manifest { Manifest { @@ -127,7 +192,10 @@ mod tests { display_name: "Aeronautics".into(), minecraft: Minecraft { version: "1.21.1".into(), - loader: Loader { kind: "neoforge".into(), version: "21.1.248".into() }, + loader: Loader { + kind: "neoforge".into(), + version: "21.1.248".into(), + }, java_major: 21, }, files: vec![ManagedFile { @@ -145,7 +213,10 @@ mod tests { let root = std::env::temp_dir().join(format!( "shacraft-launcher-test-{}-{}", process::id(), - SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() )); let bytes = b"ShaCraft test file"; let digest = format!("{:x}", Sha256::digest(bytes)); @@ -164,4 +235,44 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + + #[test] + fn edited_seed_files_remain_up_to_date() { + let root = std::env::temp_dir().join(format!( + "shacraft-seed-test-{}-{}", + process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(root.join("mods")).unwrap(); + fs::write(root.join("mods/example.jar"), b"player's edits").unwrap(); + let mut expected = manifest("0".repeat(64), 42); + expected.files[0].policy = FilePolicy::Seed; + assert!(inspect(&root, &expected).unwrap().up_to_date); + fs::remove_dir_all(root).unwrap(); + } + + #[cfg(unix)] + #[test] + fn refuses_linked_profile_directories() { + use std::os::unix::fs::symlink; + let root = std::env::temp_dir().join(format!( + "shacraft-link-test-{}-{}", + process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(root.join("outside")).unwrap(); + fs::create_dir_all(root.join("profile")).unwrap(); + symlink(root.join("outside"), root.join("profile/mods")).unwrap(); + assert!(matches!( + inspect(&root.join("profile"), &manifest("0".repeat(64), 42)), + Err(super::ProfileError::UnsafePath(_)) + )); + fs::remove_dir_all(root).unwrap(); + } } diff --git a/src-tauri/src/remote.rs b/src-tauri/src/remote.rs index ebfe07d..7deb7b2 100644 --- a/src-tauri/src/remote.rs +++ b/src-tauri/src/remote.rs @@ -1,13 +1,20 @@ use crate::manifest::{self, Manifest}; use base64::{engine::general_purpose::STANDARD, Engine}; -use ed25519_dalek::{Signature, Verifier, VerifyingKey}; +use ed25519_dalek::{Signature, VerifyingKey}; use reqwest::{blocking::Client, redirect::Policy}; use serde::Deserialize; -use std::{fmt, time::Duration}; +use std::{ + fmt, + io::{self, Read}, + time::Duration, +}; const AERONAUTICS_MANIFEST: &str = "https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest"; const MANIFEST_PUBLIC_KEY: &str = "2S3FRdZj4Xw5nJpZ3IhqVITBg3nTH9AtGSo1Ew9+qVQ="; +const MANIFEST_KEY_ID: &str = "2026-09-06"; +// Allow the base64 envelope around a payload of up to 2 MiB. +const MAX_ENVELOPE_BYTES: usize = 3 * 1024 * 1024; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] @@ -24,7 +31,9 @@ pub enum RemoteError { Network(reqwest::Error), Status(reqwest::StatusCode), TooLarge, + Read(io::Error), InvalidSignature, + ProfileMismatch, InvalidManifest(manifest::ManifestError), } @@ -35,8 +44,14 @@ impl fmt::Display for RemoteError { Self::Network(error) => write!(formatter, "Cannot load ShaCraft manifest: {error}"), Self::Status(status) => write!(formatter, "ShaCraft manifest request failed: {status}"), Self::TooLarge => formatter.write_str("ShaCraft manifest is too large"), + Self::Read(error) => write!(formatter, "Cannot read ShaCraft manifest: {error}"), Self::InvalidSignature => formatter.write_str("ShaCraft manifest signature is invalid"), - Self::InvalidManifest(error) => write!(formatter, "ShaCraft manifest is invalid: {error}"), + Self::ProfileMismatch => { + formatter.write_str("Signed manifest does not match the requested profile") + } + Self::InvalidManifest(error) => { + write!(formatter, "ShaCraft manifest is invalid: {error}") + } } } } @@ -55,22 +70,147 @@ pub fn fetch_manifest(profile_id: &str) -> Result { if !response.status().is_success() { return Err(RemoteError::Status(response.status())); } - if response.content_length().is_some_and(|size| size > 2 * 1024 * 1024) { + if response + .content_length() + .is_some_and(|size| size > MAX_ENVELOPE_BYTES as u64) + { return Err(RemoteError::TooLarge); } - let source = response.text().map_err(RemoteError::Network)?; - let envelope = serde_json::from_str::(&source) + let source = read_envelope(response)?; + let public_key_bytes = STANDARD + .decode(MANIFEST_PUBLIC_KEY) + .expect("embedded public key must be valid"); + let public_key = VerifyingKey::from_bytes( + &public_key_bytes + .try_into() + .expect("embedded public key must be 32 bytes"), + ) + .expect("embedded public key must be valid"); + verify_envelope(&source, profile_id, &public_key) +} + +fn read_envelope(source: impl Read) -> Result, 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 { + if source.len() > MAX_ENVELOPE_BYTES { + return Err(RemoteError::TooLarge); + } + let envelope = serde_json::from_slice::(source) .map_err(|_| RemoteError::InvalidSignature)?; - if envelope.schema_version != 1 || envelope.key_id != "2026-09-06" { + if envelope.schema_version != 1 || envelope.key_id != MANIFEST_KEY_ID { return Err(RemoteError::InvalidSignature); } - let payload = STANDARD.decode(envelope.payload).map_err(|_| RemoteError::InvalidSignature)?; - let signature_bytes = STANDARD.decode(envelope.signature).map_err(|_| RemoteError::InvalidSignature)?; - let public_key_bytes = STANDARD.decode(MANIFEST_PUBLIC_KEY).expect("embedded public key must be valid"); - let public_key = VerifyingKey::from_bytes(&public_key_bytes.try_into().expect("embedded public key must be 32 bytes")) - .expect("embedded public key must be valid"); - let signature = Signature::from_slice(&signature_bytes).map_err(|_| RemoteError::InvalidSignature)?; - public_key.verify(&payload, &signature).map_err(|_| RemoteError::InvalidSignature)?; + let payload = STANDARD + .decode(envelope.payload) + .map_err(|_| RemoteError::InvalidSignature)?; + let signature_bytes = STANDARD + .decode(envelope.signature) + .map_err(|_| RemoteError::InvalidSignature)?; + let signature = + Signature::from_slice(&signature_bytes).map_err(|_| RemoteError::InvalidSignature)?; + public_key + .verify_strict(&payload, &signature) + .map_err(|_| RemoteError::InvalidSignature)?; let payload = String::from_utf8(payload).map_err(|_| RemoteError::InvalidSignature)?; - manifest::validate_json(&payload).map_err(RemoteError::InvalidManifest) + let manifest = manifest::validate_json(&payload).map_err(RemoteError::InvalidManifest)?; + if manifest.id != profile_id { + return Err(RemoteError::ProfileMismatch); + } + Ok(manifest) +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::{Signer, SigningKey}; + use serde_json::{json, Value}; + + #[test] + #[ignore = "read-only check of the production signed manifest; requires network"] + fn live_validates_production_aeronautics_manifest() { + let manifest = fetch_manifest("aeronautics").unwrap(); + assert_eq!(manifest.id, "aeronautics"); + assert!(!manifest.files.is_empty()); + } + + fn signed_fixture() -> (Value, VerifyingKey) { + let key = SigningKey::from_bytes(&[17; 32]); + let payload = serde_json::to_vec(&json!({ + "schemaVersion": 1, "id": "aeronautics", "displayName": "Aeronautics", + "minecraft": {"version": "1.21.1", "loader": {"kind": "neoforge", "version": "21.1.248"}, "javaMajor": 21}, + "files": [] + })).unwrap(); + let envelope = json!({ + "schemaVersion": 1, "keyId": MANIFEST_KEY_ID, + "payload": STANDARD.encode(&payload), + "signature": STANDARD.encode(key.sign(&payload).to_bytes()) + }); + (envelope, key.verifying_key()) + } + + #[test] + fn accepts_valid_signature_and_binds_requested_profile() { + let (envelope, key) = signed_fixture(); + let bytes = serde_json::to_vec(&envelope).unwrap(); + assert_eq!( + verify_envelope(&bytes, "aeronautics", &key).unwrap().id, + "aeronautics" + ); + assert!(matches!( + verify_envelope(&bytes, "another-profile", &key), + Err(RemoteError::ProfileMismatch) + )); + } + + #[test] + fn rejects_modified_payload_signature_key_and_schema() { + let (original, key) = signed_fixture(); + for (field, value) in [ + ("payload", json!(STANDARD.encode(b"{}"))), + ("signature", json!(STANDARD.encode([0; 64]))), + ("keyId", json!("unknown")), + ("schemaVersion", json!(2)), + ] { + let mut envelope = original.clone(); + envelope[field] = value; + assert!( + matches!( + verify_envelope(&serde_json::to_vec(&envelope).unwrap(), "aeronautics", &key), + Err(RemoteError::InvalidSignature) + ), + "{field}" + ); + } + let other_key = SigningKey::from_bytes(&[18; 32]).verifying_key(); + assert!(matches!( + verify_envelope( + &serde_json::to_vec(&original).unwrap(), + "aeronautics", + &other_key + ), + Err(RemoteError::InvalidSignature) + )); + } + + #[test] + fn bounds_stream_without_content_length() { + assert!(matches!( + read_envelope(io::repeat(b'x')), + Err(RemoteError::TooLarge) + )); + } } diff --git a/src-tauri/src/runtime.rs b/src-tauri/src/runtime.rs index dfa2c7d..9b9e801 100644 --- a/src-tauri/src/runtime.rs +++ b/src-tauri/src/runtime.rs @@ -12,12 +12,18 @@ use serde::Deserialize; use std::{fmt, fs, io, path::{Path, PathBuf}}; const ADOPTIUM_HOST: &str = "api.adoptium.net"; +const RUNTIME_HOSTS: [&str; 4] = [ADOPTIUM_HOST, "github.com", "objects.githubusercontent.com", "release-assets.githubusercontent.com"]; + +pub fn http_client() -> Result { + crate::trusted_http::client(&RUNTIME_HOSTS, std::time::Duration::from_secs(10 * 60)) +} #[derive(Debug)] pub enum RuntimeError { Network(reqwest::Error), HttpStatus(reqwest::StatusCode), NoRelease, + UntrustedPackage, UnexpectedArchiveLayout, Download(DownloadError), Io(io::Error), @@ -30,6 +36,7 @@ impl fmt::Display for RuntimeError { Self::Network(error) => write!(formatter, "network error: {error}"), Self::HttpStatus(status) => write!(formatter, "Adoptium returned {status}"), Self::NoRelease => formatter.write_str("Adoptium has no matching JRE release for this platform"), + Self::UntrustedPackage => formatter.write_str("Adoptium package has an unsafe archive name, URL or checksum"), Self::UnexpectedArchiveLayout => formatter.write_str("Java archive did not contain a single top-level directory as expected"), Self::Download(error) => write!(formatter, "{error}"), Self::Io(error) => write!(formatter, "I/O error: {error}"), @@ -76,6 +83,18 @@ struct AdoptiumPackage { name: String, } +fn validate_package(package: &AdoptiumPackage) -> Result<(), RuntimeError> { + if !crate::manifest::is_portable_component(&package.name) + || package.name.contains('/') + || !(package.name.ends_with(".tar.gz") || package.name.ends_with(".zip")) + || !crate::trusted_http::allows(&package.link, &RUNTIME_HOSTS) + || package.checksum.len() != 64 + || !package.checksum.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(RuntimeError::UntrustedPackage); + } + Ok(()) +} + fn adoptium_os() -> &'static str { if cfg!(target_os = "windows") { "windows" @@ -138,6 +157,7 @@ pub fn ensure_runtime(client: &Client, runtime_root: &Path, major: u8, on_progre } let assets: Vec = response.json()?; let package = assets.into_iter().next().map(|asset| asset.binary.package).ok_or(RuntimeError::NoRelease)?; + validate_package(&package)?; fs::create_dir_all(runtime_root)?; let archive_path = runtime_root.join(&package.name); @@ -210,6 +230,42 @@ mod tests { assert!(path.ends_with(java_executable_name())); } + fn package() -> AdoptiumPackage { + AdoptiumPackage { + name: "OpenJDK21U-jre_x64_linux_hotspot_21.0.8_9.tar.gz".into(), + link: "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.8%2B9/runtime.tar.gz".into(), + checksum: "a".repeat(64), + } + } + + #[test] + fn accepts_only_portable_runtime_archive_names() { + assert!(validate_package(&package()).is_ok()); + let mut windows = package(); + windows.name = "OpenJDK21U-jre_x64_windows_hotspot.zip".into(); + assert!(validate_package(&windows).is_ok()); + for name in ["../runtime.tar.gz", "/runtime.zip", "C:\\runtime.zip", "runtime.zip:stream", "CON.zip", "LPT1.zip", "runtime.zip.", "runtime.zip ", "runtime.exe"] { + let mut malicious = package(); + malicious.name = name.into(); + assert!(matches!(validate_package(&malicious), Err(RuntimeError::UntrustedPackage)), "{name}"); + } + } + + #[test] + fn rejects_untrusted_runtime_urls_and_invalid_hashes() { + for link in ["http://github.com/runtime.zip", "https://evil.example/runtime.zip", "https://github.com.evil.example/runtime.zip", "https://user@github.com/runtime.zip"] { + let mut malicious = package(); + malicious.link = link.into(); + assert!(validate_package(&malicious).is_err()); + } + let mut malicious = package(); + malicious.checksum = "not-a-checksum".into(); + assert!(validate_package(&malicious).is_err()); + for host in RUNTIME_HOSTS { + assert!(crate::trusted_http::allows(&format!("https://{host}/release.tar.gz"), &RUNTIME_HOSTS)); + } + } + /// Live smoke test: resolves the current platform's latest Temurin 21 /// JRE from Adoptium, downloads it, verifies the checksum, and extracts /// it. Not run by default; `cargo test -- --ignored ensure_runtime`. diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index acf5645..d631c8a 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -38,7 +38,11 @@ fn default_nickname() -> String { impl Default for LauncherSettings { fn default() -> Self { - Self { memory_mb: DEFAULT_MEMORY_MB, nickname: DEFAULT_NICKNAME.into(), account_mode: AccountMode::Offline } + Self { + memory_mb: DEFAULT_MEMORY_MB, + nickname: DEFAULT_NICKNAME.into(), + account_mode: AccountMode::Offline, + } } } @@ -55,8 +59,13 @@ impl fmt::Display for SettingsError { match self { Self::Io(error) => write!(formatter, "Cannot access launcher settings: {error}"), Self::InvalidJson(error) => write!(formatter, "Cannot read launcher settings: {error}"), - Self::InvalidMemory => write!(formatter, "Memory allocation must be between 3 and 12 GiB"), - Self::InvalidNickname => write!(formatter, "Nickname must be 3-16 ASCII letters, numbers, or underscores"), + Self::InvalidMemory => { + write!(formatter, "Memory allocation must be between 3 and 12 GiB") + } + Self::InvalidNickname => write!( + formatter, + "Nickname must be 3-16 ASCII letters, numbers, or underscores" + ), } } } @@ -65,7 +74,9 @@ pub fn load(data_dir: &Path) -> Result { let path = data_dir.join(SETTINGS_FILE); let source = match fs::read_to_string(path) { Ok(source) => source, - Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(LauncherSettings::default()), + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Ok(LauncherSettings::default()) + } Err(error) => return Err(SettingsError::Io(error)), }; let settings = serde_json::from_str(&source).map_err(SettingsError::InvalidJson)?; @@ -73,23 +84,31 @@ pub fn load(data_dir: &Path) -> Result { Ok(settings) } -pub fn save(data_dir: &Path, settings: LauncherSettings) -> Result { +pub fn save( + data_dir: &Path, + settings: LauncherSettings, +) -> Result { validate(&settings)?; fs::create_dir_all(data_dir).map_err(SettingsError::Io)?; let target = data_dir.join(SETTINGS_FILE); - let temporary = data_dir.join(".settings.json.shacraft.part"); let contents = serde_json::to_vec_pretty(&settings).expect("LauncherSettings is serializable"); - fs::write(&temporary, contents).map_err(SettingsError::Io)?; - fs::rename(temporary, target).map_err(SettingsError::Io)?; + crate::storage::write_atomic(&target, &contents).map_err(SettingsError::Io)?; Ok(settings) } fn validate(settings: &LauncherSettings) -> Result<(), SettingsError> { - if !(MIN_MEMORY_MB..=MAX_MEMORY_MB).contains(&settings.memory_mb) || settings.memory_mb % 1024 != 0 { + if !(MIN_MEMORY_MB..=MAX_MEMORY_MB).contains(&settings.memory_mb) + || settings.memory_mb % 1024 != 0 + { return Err(SettingsError::InvalidMemory); } - if !(3..=16).contains(&settings.nickname.len()) || !settings.nickname.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') { + if !(3..=16).contains(&settings.nickname.len()) + || !settings + .nickname + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + { return Err(SettingsError::InvalidNickname); } Ok(()) @@ -98,13 +117,19 @@ fn validate(settings: &LauncherSettings) -> Result<(), SettingsError> { #[cfg(test)] mod tests { use super::{load, save, AccountMode, LauncherSettings}; - use std::{fs, process, time::{SystemTime, UNIX_EPOCH}}; + use std::{ + fs, process, + time::{SystemTime, UNIX_EPOCH}, + }; fn temporary_directory() -> std::path::PathBuf { std::env::temp_dir().join(format!( "shacraft-settings-test-{}-{}", process::id(), - SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() )) } @@ -116,9 +141,20 @@ mod tests { assert_eq!(default.nickname, "Emil"); assert_eq!(default.account_mode, AccountMode::Offline); - let saved = save(&directory, LauncherSettings { memory_mb: 8 * 1024, nickname: "Emil".into(), account_mode: AccountMode::Microsoft }).unwrap(); + let saved = save( + &directory, + LauncherSettings { + memory_mb: 8 * 1024, + nickname: "Emil".into(), + account_mode: AccountMode::Microsoft, + }, + ) + .unwrap(); assert_eq!(saved.memory_mb, 8 * 1024); - assert_eq!(load(&directory).unwrap().account_mode, AccountMode::Microsoft); + assert_eq!( + load(&directory).unwrap().account_mode, + AccountMode::Microsoft + ); fs::remove_dir_all(directory).unwrap(); } @@ -126,8 +162,24 @@ mod tests { #[test] fn rejects_unsafe_memory_values() { let directory = temporary_directory(); - assert!(save(&directory, LauncherSettings { memory_mb: 512, nickname: "Emil".into(), account_mode: AccountMode::Offline }).is_err()); - assert!(save(&directory, LauncherSettings { memory_mb: 6 * 1024, nickname: "невалидный".into(), account_mode: AccountMode::Offline }).is_err()); + assert!(save( + &directory, + LauncherSettings { + memory_mb: 512, + nickname: "Emil".into(), + account_mode: AccountMode::Offline + } + ) + .is_err()); + assert!(save( + &directory, + LauncherSettings { + memory_mb: 6 * 1024, + nickname: "невалидный".into(), + account_mode: AccountMode::Offline + } + ) + .is_err()); } #[test] diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs new file mode 100644 index 0000000..d5d1c19 --- /dev/null +++ b/src-tauri/src/storage.rs @@ -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, + committed: bool, +} + +impl AtomicFile { + pub fn new(target: &Path) -> io::Result { + 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(); + } +} diff --git a/src-tauri/src/trusted_http.rs b/src-tauri/src/trusted_http.rs new file mode 100644 index 0000000..1edc05d --- /dev/null +++ b/src-tauri/src/trusted_http.rs @@ -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::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}"); + } + } +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..6b4d191 --- /dev/null +++ b/src/App.tsx @@ -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 ( +
+ +
+ setSettingsOpen(true)} onLogout={session.logout} /> + + { if (!repairDisabled && selected.profileId) void launcher.repair(selected.profileId) }} /> + +
+ + +
+ ) +} diff --git a/src/components/Library.tsx b/src/components/Library.tsx new file mode 100644 index 0000000..71699bd --- /dev/null +++ b/src/components/Library.tsx @@ -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 + 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 ( + <> + + + + ) +} diff --git a/src/components/LoginModal.tsx b/src/components/LoginModal.tsx new file mode 100644 index 0000000..4fc895d --- /dev/null +++ b/src/components/LoginModal.tsx @@ -0,0 +1,16 @@ +import type { DeviceCodePayload } from '../types/launcher' + +export function LoginModal({ code }: { code: DeviceCodePayload | null }) { + if (!code) return null + return ( + <> +
+
+

Вход через Microsoft

+

Откройте страницу и введите код, чтобы подтвердить вход в аккаунт с лицензией Minecraft.

+
{code.userCode}
+

{code.verificationUri}

+
+ + ) +} diff --git a/src/components/PlayDock.tsx b/src/components/PlayDock.tsx new file mode 100644 index 0000000..088615e --- /dev/null +++ b/src/components/PlayDock.tsx @@ -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 ( +
+
+ + {working ? : server.disabled ? : } + + {title}{error ?? detail} + {percent !== null &&
} +
+
+ {server.version} + {memoryGb} ГБ памяти +
+ + +
+ ) +} diff --git a/src/components/ServerStage.tsx b/src/components/ServerStage.tsx new file mode 100644 index 0000000..53feed6 --- /dev/null +++ b/src/components/ServerStage.tsx @@ -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 ( +
+
+
{server.disabled ? 'Не в сети' : 'Статус не проверен'}
+
{server.disabled ? 'Сервер остановлен' : 'Онлайн неизвестен'}
+
+
+

{server.kicker}

{server.name}

{server.subtitle}

+
+
Состав
{server.composition}
+
Загрузчик
{server.loader}
+
Java
Версия 21
+
+
+ {children} +
+ ) +} diff --git a/src/components/SettingsDrawer.tsx b/src/components/SettingsDrawer.tsx new file mode 100644 index 0000000..c016dd4 --- /dev/null +++ b/src/components/SettingsDrawer.tsx @@ -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 + session: ReturnType + onClose: () => void +} + +export function SettingsDrawer({ open, locked, host, java, preferences, session, onClose }: SettingsDrawerProps) { + const { settings, nickname, loaded, saving, error, nicknameError } = preferences + const closeButton = useRef(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('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 ( + <> +
+ + + ) +} diff --git a/src/components/Titlebar.tsx b/src/components/Titlebar.tsx new file mode 100644 index 0000000..00f5fba --- /dev/null +++ b/src/components/Titlebar.tsx @@ -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 ( +
+
ShaCraft
+
{host ? `Лаунчер · ${host.platform}` : 'Лаунчер'}
+
+ + + +
+
+ ) +} diff --git a/src/data/servers.ts b/src/data/servers.ts new file mode 100644 index 0000000..a33aac0 --- /dev/null +++ b/src/data/servers.ts @@ -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, + }, +] diff --git a/src/hooks/useAccount.ts b/src/hooks/useAccount.ts new file mode 100644 index 0000000..7aaadd4 --- /dev/null +++ b/src/hooks/useAccount.ts @@ -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(isNative() ? undefined : null) + const [code, setCode] = useState(null) + const [error, setError] = useState(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 } +} diff --git a/src/hooks/useLauncher.ts b/src/hooks/useLauncher.ts new file mode 100644 index 0000000..4576fbc --- /dev/null +++ b/src/hooks/useLauncher.ts @@ -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(null) + const [java, setJava] = useState(undefined) + const [environmentError, setEnvironmentError] = useState(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 } +} diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts new file mode 100644 index 0000000..72f8fb6 --- /dev/null +++ b/src/hooks/useSettings.ts @@ -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(null) + const [nicknameError, setNicknameError] = useState(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) => { + 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({}) + }, + } +} diff --git a/src/main.tsx b/src/main.tsx index 1b4e896..1fc8785 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,536 +1,9 @@ -import React, { useEffect, useState } from 'react' +import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' -import { invoke } from '@tauri-apps/api/core' -import { listen } from '@tauri-apps/api/event' -import { - ChevronRight, - Download, - FolderOpen, - Gauge, - Globe2, - Library, - LogOut, - MessageCircle, - Minus, - Newspaper, - Play, - RotateCcw, - Settings, - ShieldCheck, - Square, - Users, - Wrench, - X, -} from 'lucide-react' -import logo from './assets/shacraft-logo.png' +import { App } from './App' import './styles.css' -type Server = { - id: string - kicker: string - name: string - subtitle: string - players: string - version: string - memory: string - installed: boolean - profileId?: string - disabled?: boolean -} +const root = document.getElementById('root') +if (!root) throw new Error('Launcher root element is missing') -type NativeHost = { - platform: string - dataDir: string - launcherVersion: string -} - -type NativeSettings = { - memoryMb: number - nickname: string - accountMode: 'microsoft' | 'offline' -} - -type JavaInstallation = { - executable: string - major: number - version: string -} - -type ProfileInspection = { - managedFiles: number - missingFiles: number - mismatchedFiles: number - upToDate: boolean -} - -type SyncResult = { - downloadedFiles: number - reusedFiles: number - downloadedBytes: number -} - -type MinecraftProfile = { - id: string - name: string -} - -type DeviceCodePayload = { - verificationUri: string - userCode: string - expiresInSeconds: number -} - -type LoginResultPayload = { - ok: boolean - profile?: MinecraftProfile - error?: string -} - -type InstallProgressPayload = { - stage: 'java' | 'neoforge' | 'libraries' | 'assets' - currentBytes: number - totalBytes: number -} - -const INSTALL_STAGE_LABEL: Record = { - 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(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(null) - const [java, setJava] = useState(undefined) - const [profile, setProfile] = useState(null) - const [syncing, setSyncing] = useState(false) - const [syncError, setSyncError] = useState(null) - - // undefined = still checking for a saved session; null = signed out. - const [account, setAccount] = useState(undefined) - const [loginCode, setLoginCode] = useState(null) - const [loginError, setLoginError] = useState(null) - const [loggingIn, setLoggingIn] = useState(false) - const [installing, setInstalling] = useState(false) - const [installProgress, setInstallProgress] = useState(null) - const [launchError, setLaunchError] = useState(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('native_host').then(setNativeHost).catch(() => setNativeHost(null)) - invoke('load_settings') - .then((settings) => { - setRam(settings.memoryMb / 1024) - setNickname(settings.nickname) - setAccountMode(settings.accountMode) - }) - .catch(() => undefined) - invoke('detect_java') - .then(setJava) - .catch(() => setJava(null)) - invoke('inspect_remote_profile', { profileId: 'aeronautics' }) - .then((inspection) => { - setProfile(inspection) - setReady(inspection.upToDate) - }) - .catch(() => undefined) - invoke('get_account') - .then(setAccount) - .catch(() => setAccount(null)) - }, []) - - useEffect(() => { - if (!isTauri()) return - const unlisten = [ - listen('msa-login-code', (event) => setLoginCode(event.payload)), - listen('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('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('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('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 ( -
-
-
- - ShaCraft -
-
{nativeHost ? `Лаунчер · ${nativeHost.platform}` : 'Лаунчер'}
-
- - - -
-
- -
- - - - -
-
-
- {selected.disabled ? 'Не в сети' : 'Сервер работает'} -
-
{selected.players}
-
- -
-

{selected.id === 'aoc' ? 'All of Create / сборка 2.5' : selected.kicker}

-

{selected.name}

-

{selected.subtitle}

-
-
Состав
{selected.id === 'aoc' ? '250 модов' : '41 мод'}
-
Загрузчик
{selected.id === 'aoc' ? 'NeoForge 21.1.248' : 'NeoForge 21.1.249'}
-
Java
Версия 21
-
-
- -
-
- {installing ? ( - <> - - - {installProgress ? INSTALL_STAGE_LABEL[installProgress.stage] : 'Готовим установку'} - {installPercent !== null ? `${installPercent}%` : 'Проверяем файлы…'} - - - ) : syncing ? ( - <> - - Синхронизируем сборкуСкачиваем и проверяем файлы - - ) : progress !== null ? ( - <> - - - Проверяем сборку - Файлы и обновления · {progress}% - - - ) : selected.disabled ? ( - <> - - ТехобслуживаниеСообщим, когда сервер вернётся - - ) : ( - <> - - - {accountMode === 'microsoft' && account === null ? 'Нужен вход' : ready ? 'Сборка готова' : 'Требуется проверка'} - {launchError || syncError || loginError || (profile ? `${profile.managedFiles} файлов под контролем` : 'Проверяем локальные файлы')} - - - )} - {progress !== null &&
} - {installPercent !== null &&
} -
- -
- {selected.version} - {ram} ГБ памяти -
- - - -
-
-
- -
- {loginCode && ( -
-

Вход через Microsoft

-

Откройте страницу и введите код, чтобы подтвердить вход в аккаунт с лицензией Minecraft.

-
{loginCode.userCode}
-

{loginCode.verificationUri}

-
- )} - -
setSettingsOpen(false)} /> - -
- ) -} - -createRoot(document.getElementById('root')!).render( - , -) +createRoot(root).render() diff --git a/src/services/async.test.ts b/src/services/async.test.ts new file mode 100644 index 0000000..70c6172 --- /dev/null +++ b/src/services/async.test.ts @@ -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() { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((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() + 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() + 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) +}) diff --git a/src/services/async.ts b/src/services/async.ts new file mode 100644 index 0000000..cb6c618 --- /dev/null +++ b/src/services/async.ts @@ -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(operation: () => Promise): () => Promise { + let pending: Promise | 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 = Promise.resolve() + return { + enqueue(operation: () => Promise): Promise { + 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 } +} diff --git a/src/services/native.ts b/src/services/native.ts new file mode 100644 index 0000000..8dc35e4 --- /dev/null +++ b/src/services/native.ts @@ -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('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('native_host'), + loadSettings: () => invoke('load_settings'), + saveSettings: (settings: LauncherSettings) => invoke('save_settings', { settings }), + detectJava: () => invoke('detect_java'), + inspectProfile: (profileId: string) => invoke('inspect_remote_profile', { profileId }), + syncProfile: (profileId: string) => invoke('sync_remote_profile', { profileId }), + getAccount: restoreAccount, + startLogin: () => invoke('start_microsoft_login'), + logout: () => invoke('logout'), + installGame: (profileId: string) => invoke('ensure_game_installed', { profileId }), + launchGame: (profileId: string) => invoke('launch_game', { profileId }), +} + +export function watchAccount(handlers: { + code: (payload: DeviceCodePayload) => void + result: (payload: LoginResultPayload) => void +}) { + return createSubscription([ + listen('msa-login-code', ({ payload }) => handlers.code(payload)), + listen('msa-login-result', ({ payload }) => handlers.result(payload)), + ]) +} + +export function watchGame(handlers: { + progress: (payload: InstallProgressPayload) => void + exited: (payload: GameExitedPayload) => void +}) { + return createSubscription([ + listen('game-install-progress', ({ payload }) => handlers.progress(payload)), + listen('game-exited', ({ payload }) => handlers.exited(payload)), + ]) +} diff --git a/src/state/game.test.ts b/src/state/game.test.ts new file mode 100644 index 0000000..06a0893 --- /dev/null +++ b/src/state/game.test.ts @@ -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) +}) diff --git a/src/state/game.ts b/src/state/game.ts new file mode 100644 index 0000000..7efb709 --- /dev/null +++ b/src/state/game.ts @@ -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 = { + 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))) +} diff --git a/src/state/profiles.test.ts b/src/state/profiles.test.ts new file mode 100644 index 0000000..6940478 --- /dev/null +++ b/src/state/profiles.test.ts @@ -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) +}) diff --git a/src/state/profiles.ts b/src/state/profiles.ts new file mode 100644 index 0000000..b8c23f5 --- /dev/null +++ b/src/state/profiles.ts @@ -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, action: ProfileAction, +): Record { + 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 } +} diff --git a/src/state/settings.ts b/src/state/settings.ts new file mode 100644 index 0000000..e56a992 --- /dev/null +++ b/src/state/settings.ts @@ -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) diff --git a/src/styles.css b/src/styles.css index 16785de..ea85acb 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,6 +1,7 @@ @import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Unbounded:wght@500;600&display=swap'); :root { + color-scheme: dark; font-family: 'Manrope', sans-serif; color: #eef2ed; background: #090c0a; @@ -17,10 +18,11 @@ } * { box-sizing: border-box; } -button, input { font: inherit; } +button, input, select { font: inherit; } button { color: inherit; } body { margin: 0; min-width: 1040px; min-height: 680px; overflow: hidden; } -button:focus-visible, input:focus-visible { outline: 2px solid var(--green); outline-offset: 2px; } +button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid var(--green); outline-offset: 2px; } +button:disabled { cursor: default; } .app-shell { height: 100vh; @@ -77,6 +79,7 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--green); out .account-chip strong { font-size: 12px; } .account-chip small { color: #6d756e; font-size: 9px; } .account-chip svg { color: #555e56; } +.account-logout { padding: 0; background: transparent; border: 0; cursor: pointer; color: inherit; } .stage { position: relative; overflow: hidden; isolation: isolate; background: #16231b; } .stage::before { content: ''; position: absolute; inset: 0; z-index: -5; background: @@ -125,7 +128,7 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--green); out .drawer-backdrop { position: fixed; inset: 48px 0 0; background: rgba(0,0,0,.44); opacity: 0; pointer-events: none; transition: opacity .2s; z-index: 20; } .drawer-backdrop.visible { opacity: 1; pointer-events: auto; } -.settings-drawer { position: fixed; top: 48px; right: 0; bottom: 0; width: 390px; background: #121713; border-left: 1px solid var(--line); z-index: 21; padding: 28px; transform: translateX(100%); transition: transform .24s cubic-bezier(.2,.8,.2,1); box-shadow: -30px 0 70px rgba(0,0,0,.35); } +.settings-drawer { position: fixed; top: 48px; right: 0; bottom: 0; width: 390px; overflow-y: auto; background: #121713; border-left: 1px solid var(--line); z-index: 21; padding: 28px; transform: translateX(100%); transition: transform .24s cubic-bezier(.2,.8,.2,1); box-shadow: -30px 0 70px rgba(0,0,0,.35); } .settings-drawer.open { transform: translateX(0); } .drawer-title { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 34px; } .drawer-title p { color: #737c74; font-size: 11px; margin: 0 0 5px; } @@ -144,13 +147,18 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--green); out .setting-row.static { cursor: default; } .setting-row.static:hover { color: #abb3ac; } .setting-row small { color: #7f8a80; font-size: 11px; } +.setting-row select { background: #121713; border: 0; color: inherit; text-align: right; } .text-setting { display: grid; gap: 9px; padding: 18px 0; border-bottom: 1px solid var(--line); } .text-setting > span { display: flex; align-items: baseline; justify-content: space-between; } .text-setting strong { font-size: 12px; } .text-setting span small, .text-setting > small { color: #7f8a80; font-size: 11px; } .text-setting input { width: 100%; box-sizing: border-box; background: #0d110e; color: #eef2ed; border: 1px solid #384239; padding: 10px 11px; font: 600 13px/1 Manrope, sans-serif; outline: none; } -.text-setting input:focus { border-color: var(--accent); } -.drawer-note { position: absolute; left: 28px; right: 28px; bottom: 26px; padding: 13px 15px; background: #17231a; color: #8fb193; border-radius: 10px; font-size: 10px; } +.text-setting input:focus { border-color: var(--green); } +.drawer-note { margin-top: 24px; overflow-wrap: anywhere; padding: 13px 15px; background: #17231a; color: #8fb193; border-radius: 10px; font-size: 10px; } +.settings-feedback { font-size: 11px; color: var(--muted); } +.settings-feedback button { padding: 6px 10px; background: var(--panel-2); border: 1px solid var(--line); cursor: pointer; } +.status-error, .build-state .status-error, .text-setting > .status-error { color: #eea18f; overflow-wrap: anywhere; } +.build-state .status-error { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } .login-modal { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 22; width: 360px; background: #121713; border: 1px solid var(--line); border-radius: 14px; padding: 26px; text-align: center; box-shadow: 0 30px 80px rgba(0,0,0,.5); } .login-modal h2 { margin: 0 0 10px; font-family: 'Unbounded'; font-size: 18px; } diff --git a/src/types/launcher.ts b/src/types/launcher.ts new file mode 100644 index 0000000..89c2935 --- /dev/null +++ b/src/types/launcher.ts @@ -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 +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..88d7f19 --- /dev/null +++ b/tsconfig.json @@ -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"] +}