diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1beff6c..10949e2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,6 +2,8 @@ name: Cross-platform build on: workflow_dispatch: + push: + branches: [main] permissions: contents: read @@ -35,7 +37,7 @@ jobs: node-version: 22 cache: npm - uses: dtolnay/rust-toolchain@stable - - name: Install Linux desktop dependencies + - name: Install Linux system dependencies if: runner.os == 'Linux' run: | sudo apt-get update @@ -44,10 +46,28 @@ jobs: - run: npm test - run: cargo test --locked --manifest-path src-tauri/Cargo.toml - run: npm run tauri:build -- ${{ matrix.args }} - - uses: actions/upload-artifact@v4 + - name: Upload Windows installers + if: runner.os == 'Windows' + uses: actions/upload-artifact@v4 with: - name: shacraft-${{ matrix.os }} + name: shacraft-launcher-windows-x64 if-no-files-found: error path: | - src-tauri/target/release/bundle/** - src-tauri/target/*/release/bundle/** + src-tauri/target/release/bundle/nsis/*.exe + src-tauri/target/release/bundle/msi/*.msi + - name: Upload Linux packages + if: runner.os == 'Linux' + uses: actions/upload-artifact@v4 + with: + name: shacraft-launcher-linux-x64 + if-no-files-found: error + path: | + src-tauri/target/release/bundle/appimage/*.AppImage + src-tauri/target/release/bundle/deb/*.deb + - name: Upload macOS package + if: runner.os == 'macOS' + uses: actions/upload-artifact@v4 + with: + name: shacraft-launcher-${{ matrix.name == 'macOS Apple Silicon' && 'macos-arm64' || 'macos-x64' }} + if-no-files-found: error + path: src-tauri/target/*/release/bundle/dmg/*.dmg diff --git a/AGENTS.md b/AGENTS.md index 911c632..68ff036 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,8 +21,11 @@ payload are in `/root/shacraft` on the ShaCraft host; see ## Trust model -- The only supported remote profile endpoint is +- The only supported remote profile manifest endpoint is `https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest`. + The read-only Aeronautics player-count endpoint + `https://shacraft.ru/api/online/aoc` is also hardcoded in `remote.rs`; it + is display-only and is never allowed to influence downloads or launching. - The response is an Ed25519 envelope. `src-tauri/src/remote.rs` verifies its embedded public key and `keyId` **before** parsing the payload. - `src-tauri/src/manifest.rs` then validates paths, SHA-256, sizes, HTTPS and @@ -36,15 +39,13 @@ payload are in `/root/shacraft` on the ShaCraft host; see independent, hardcoded-host trust domains (Mojang, NeoForge, Microsoft, Adoptium) that install and run the actual game. Do not let manifest data control a URL in any of those domains. -- Account modes: the launcher supports launching as either a genuine - Microsoft account that owns Minecraft Java Edition (`src-tauri/src/msa.rs`, - device-code OAuth -> Xbox Live -> XSTS -> Minecraft Services) or as a local - offline profile (nickname + deterministic offline UUID, see - `src-tauri/src/session.rs`). The mode is an explicit player choice - (`account_mode` in settings); offline is never silently substituted for a - Microsoft session. The mc-aoc/mc-create servers' own `ONLINE_MODE=FALSE` + - whitelist + Login System are a separate, independent access-control layer - on the server side. +- ShaCraft accounts: `src-tauri/src/shacraft_account.rs` talks only to the + hardcoded `https://shacraft.ru` origin. Passwords are never persisted. The + revocable session token is stored locally with mode 600 on Unix. At launch, + the nickname is fetched from the verified `aoc` account link; the legacy + nickname in `settings.json` is ignored as an identity source. Server-side + whitelist enforcement and LoginSystem remain the final access-control + boundary, including for old launcher versions. ## Layout @@ -59,7 +60,7 @@ payload are in `/root/shacraft` on the ShaCraft host; see for account/game/host/preferences/profiles. Unsigned sync/inspect IPC was removed; only verified remote manifests may drive profile mutations. - `operations.rs` — process-local install/account permits owned by workers. - Offline launch must not acquire the Microsoft refresh permit. + Launch must use the authenticated ShaCraft nickname; no settings fallback. - `storage.rs` — unique same-directory atomic writes, owner-only Unix files. - `trusted_http.rs` — HTTPS and exact-host redirect policy per game provider. - `download.rs` — shared verified-download helper (temp file, hash, @@ -74,6 +75,8 @@ payload are in `/root/shacraft` on the ShaCraft host; see `MSA_CLIENT_ID`'s doc comment before touching login — it is currently a placeholder pending ShaCraft's own Azure AD app registration and Minecraft-API approval. + - `shacraft_account.rs` — local ShaCraft login/registration, session and + verified nickname-link API. - `launch.rs` — builds and spawns the actual `java` process. - `src-tauri/src/settings.rs` — durable local preferences; maintain backward compatibility with already-written JSON. @@ -82,7 +85,7 @@ payload are in `/root/shacraft` on the ShaCraft host; see - `docs/game-trust-boundary.md` — the Mojang/NeoForge/Microsoft/Adoptium trust domains used to install and run the game itself. - `.github/workflows/check.yml` — push/PR UI checks and Linux Rust tests. -- `.github/workflows/build.yml` — manual cross-platform builds with artifacts; +- `.github/workflows/build.yml` — main-push/manual cross-platform builds with artifacts; not a signed release or updater publication. ## Verification diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ee36cf7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 ShaCraft + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PLAN.md b/PLAN.md index 4540334..27775b2 100644 --- a/PLAN.md +++ b/PLAN.md @@ -11,16 +11,18 @@ ## Следующие задачи -- [ ] Microsoft: собственный public-client ID + Minecraft API approval; - затем живой device-code/login/refresh/logout тест. Сейчас ID — placeholder. +- [x] Сохранены изменения 0.1.1 из GitHub: обязательный ShaCraft-аккаунт, + подтверждённый ник, реальный онлайн и исправления Windows-install pipeline. +- [ ] Microsoft (отдельное будущее решение): собственный client ID + API + approval и живой OAuth-тест. Текущий запуск использует ShaCraft identity. - [ ] Cold install / repair / update / game exit на чистых Windows/Linux/macOS. Unit tests и web preview не заменяют эти прогоны. - [ ] Подписанные installer-релизы и подписанное автообновление лаунчера. - [ ] Реальная отмена загрузок, журнал с редактированием токенов и retry UX. - [ ] Выбор каталога профиля и безопасный reset только managed-файлов. - [ ] Keychain-хранилище refresh token; cross-process exclusion при необходимости. -- [ ] Динамический каталог/онлайн серверов, новости и ссылки сообщества. - Недоступные функции сейчас отключены, данные не имитируются. +- [ ] Динамический каталог и новости; реальный Aeronautics онлайн уже + загружается через фиксированный display-only API. Не имитировать данные. ## Связанные серверные риски diff --git a/README.md b/README.md index 584d6cf..ac41dd6 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,11 @@ React/TypeScript интерфейс, Rust — файлы, сеть и запус модов и конфигурации, Java discovery/provisioning, bootstrap Minecraft и NeoForge, настройки памяти и ника, обработка установки/запуска/выхода. -Режим аккаунта выбирается явно: offline-профиль или Microsoft. Код Microsoft -OAuth/проверки владения готов, но **вход ещё требует собственного client ID и -одобрения Minecraft API**. Offline не подставляется при ошибке Microsoft. +Вход выполняется через аккаунт ShaCraft — тот же, что на сайте. Игровой ник +берётся только из подтверждённой привязки Aeronautics, а не из редактируемых +локальных настроек. Пароли не сохраняются; сессию можно отозвать. +Microsoft OAuth-модуль сохранён отдельно, но не используется текущим +сценарием запуска; для его активации потребуются client ID и API approval. ## Разработка @@ -34,7 +36,7 @@ cargo test --locked --manifest-path src-tauri/Cargo.toml ``` Build включает строгий TypeScript. GitHub Actions проверяет UI и Rust на -push/PR; ручной workflow собирает пакеты Windows x64, Linux x64, macOS Intel +push/PR; workflow на main-push/ручном запуске собирает Windows x64, Linux x64, macOS Intel и Apple Silicon и сохраняет артефакты. Подпись релиза/автообновления ещё впереди. Используемые macOS runners соответствуют [списку GitHub](https://docs.github.com/en/actions/reference/runners/github-hosted-runners). diff --git a/docs/game-trust-boundary.md b/docs/game-trust-boundary.md index e730490..2fe8882 100644 --- a/docs/game-trust-boundary.md +++ b/docs/game-trust-boundary.md @@ -33,10 +33,14 @@ real main class is `net.minecraftforge.installer.SimpleInstaller`, which supports this flag. **Empirically verified (2026-09-06)**: it refuses to target a directory unless a `launcher_profiles.json` stub already exists there ("you need to run the launcher first!") — `ensure_launcher_profiles_stub` -writes a minimal one. It then fetches and patches vanilla itself; no -pre-seeding needed. Its own downloads go straight to `maven.neoforged.net`/ -Mojang, outside our control — an accepted trust delegation to NeoForge's -official tooling once the installer binary itself is verified. +writes a minimal one. It fetches the inputs needed to patch vanilla, but does +not guarantee that the complete vanilla runtime library set is present. +After installation, `mojang::ensure_client_jar` and `ensure_libraries` always +verify and download the complete merged launch set, including LWJGL and its +platform natives. The installer's own downloads go straight to +`maven.neoforged.net`/Mojang, outside our control — an accepted trust +delegation to NeoForge's official tooling once the installer binary itself is +verified. Also verified: the resulting `libraries/net/neoforged/neoforge//neoforge--client.jar` (the @@ -53,10 +57,13 @@ own on disk, confirmed). Hosts: `login.microsoftonline.com`, `user.auth.xboxlive.com`, `xsts.auth.xboxlive.com`, `api.minecraftservices.com`. -Real device-code OAuth login -> Xbox Live user token -> XSTS token -> -Minecraft Services login -> `GET /minecraft/profile` ownership check (404 = -doesn't own the game = nothing installs or launches). This is the actual -ownership gate; it is not optional and there is no fallback identity. See +This module is retained for a future Microsoft mode; the current launcher +uses authenticated ShaCraft account links and deterministic offline identity. +Do not treat this unused module as the active launch gate. + +In a Microsoft flow: device-code OAuth -> Xbox Live user token -> XSTS token -> +Minecraft Services login -> `GET /minecraft/profile` ownership check. An +authentication/ownership failure must never fall back to another identity. See `MSA_CLIENT_ID`'s doc comment in `msa.rs`: unlike the other three domains, this one needs a deployment-specific value — ShaCraft's own Azure AD app registration, approved for Minecraft API access via @@ -66,13 +73,14 @@ refuse to run while it's still the placeholder. ## 4. Eclipse Adoptium (`runtime.rs`) Host: `api.adoptium.net` (redirects to `github.com`/ -`objects.githubusercontent.com` for the actual download — expected, still +`objects.githubusercontent.com`/`release-assets.githubusercontent.com` for the download — expected, still verified). Java 21 JRE, GPLv2+CE. The API returns the release's SHA-256 inline, verified before extraction. Never touches a Java installation the user already has — `java::ensure_java` only provisions here when `java::detect()` finds nothing -with at least the manifest's `javaMajor`. +with exactly the manifest's `javaMajor`; a newer major is not assumed +compatible with the Minecraft/NeoForge version. ## Why this separation matters diff --git a/docs/launcher-architecture.md b/docs/launcher-architecture.md index 3fe51ab..2c56b92 100644 --- a/docs/launcher-architecture.md +++ b/docs/launcher-architecture.md @@ -4,10 +4,14 @@ The launcher persists local settings, synchronises Aeronautics mod/config files from the signed ShaCraft v2 manifest, installs the exact Minecraft + -NeoForge version the manifest specifies, and launches the game. Players can -launch either with a real Microsoft account or with a local offline profile -(nickname + deterministic offline UUID) — see `docs/game-trust-boundary.md` -and `AGENTS.md`'s trust model section. +NeoForge version the manifest specifies, and launches the game. A player +signs in with the same local ShaCraft account used on the website. The game +identity is derived only from that account's verified Aeronautics nickname; +the legacy editable nickname setting is not trusted at launch. + +The interface also shows a live Aeronautics player count from the fixed, +read-only `https://shacraft.ru/api/online/aoc` endpoint. It is display-only: +the result never controls files, versions, URLs, or the launch command. Not yet implemented: a user-selectable profile directory, a "reset managed files only" recovery action, and signed cross-platform release builds of the @@ -28,7 +32,9 @@ Game itself (never controlled by the manifest above) -> Java 21 via Adoptium if none installed (runtime.rs) -> NeoForge's own installer, run headlessly (neoforge.rs) -> generic inheritsFrom merge of the two version JSONs (mojang.rs) - -> explicit Microsoft session (msa.rs) OR offline identity (session.rs) + -> SHA-1-verified merged libraries + platform natives (mojang.rs) + -> verified ShaCraft account link (shacraft_account.rs) + -> deterministic offline UUID for the linked nickname (session.rs) -> java process spawned with the merged classpath/args (launch.rs) ``` @@ -37,8 +43,9 @@ screenshots/resourcepacks) live below Tauri's `app_data_dir()/profiles/ ` — this becomes `--gameDir`. The shared vanilla+NeoForge install (versions/libraries/assets/runtime, reused across profiles that target the same Minecraft version) lives at `app_data_dir()/game`. Settings -live at `app_data_dir()/settings.json`, the Microsoft refresh token at -`app_data_dir()/account.json` (mode 600). None of these should be assumed to +live at `app_data_dir()/settings.json`, and the revocable ShaCraft session at +`app_data_dir()/shacraft-session` (mode 600 on Unix). Passwords are never +written to disk. None of these should be assumed to be the system `.minecraft` directory. ## Aeronautics contract @@ -52,6 +59,22 @@ be the system `.minecraft` directory. no launcher release. - ShaCraft download files: HTTPS only, exact hosts `shacraft.ru` and `cdn.shacraft.ru`. +- Account API origin: fixed `https://shacraft.ru`; redirects are rejected. +- Launch identity: the most recently verified `aoc` nickname returned by the + authenticated account API. Local nickname edits cannot select an identity. + +## Planned but not implemented + +1. User-selectable profile directory and structured launcher logs. +2. "Reset managed files only" recovery action that doesn't touch player + worlds/screenshots/resourcepacks. +3. Signed, cross-platform release builds of the launcher itself. +4. Cancellation, structured logs and a full cold-install/recovery beta on + every target OS. Install progress reports bytes or installer work counts + depending on the stage; these units are not interchangeable. + +Do not represent these as completed features in UI or release notes. + ## Module boundaries (2026-09-09) @@ -62,32 +85,23 @@ even under React StrictMode. A failed repair invalidates profile readiness. Game exit may arrive before launch acknowledgement; the reducer handles both. Browser preview cannot install/launch and does not simulate download progress. -Rust `lib.rs` registers commands from `commands/`. Install/account permits in -`operations.rs` stay owned by blocking workers until completion. These are -process-local guards, not cross-process locks or cancellation support. +Rust `lib.rs` registers commands from `commands/`. Installation and account +permits in `operations.rs` stay owned by blocking workers until completion. +ShaCraft sessions have a separate gate from the retained Microsoft module. +These are process-local guards, not cross-process locks or cancellation. `storage.rs` provides unique temporary files and atomic replacement; Unix -account files are created owner-only rather than chmodded after writing. -`trusted_http.rs` constrains initial provider URLs and every redirect. -Manifest profile identity, size, signature, portable paths and existing -symlinks are checked before managed file writes. Local same-user TOCTOU is -outside this protection; do not describe it as an OS sandbox. +session files are created owner-only. Windows keeps a recoverable replacement +fallback if the OS refuses direct replacement. `trusted_http.rs` constrains provider +URLs and redirects. Manifest profile identity, size, signature, portable +paths and existing symlinks are checked before managed file writes. +Hostile same-user TOCTOU is outside this protection; it is not an OS sandbox. ## Verification and distribution `npm test` covers asynchronous helpers and state transitions; `npm run build` runs strict TypeScript before Vite. `cargo test --locked` -covers native policy and storage. Push/PR CI repeats these checks on Linux. -Manual `build.yml` builds Windows x64, Linux x64 and both macOS architectures -and uploads bundles. Packages are not yet signed release artifacts. - -## Planned but not implemented - -1. User-selectable profile directory and structured launcher logs. -2. "Reset managed files only" recovery action that doesn't touch player - worlds/screenshots/resourcepacks. -3. Signed, cross-platform release builds of the launcher itself. -4. Cancellation, structured logs and full cold-install/recovery beta on - every target OS. Current install progress reports actual stage work; - bytes and installer completion counts are not interchangeable units. - -Do not represent these as completed features in UI or release notes. +covers native policy and storage. Push/PR CI repeats checks on Linux. +The package workflow runs on main pushes or manually and builds Windows +x64, Linux x64 and both macOS architectures with named artifacts. +Packages are not yet signed release artifacts. Native cold-install and +launch tests are required before calling a platform release-ready. diff --git a/docs/refactor-2026-09-09.md b/docs/refactor-2026-09-09.md new file mode 100644 index 0000000..4ed26c9 --- /dev/null +++ b/docs/refactor-2026-09-09.md @@ -0,0 +1,86 @@ +# Глобальный рефакторинг — 2026-09-09 + +## Границы работы + +Переработаны backend/шаблоны сайта и React/Rust-слои нового лаунчера. +Не менялись миры, модпак, порты, compose-топология, цены или режимы аккаунтов. +Не переносились production-БД, секреты и приватный ключ подписи. + +## Backend + +- Монолит main.py разделён на HTTP routers, services, schemas, middleware + и единый резолвер ресурсов. Entrypoint app.main:app сохранён. +- Cookie/bearer вход используют одну реализацию аккаунтов, паролей и сессий. +- Ограничитель попыток входа защищён от гонок и бесконечного роста памяти. + Он process-local; multi-worker развертывание требует общего хранилища. +- Оплата и подписка фиксируются одной SQLite-транзакцией. Claim/reject, + продление, recovery и привязки сериализуют конфликтующие операции. +- JSON metadata проверяются централизованно: неверный UTF-8, не-object JSON + и небезопасные идентификаторы не приводят к непредусмотренному чтению пути. +- Jinja наследование убирает копии фона/шапки/навигации. Размер фона 72×83 + задаётся единожды. HTML-формы, скрипты и визуальная структура сохранены. +- Python зависимости зафиксированы на версиях действовавшего runtime. + Добавлены pytest, Ruff, CI и изолированный test-stage Docker. + +## Лаунчер + +- React разделён на компоненты, hooks, типизированный IPC и reducers. + TypeScript проверяется сборкой; ошибки IPC не теряются. +- Сохранены новые изменения GitHub 0.1.1: обязательный ShaCraft-аккаунт, + verified aoc nickname, реальный онлайн, управление окном и Windows-фиксы + установки/полных библиотек/точной Java major/длинной JVM-команды. +- Сохранения настроек упорядочены; lifecycle install/launch/exit явный; + демонстрационные значения прогресса не выдаются за реальную установку. +- Rust commands выделены по ответственности. Неиспользуемые команды + unsigned sync/inspect удалены; запись профиля требует verified manifest. +- Общие атомарные записи и process-local permits защищают файлы от + конфликтующих операций. Проверяются переносимые пути, symlink и подпись. +- HTTPS exact-host policy распространяется и на redirects отдельных + игровых провайдеров. Метаданные архивов Adoptium проверяются до загрузки. +- GitHub: тесты/сборка на push/PR; ручная матрица пакетов Linux/Windows/macOS + Intel/ARM с сохранением артефактов. Это не подпись релизов и не автообновление. + +## Проверки + +Фактический итог: 88 backend-тестов + 23 subtests (локально и в изолированном +Docker), 22 UI-теста, 59 Rust unit tests и отдельная живая read-only проверка +production signed-manifest — успешно. npm ci/audit: 0 известных уязвимостей +на момент прогона; строгий TypeScript/Vite и Ruff проходят. Это итог после +объединения со всеми изменениями GitHub 0.1.1; прежние upstream Rust-тесты +сохранены. Browser smoke: вход/регистрация ShaCraft, preview-gating, +настройки, закрытие Escape и восстановление фокуса проверены без отправки +учётных данных. Четыре тяжёлых/live игровых сценария не запускались. + +Backend выложен, image ID контейнера совпадает с собранным образом; +главная/Help/Моды/кабинет/healthz/catalog/signed-manifest отвечают 200, +публичная админка по-прежнему закрыта Caddy (403). Внешний вид проверен +в браузере. Время запуска mc-aoc не изменилось. Резервный образ сохранён +как `shacraft-backend:before-refactor-20260909`, исходники — +`/root/shacraft-rollback-NWKzLR`. Схема БД не менялась. + +Backend: неизменность полного OpenAPI-снимка, публичные шаблоны, аккаунты, +пароли/recovery, rate limit, LoginSystem gating, admin auth, платежи, +rollback/параллельные операции, каталоги/manifest/config, startup/shutdown. +Внешние эффекты замещены заглушками, БД только временная. + +Launcher: строгий TypeScript + Vite, тесты UI state/settings/listeners, +Rust unit tests по подписи/путям/хранилищу/IPC guards/trusted hosts. +Полная холодная установка игры, OAuth и запуск на Windows/macOS требуют +отдельного ручного beta-прогона; не выдавать локальные проверки за такой тест. + +## Не замаскированные рефактором ограничения + +1. Привязка ника: NoGravity подтверждает авторизацию игрока на сервере, + но не связь с конкретным веб-запросом. Нужен одноразовый challenge в игре. +2. Реферальная акция: enforcement REFERRAL_ENABLED и новизны самого + плательщика не соответствует всей публичной формулировке. Требуется + отдельное согласованное изменение бизнес-правил и регрессионные тесты. +3. RCON/уведомления после commit не имеют outbox/retry. Сбой может потребовать + ручной выдачи, а не повторного начисления подписки. +4. Microsoft OAuth не является текущим способом входа: используется + ShaCraft account + verified nickname. Неактивный OAuth-модуль требует + собственного client ID и API approval при отдельной будущей активации. +5. Нужны подписанные installer/update-релизы, native beta на всех ОС, + полноценная отмена загрузок и recovery пользовательских данных. +6. Блокировки лаунчера process-local; symlink-проверки не защищают от + злонамеренного same-user TOCTOU. Keychain хранения refresh token пока нет. diff --git a/package-lock.json b/package-lock.json index e05c8e0..79b527c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "shacraft-launcher-ui", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "shacraft-launcher-ui", - "version": "0.1.0", + "version": "0.1.1", "dependencies": { "@tauri-apps/api": "2.11.1", "lucide-react": "1.41.0", diff --git a/package.json b/package.json index b0f7fd2..d08e790 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,14 @@ { "name": "shacraft-launcher-ui", + "license": "MIT", "private": true, - "version": "0.1.0", + "version": "0.1.1", "type": "module", "scripts": { "dev": "vite", "build": "npm run typecheck && vite build", "typecheck": "tsc --noEmit", - "test": "tsx --test src/services/async.test.ts src/state/game.test.ts src/state/profiles.test.ts", + "test": "tsx --test src/services/async.test.ts src/state/game.test.ts src/state/profiles.test.ts src/state/account.test.ts src/components/account.test.tsx", "preview": "vite preview", "tauri": "tauri", "tauri:dev": "tauri dev", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e4593aa..2570698 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3216,7 +3216,7 @@ dependencies = [ [[package]] name = "shacraft-launcher" -version = "0.1.0" +version = "0.1.1" dependencies = [ "base64 0.22.1", "ed25519-dalek", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5320467..64a2ba6 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,8 +1,9 @@ [package] name = "shacraft-launcher" -version = "0.1.0" +version = "0.1.1" description = "ShaCraft Minecraft launcher" authors = ["ShaCraft"] +license = "MIT" edition = "2021" [lib] diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 0e15dea..7a2e208 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -3,5 +3,11 @@ "identifier": "main-window", "description": "Minimal permissions for the ShaCraft main window.", "windows": ["main"], - "permissions": ["core:default"] + "permissions": [ + "core:default", + "core:window:allow-close", + "core:window:allow-minimize", + "core:window:allow-toggle-maximize", + "core:window:allow-start-dragging" + ] } diff --git a/src-tauri/src/commands/account.rs b/src-tauri/src/commands/account.rs index 8fadbed..df58ca2 100644 --- a/src-tauri/src/commands/account.rs +++ b/src-tauri/src/commands/account.rs @@ -1,11 +1,6 @@ use super::data_dir; -use crate::{ - msa, - operations::{LauncherOperations, Operation}, - session, settings, -}; +use crate::{msa, operations::LauncherOperations}; use serde::Serialize; -use std::path::Path; use tauri::{AppHandle, Emitter, State}; #[derive(Clone, Serialize)] @@ -116,53 +111,3 @@ pub(crate) async fn logout( .map_err(|error| format!("Logout task failed: {error}"))? .map_err(|error| error.to_string()) } - -/// Resolves the identity to launch as, based on the persisted `account_mode`. -/// In `Microsoft` mode this requires a real signed-in session (see -/// `msa::login_with_refresh_token`) and returns an error if there is none; -/// in `Offline` mode it uses the local nickname from settings, so no -/// Microsoft account is needed at all. Offline is never silently used in -/// place of a missing Microsoft session. -pub(super) fn resolve_identity( - data_dir: &Path, - settings: &settings::LauncherSettings, - account_operation: &Operation, -) -> Result { - 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 index d770dfa..267c25d 100644 --- a/src-tauri/src/commands/game.rs +++ b/src-tauri/src/commands/game.rs @@ -1,4 +1,4 @@ -use super::{account::resolve_identity, data_dir}; +use super::{data_dir, shacraft::resolve_identity}; use crate::{ java, launch, manifest, mojang, neoforge, operations::LauncherOperations, remote, runtime, settings, @@ -114,24 +114,23 @@ pub(crate) async fn ensure_game_installed( &cache_dir, &stage_progress("neoforge"), )?; - if manifest.minecraft.loader.kind != "neoforge" { - // Vanilla-only profiles skip the installer, which normally - // downloads vanilla itself; do it ourselves here instead. - mojang::ensure_client_jar( - &client, - &game_dir, - &merged.client_jar_version_id, - &merged.client, - ) - .map_err(|error| error.to_string())?; - mojang::ensure_libraries( - &client, - &game_dir, - &merged.libraries, - &stage_progress("libraries"), - ) - .map_err(|error| error.to_string())?; - }; + // NeoForge may leave vanilla runtime libraries (including LWJGL) absent. + // Verify the full merged set, using the loader Maven only for libraries. + let library_client = mojang::library_http_client().map_err(|error| error.to_string())?; + mojang::ensure_client_jar( + &client, + &game_dir, + &merged.client_jar_version_id, + &merged.client, + ) + .map_err(|error| error.to_string())?; + mojang::ensure_libraries( + &library_client, + &game_dir, + &merged.libraries, + &stage_progress("libraries"), + ) + .map_err(|error| error.to_string())?; let asset_index = mojang::ensure_asset_index(&client, &game_dir, &merged.asset_index) .map_err(|error| error.to_string())?; @@ -151,10 +150,9 @@ pub(crate) struct GameExited { exit_code: Option, } -/// Launches `profile_id` as the account chosen in settings (`account_mode`). -/// In `Microsoft` mode a real session is required (see `resolve_identity`); -/// in `Offline` mode the local nickname from settings is used, so no -/// Microsoft account is needed. Spawns the game detached; watches it on a +/// Launches `profile_id` with the verified ShaCraft account's linked nickname. +/// Local legacy nickname/account-mode preferences cannot override the link. +/// Spawns the game detached; watches it on a /// background thread only to emit `game-exited` when it eventually closes. #[tauri::command] pub(crate) async fn launch_game( @@ -165,7 +163,7 @@ pub(crate) async fn launch_game( let game_dir = data_dir(&app)?.join("game"); let data_dir = data_dir(&app)?; let permit = state.installation.acquire("Installation")?; - let account_operation = state.account.clone(); + let account_operation = state.shacraft_account.clone(); tauri::async_runtime::spawn_blocking(move || -> Result<(), String> { let _permit = permit; @@ -174,7 +172,7 @@ pub(crate) async fn launch_game( let manifest = remote::fetch_manifest(&profile_id).map_err(|error| error.to_string())?; let profile_dir = data_dir.join("profiles").join(&manifest.id); let settings = settings::load(&data_dir).map_err(|error| error.to_string())?; - let identity = resolve_identity(&data_dir, &settings, &account_operation)?; + let identity = resolve_identity(&data_dir, &account_operation)?; // Everything here should already be installed by `ensure_game_installed`, // so these are expected to hit their fast paths; no progress to show. diff --git a/src-tauri/src/commands/host.rs b/src-tauri/src/commands/host.rs index a877e97..827524a 100644 --- a/src-tauri/src/commands/host.rs +++ b/src-tauri/src/commands/host.rs @@ -1,5 +1,5 @@ use super::data_dir; -use crate::{java, manifest}; +use crate::{java, manifest, msa}; use serde::Serialize; use tauri::AppHandle; @@ -29,6 +29,11 @@ pub(crate) fn detect_java() -> Option { java::detect() } +#[tauri::command] +pub(crate) fn microsoft_login_available() -> bool { + msa::is_configured() +} + /// Validates an untrusted profile manifest before any file is downloaded. #[tauri::command] pub(crate) fn validate_manifest(manifest_json: String) -> Result<(), String> { diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 3731958..6f82e5a 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -4,6 +4,7 @@ pub(crate) mod game; pub(crate) mod host; pub(crate) mod preferences; pub(crate) mod profiles; +pub(crate) mod shacraft; use std::path::PathBuf; use tauri::{AppHandle, Manager}; diff --git a/src-tauri/src/commands/profiles.rs b/src-tauri/src/commands/profiles.rs index 83d64b1..67c0d85 100644 --- a/src-tauri/src/commands/profiles.rs +++ b/src-tauri/src/commands/profiles.rs @@ -2,6 +2,15 @@ use super::data_dir; use crate::{operations::LauncherOperations, profile, remote}; use tauri::{AppHandle, State}; +#[tauri::command] +pub(crate) async fn get_server_status(profile_id: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + remote::fetch_server_status(&profile_id).map_err(|error| error.to_string()) + }) + .await + .map_err(|error| format!("Server-status task failed: {error}"))? +} + /// Loads and validates the published ShaCraft manifest before inspecting a profile. #[tauri::command] pub(crate) async fn inspect_remote_profile( diff --git a/src-tauri/src/commands/shacraft.rs b/src-tauri/src/commands/shacraft.rs new file mode 100644 index 0000000..fea726d --- /dev/null +++ b/src-tauri/src/commands/shacraft.rs @@ -0,0 +1,120 @@ +//! ShaCraft sessions and verified account links are the launch identity source. +use super::data_dir; +use crate::{ + operations::{LauncherOperations, Operation}, + session, shacraft_account, +}; +use std::path::Path; +use tauri::{AppHandle, State}; + +async fn account_task( + app: &AppHandle, + operations: &LauncherOperations, + work: impl FnOnce(&Path) -> Result + Send + 'static, +) -> Result { + let directory = data_dir(app)?; + let permit = operations + .shacraft_account + .acquire("ShaCraft account operation")?; + tauri::async_runtime::spawn_blocking(move || { + let _permit = permit; + work(&directory).map_err(|error| error.to_string()) + }) + .await + .map_err(|error| format!("ShaCraft account task failed: {error}"))? +} + +#[tauri::command] +pub(crate) async fn shacraft_authenticate( + app: AppHandle, + state: State<'_, LauncherOperations>, + username: String, + password: String, + register: bool, +) -> Result { + account_task(&app, &state, move |directory| { + shacraft_account::authenticate(directory, &username, &password, register) + }) + .await +} + +#[tauri::command] +pub(crate) async fn get_shacraft_account( + app: AppHandle, + state: State<'_, LauncherOperations>, +) -> Result, String> { + account_task( + &app, + &state, + |directory| match shacraft_account::get_account(directory) { + Ok(account) => Ok(Some(account)), + Err(shacraft_account::AccountError::InvalidSession) => Ok(None), + Err(error) => Err(error), + }, + ) + .await +} + +#[tauri::command] +pub(crate) async fn shacraft_logout( + app: AppHandle, + state: State<'_, LauncherOperations>, +) -> Result<(), String> { + account_task(&app, &state, shacraft_account::logout).await +} + +#[tauri::command] +pub(crate) async fn shacraft_start_link( + app: AppHandle, + state: State<'_, LauncherOperations>, + nickname: String, +) -> Result { + account_task(&app, &state, move |directory| { + shacraft_account::start_link(directory, "aoc", &nickname) + }) + .await +} + +#[tauri::command] +pub(crate) async fn shacraft_link_status( + app: AppHandle, + state: State<'_, LauncherOperations>, + challenge_id: i64, +) -> Result { + account_task(&app, &state, move |directory| { + shacraft_account::link_status(directory, challenge_id) + }) + .await +} + +/// Always revalidate the server session and its aoc link. Legacy local settings +/// and Microsoft tokens do not select the identity in the ShaCraft-only flow. +pub(super) fn resolve_identity( + directory: &Path, + operation: &Operation, +) -> Result { + let _permit = operation.acquire("ShaCraft account operation")?; + let name = + shacraft_account::aeronautics_nickname(directory).map_err(|error| error.to_string())?; + Ok(session::PlayerIdentity::Offline { name }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_shacraft_session_cannot_fall_back_to_legacy_nickname() { + let directory = std::env::temp_dir().join(format!( + "shacraft-identity-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + crate::settings::save(&directory, crate::settings::LauncherSettings::default()).unwrap(); + assert!(resolve_identity(&directory, &Operation::default()).is_err()); + std::fs::remove_dir_all(directory).unwrap(); + } +} diff --git a/src-tauri/src/java.rs b/src-tauri/src/java.rs index a8e68ce..e96e086 100644 --- a/src-tauri/src/java.rs +++ b/src-tauri/src/java.rs @@ -2,7 +2,11 @@ use crate::download::ProgressCallback; use crate::runtime::{self, RuntimeError}; use reqwest::blocking::Client; use serde::Serialize; -use std::{env, fmt, path::{Path, PathBuf}, process::Command}; +use std::{ + env, fmt, + path::{Path, PathBuf}, + process::Command, +}; #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] @@ -21,9 +25,14 @@ pub enum EnsureJavaError { impl fmt::Display for EnsureJavaError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Provisioning(error) => write!(formatter, "Cannot install a Java runtime: {error}"), + Self::Provisioning(error) => { + write!(formatter, "Cannot install a Java runtime: {error}") + } Self::ProvisionedButUnrecognised(path) => { - write!(formatter, "Installed a Java runtime at {path:?}, but it did not report a usable version") + write!( + formatter, + "Installed a Java runtime at {path:?}, but it did not report a usable version" + ) } } } @@ -38,21 +47,29 @@ pub fn detect() -> Option { candidates().into_iter().find_map(check_candidate) } -/// Returns a Java runtime with at least `required_major`, preferring -/// whatever the user already has installed. Only downloads and extracts a +/// Returns a Java runtime with exactly `required_major`, preferring a matching +/// installation already on the machine. Newer JVM majors are not assumed to +/// be compatible with the selected NeoForge/modpack version. Only downloads and extracts a /// ShaCraft-managed Eclipse Temurin JRE under `runtime_root` (never touches /// the user's own Java) when nothing suitable is already on the machine. /// `on_progress` reports real download bytes when a JRE actually needs /// fetching; it fires once with `(1, 1)` when an existing Java is reused. -pub fn ensure_java(client: &Client, runtime_root: &Path, required_major: u8, on_progress: &ProgressCallback) -> Result { +pub fn ensure_java( + client: &Client, + runtime_root: &Path, + required_major: u8, + on_progress: &ProgressCallback, +) -> Result { if let Some(installation) = detect() { - if installation.major >= required_major { + if installation.major == required_major { on_progress(1, 1); return Ok(installation); } } - let executable = runtime::ensure_runtime(client, runtime_root, required_major, on_progress).map_err(EnsureJavaError::Provisioning)?; - check_candidate(executable.clone()).ok_or(EnsureJavaError::ProvisionedButUnrecognised(executable)) + let executable = runtime::ensure_runtime(client, runtime_root, required_major, on_progress) + .map_err(EnsureJavaError::Provisioning)?; + check_candidate(executable.clone()) + .ok_or(EnsureJavaError::ProvisionedButUnrecognised(executable)) } fn candidates() -> Vec { diff --git a/src-tauri/src/launch.rs b/src-tauri/src/launch.rs index db88ee5..71ae513 100644 --- a/src-tauri/src/launch.rs +++ b/src-tauri/src/launch.rs @@ -8,7 +8,7 @@ use crate::mojang::{self, MergedVersion}; use crate::session::PlayerIdentity; use sha2::{Digest, Sha256}; use std::{ - collections::HashMap, + collections::{HashMap, HashSet}, fmt, fs, io, path::{Path, PathBuf}, process::{Child, Command, Stdio}, @@ -57,17 +57,37 @@ fn classpath_separator() -> &'static str { } } +fn unique_classpath_entries(mut entries: Vec) -> Vec { + let mut seen = HashSet::new(); + entries.retain(|path| seen.insert(path.clone())); + entries +} + fn build_classpath(game_dir: &Path, merged: &MergedVersion, client_jar: &Path) -> String { let no_features = HashMap::new(); let mut entries: Vec = merged .libraries .iter() .filter(|library| mojang::rule_allows(&library.rules, &no_features)) - .filter_map(|library| library.downloads.as_ref().and_then(|downloads| downloads.artifact.as_ref())) + .filter_map(|library| { + library + .downloads + .as_ref() + .and_then(|downloads| downloads.artifact.as_ref()) + }) .map(|artifact| game_dir.join("libraries").join(&artifact.path)) .collect(); entries.push(client_jar.to_path_buf()); - entries.iter().map(|path| path.display().to_string()).collect::>().join(classpath_separator()) + // NeoForge's inherited profile can repeat vanilla libraries verbatim. + // Passing the same jar twice makes SecureJarHandler abort during startup + // (for example on gson-2.10.1.jar), so preserve order and keep each path + // only once. + let entries = unique_classpath_entries(entries); + entries + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(classpath_separator()) } /// A persistent-but-not-security-sensitive per-install identifier for the @@ -93,7 +113,13 @@ static UUID_COUNTER: AtomicU64 = AtomicU64::new(0); fn random_uuid_v4() -> String { let mut hasher = Sha256::new(); - hasher.update(SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos().to_le_bytes()); + hasher.update( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + .to_le_bytes(), + ); hasher.update(std::process::id().to_le_bytes()); hasher.update(UUID_COUNTER.fetch_add(1, Ordering::Relaxed).to_le_bytes()); let stack_marker = 0_u8; @@ -103,7 +129,10 @@ fn random_uuid_v4() -> String { bytes.copy_from_slice(&digest[0..16]); bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant - let hex = bytes.iter().map(|byte| format!("{byte:02x}")).collect::(); + let hex = bytes + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); crate::session::format_uuid_with_dashes(&hex) } @@ -118,6 +147,36 @@ fn substitute(template: &str, vars: &HashMap<&str, String>) -> String { result } +/// Java's argument-file syntax is independent of the platform shell. Keeping +/// the large JVM/module/classpath portion in an argfile avoids Windows' +/// 32,767 UTF-16 command-line limit while leaving account tokens out of it. +fn quote_argfile_argument(argument: &str) -> String { + let mut quoted = String::with_capacity(argument.len() + 2); + quoted.push('"'); + for character in argument.chars() { + match character { + '\\' => quoted.push_str("\\\\"), + '"' => quoted.push_str("\\\""), + '\n' => quoted.push_str("\\n"), + '\r' => quoted.push_str("\\r"), + '\t' => quoted.push_str("\\t"), + other => quoted.push(other), + } + } + quoted.push('"'); + quoted +} + +fn write_jvm_argfile(path: &Path, arguments: &[String]) -> io::Result<()> { + let mut contents = arguments + .iter() + .map(|argument| quote_argfile_argument(argument)) + .collect::>() + .join("\n"); + contents.push('\n'); + fs::write(path, contents) +} + /// Builds the full `java` command line for `request.merged` and spawns it /// detached, with stdout/stderr both redirected to `request.log_path`. /// Never blocks on the child exiting — the caller decides how to observe @@ -129,17 +188,26 @@ pub fn launch(request: &LaunchRequest) -> Result { fs::create_dir_all(&natives_dir)?; let assets_root = request.game_dir.join("assets"); let libraries_dir = request.game_dir.join("libraries"); - let client_jar = mojang::client_jar_path(request.game_dir, &request.merged.client_jar_version_id); + let client_jar = + mojang::client_jar_path(request.game_dir, &request.merged.client_jar_version_id); let classpath = build_classpath(request.game_dir, request.merged, &client_jar); let mut vars: HashMap<&str, String> = HashMap::new(); vars.insert("auth_player_name", request.identity.name().to_string()); - vars.insert("version_name", request.merged.id.clone()); + // NeoForge's inherited JVM profile uses `${version_name}.jar` in + // `-DignoreList`. The actual client jar belongs to the vanilla parent + // (`1.21.1.jar`), not to the child profile (`neoforge-...`), so this + // token must identify the parent or both vanilla and patched Minecraft + // modules are loaded and Java aborts with a ResolutionException. + vars.insert("version_name", request.merged.client_jar_version_id.clone()); vars.insert("game_directory", request.profile_dir.display().to_string()); vars.insert("assets_root", assets_root.display().to_string()); vars.insert("assets_index_name", request.merged.asset_index.id.clone()); vars.insert("auth_uuid", request.identity.uuid()); - vars.insert("auth_access_token", request.identity.access_token().to_string()); + vars.insert( + "auth_access_token", + request.identity.access_token().to_string(), + ); vars.insert("clientid", launcher_client_id(request.game_dir)?); vars.insert("auth_xuid", request.identity.xuid().to_string()); vars.insert("user_type", request.identity.user_type().to_string()); @@ -152,13 +220,24 @@ pub fn launch(request: &LaunchRequest) -> Result { vars.insert("classpath_separator", classpath_separator().to_string()); let no_features = HashMap::new(); - let jvm_args = mojang::resolve_arguments(&request.merged.jvm_arguments, &no_features); + let jvm_args = mojang::resolve_arguments(&request.merged.jvm_arguments, &no_features) + .into_iter() + .map(|argument| substitute(&argument, &vars)) + .collect::>(); let game_args = mojang::resolve_arguments(&request.merged.game_arguments, &no_features); let mut command = Command::new(request.java_executable); - command.arg(format!("-Xmx{}M", request.memory_mb)); - for argument in jvm_args { - command.arg(substitute(&argument, &vars)); + let memory_argument = format!("-Xmx{}M", request.memory_mb); + if cfg!(windows) { + let argfile = request.profile_dir.join(".shacraft-jvm.args"); + let mut argfile_arguments = Vec::with_capacity(jvm_args.len() + 1); + argfile_arguments.push(memory_argument); + argfile_arguments.extend(jvm_args); + write_jvm_argfile(&argfile, &argfile_arguments)?; + command.arg(format!("@{}", argfile.display())); + } else { + command.arg(memory_argument); + command.args(jvm_args); } command.arg(&request.merged.main_class); for argument in game_args { @@ -183,12 +262,18 @@ mod tests { let parts: Vec<&str> = id.split('-').collect(); assert_eq!(parts.len(), 5); assert_eq!(parts[2].chars().next().unwrap(), '4'); - assert!(matches!(parts[3].chars().next().unwrap(), '8' | '9' | 'a' | 'b')); + assert!(matches!( + parts[3].chars().next().unwrap(), + '8' | '9' | 'a' | 'b' + )); } #[test] fn client_id_is_persisted_across_calls() { - let dir = std::env::temp_dir().join(format!("shacraft-launch-clientid-test-{}", std::process::id())); + let dir = std::env::temp_dir().join(format!( + "shacraft-launch-clientid-test-{}", + std::process::id() + )); let first = launcher_client_id(&dir).unwrap(); let second = launcher_client_id(&dir).unwrap(); assert_eq!(first, second); @@ -201,6 +286,34 @@ mod tests { vars.insert("auth_player_name", "Steve".to_string()); assert_eq!(substitute("--username", &vars), "--username"); assert_eq!(substitute("${auth_player_name}", &vars), "Steve"); - assert_eq!(substitute("-Djava.library.path=${natives_directory}", &vars), "-Djava.library.path=${natives_directory}"); + assert_eq!( + substitute("-Djava.library.path=${natives_directory}", &vars), + "-Djava.library.path=${natives_directory}" + ); + } + + #[test] + fn classpath_entries_are_unique() { + let entries = unique_classpath_entries(vec![ + PathBuf::from("gson.jar"), + PathBuf::from("gson.jar"), + PathBuf::from("client.jar"), + ]); + assert_eq!( + entries, + vec![PathBuf::from("gson.jar"), PathBuf::from("client.jar")] + ); + } + + #[test] + fn quotes_java_argfile_arguments() { + assert_eq!( + quote_argfile_argument(r#"-Dpath=C:\\Users\\Jane Doe\\game"#), + r#""-Dpath=C:\\\\Users\\\\Jane Doe\\\\game""# + ); + assert_eq!( + quote_argfile_argument(r#"-Dname="ShaCraft""#), + r#""-Dname=\"ShaCraft\"""# + ); } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5618b27..325ad9f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,6 +10,7 @@ mod remote; mod runtime; mod session; mod settings; +mod shacraft_account; mod storage; mod trusted_http; @@ -22,11 +23,18 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ commands::host::native_host, commands::host::detect_java, + commands::host::microsoft_login_available, commands::host::validate_manifest, commands::profiles::inspect_remote_profile, commands::profiles::sync_remote_profile, + commands::profiles::get_server_status, commands::preferences::load_settings, commands::preferences::save_settings, + commands::shacraft::shacraft_authenticate, + commands::shacraft::get_shacraft_account, + commands::shacraft::shacraft_logout, + commands::shacraft::shacraft_start_link, + commands::shacraft::shacraft_link_status, commands::account::start_microsoft_login, commands::account::get_account, commands::account::logout, diff --git a/src-tauri/src/mojang.rs b/src-tauri/src/mojang.rs index 26ac2a0..e812858 100644 --- a/src-tauri/src/mojang.rs +++ b/src-tauri/src/mojang.rs @@ -26,7 +26,8 @@ use std::{ }, }; -const VERSION_MANIFEST_URL: &str = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"; +const VERSION_MANIFEST_URL: &str = + "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"; const MOJANG_HOSTS: [&str; 4] = [ "piston-meta.mojang.com", "piston-data.mojang.com", @@ -47,6 +48,28 @@ pub fn http_client() -> Result { crate::trusted_http::client(&MOJANG_HOSTS, std::time::Duration::from_secs(10 * 60)) } +/// The verified merged profile can contain both Mojang and NeoForge artifacts. +/// This broader client is used only for that library list, never metadata. +pub fn library_http_client() -> Result { + crate::trusted_http::client( + &[ + MOJANG_HOSTS[0], + MOJANG_HOSTS[1], + MOJANG_HOSTS[2], + MOJANG_HOSTS[3], + crate::neoforge::NEOFORGE_HOST, + ], + std::time::Duration::from_secs(10 * 60), + ) +} + +/// Library entries in a merged loader profile may point at the loader's +/// own fixed Maven. The profile itself comes from the SHA-256-verified +/// NeoForge installer, never from the ShaCraft manifest. +fn is_allowed_library_host(url: &str) -> bool { + is_allowed_host(url) || crate::neoforge::is_allowed_host(url) +} + #[derive(Debug)] pub enum MojangError { Network(reqwest::Error), @@ -55,6 +78,7 @@ pub enum MojangError { ChecksumMismatch(String), DisallowedHost(String), MissingField(String), + ConflictingLibrary(String), Download(DownloadError), Io(io::Error), } @@ -66,8 +90,14 @@ impl fmt::Display for MojangError { Self::HttpStatus(status) => write!(formatter, "Mojang returned {status}"), Self::InvalidJson(error) => write!(formatter, "invalid Mojang JSON: {error}"), Self::ChecksumMismatch(context) => write!(formatter, "checksum mismatch for {context}"), - Self::DisallowedHost(url) => write!(formatter, "URL is not a recognised Mojang host: {url}"), + Self::DisallowedHost(url) => { + write!(formatter, "URL is not a recognised Mojang host: {url}") + } Self::MissingField(field) => write!(formatter, "version JSON is missing {field}"), + Self::ConflictingLibrary(path) => write!( + formatter, + "merged version contains conflicting library entries for {path}" + ), Self::Download(error) => write!(formatter, "{error}"), Self::Io(error) => write!(formatter, "I/O error: {error}"), } @@ -106,7 +136,10 @@ pub fn fetch_version_manifest(client: &Client) -> Result(manifest: &'a VersionManifest, id: &str) -> Option<&'a VersionManifestEntry> { +pub fn find_version<'a>( + manifest: &'a VersionManifest, + id: &str, +) -> Option<&'a VersionManifestEntry> { manifest.versions.iter().find(|entry| entry.id == id) } @@ -141,7 +174,10 @@ pub struct Arguments { #[serde(untagged)] pub enum ArgumentValue { Plain(String), - Conditional { rules: Vec, value: StringOrList }, + Conditional { + rules: Vec, + value: StringOrList, + }, } #[derive(Debug, Deserialize, Clone)] @@ -227,7 +263,11 @@ fn current_os_name() -> &'static str { } fn arch_matches(expected: &str) -> bool { - let normalized = if expected == "arm64" { "aarch64" } else { expected }; + let normalized = if expected == "arm64" { + "aarch64" + } else { + expected + }; normalized == std::env::consts::ARCH } @@ -246,7 +286,9 @@ fn os_matches(os: &RuleOs) -> bool { } fn features_match(required: &HashMap, active: &HashMap) -> bool { - required.iter().all(|(key, value)| active.get(key).copied().unwrap_or(false) == *value) + required + .iter() + .all(|(key, value)| active.get(key).copied().unwrap_or(false) == *value) } /// Evaluates a Mojang-style rule list: no rules means always allowed; @@ -261,7 +303,10 @@ pub fn rule_allows(rules: &[Rule], active_features: &HashMap) -> b let mut allowed = false; for rule in rules { let os_ok = rule.os.as_ref().is_none_or(os_matches); - let features_ok = rule.features.as_ref().is_none_or(|required| features_match(required, active_features)); + let features_ok = rule + .features + .as_ref() + .is_none_or(|required| features_match(required, active_features)); if os_ok && features_ok { allowed = rule.action == RuleAction::Allow; } @@ -271,7 +316,10 @@ pub fn rule_allows(rules: &[Rule], active_features: &HashMap) -> b /// Flattens an argument list into plain strings, dropping conditional /// entries whose rules don't match this platform/feature set. -pub fn resolve_arguments(arguments: &[ArgumentValue], active_features: &HashMap) -> Vec { +pub fn resolve_arguments( + arguments: &[ArgumentValue], + active_features: &HashMap, +) -> Vec { let mut resolved = Vec::new(); for argument in arguments { match argument { @@ -289,11 +337,18 @@ pub fn resolve_arguments(arguments: &[ArgumentValue], active_features: &HashMap< resolved } -pub fn fetch_version_json(client: &Client, entry: &VersionManifestEntry) -> Result { +pub fn fetch_version_json( + client: &Client, + entry: &VersionManifestEntry, +) -> Result { fetch_json(client, &entry.url, Some(&entry.sha1)) } -fn fetch_json(client: &Client, url: &str, expected_sha1: Option<&str>) -> Result { +fn fetch_json( + client: &Client, + url: &str, + expected_sha1: Option<&str>, +) -> Result { if !is_allowed_host(url) { return Err(MojangError::DisallowedHost(url.to_string())); } @@ -344,8 +399,14 @@ pub struct MergedVersion { /// the parent's, and its libraries are appended after the parent's. /// `assetIndex`/`downloads.client` always come from the parent, since /// modloader profiles don't redeclare them. -pub fn merge_versions(parent: &VersionJson, child: Option<&VersionJson>) -> Result { - let asset_index = parent.asset_index.clone().ok_or_else(|| MojangError::MissingField("assetIndex".into()))?; +pub fn merge_versions( + parent: &VersionJson, + child: Option<&VersionJson>, +) -> Result { + let asset_index = parent + .asset_index + .clone() + .ok_or_else(|| MojangError::MissingField("assetIndex".into()))?; let client = parent .downloads .as_ref() @@ -397,34 +458,69 @@ pub fn natives_directory(game_dir: &Path, version_id: &str) -> PathBuf { } pub fn client_jar_path(game_dir: &Path, version_id: &str) -> PathBuf { - game_dir.join("versions").join(version_id).join(format!("{version_id}.jar")) + game_dir + .join("versions") + .join(version_id) + .join(format!("{version_id}.jar")) } -pub fn ensure_client_jar(client: &Client, game_dir: &Path, version_id: &str, download_ref: &DownloadRef) -> Result { +pub fn ensure_client_jar( + client: &Client, + game_dir: &Path, + version_id: &str, + download_ref: &DownloadRef, +) -> Result { if !is_allowed_host(&download_ref.url) { return Err(MojangError::DisallowedHost(download_ref.url.clone())); } let target = client_jar_path(game_dir, version_id); let checksum = Checksum::Sha1(download_ref.sha1.clone()); if !download::is_current(&target, Some(download_ref.size), &checksum)? { - download::download_verified(client, &download_ref.url, &target, Some(download_ref.size), &checksum, |_, _| {})?; + download::download_verified( + client, + &download_ref.url, + &target, + Some(download_ref.size), + &checksum, + |_, _| {}, + )?; } Ok(target) } /// Downloads every rule-allowed library with a `downloads.artifact`, /// returning the resulting jar paths in the same order as `libraries`. -pub fn ensure_libraries(client: &Client, game_dir: &Path, libraries: &[Library], on_progress: &ProgressCallback) -> Result, MojangError> { +pub fn ensure_libraries( + client: &Client, + game_dir: &Path, + libraries: &[Library], + on_progress: &ProgressCallback, +) -> Result, MojangError> { let mut paths = Vec::new(); let mut tasks = Vec::new(); + let mut seen: HashMap = HashMap::new(); for library in libraries { if !rule_allows(&library.rules, &HashMap::new()) { continue; } - let Some(artifact) = library.downloads.as_ref().and_then(|downloads| downloads.artifact.as_ref()) else { + let Some(artifact) = library + .downloads + .as_ref() + .and_then(|downloads| downloads.artifact.as_ref()) + else { continue; }; let target = game_dir.join("libraries").join(&artifact.path); + let identity = (artifact.url.clone(), artifact.size, artifact.sha1.clone()); + if let Some(existing) = seen.get(&target) { + if existing != &identity { + return Err(MojangError::ConflictingLibrary( + target.display().to_string(), + )); + } + continue; + } + seen.insert(target.clone(), identity); paths.push(target.clone()); tasks.push(DownloadTask { url: artifact.url.clone(), @@ -433,7 +529,7 @@ pub fn ensure_libraries(client: &Client, game_dir: &Path, libraries: &[Library], checksum: Checksum::Sha1(artifact.sha1.clone()), }); } - download_many(client, tasks, on_progress)?; + download_many(client, tasks, on_progress, is_allowed_library_host)?; Ok(paths) } @@ -448,20 +544,39 @@ pub struct AssetObject { pub size: u64, } -pub fn ensure_asset_index(client: &Client, game_dir: &Path, asset_index: &AssetIndexRef) -> Result { +pub fn ensure_asset_index( + client: &Client, + game_dir: &Path, + asset_index: &AssetIndexRef, +) -> Result { if !is_allowed_host(&asset_index.url) { return Err(MojangError::DisallowedHost(asset_index.url.clone())); } - let target = game_dir.join("assets").join("indexes").join(format!("{}.json", asset_index.id)); + let target = game_dir + .join("assets") + .join("indexes") + .join(format!("{}.json", asset_index.id)); let checksum = Checksum::Sha1(asset_index.sha1.clone()); if !download::is_current(&target, Some(asset_index.size), &checksum)? { - download::download_verified(client, &asset_index.url, &target, Some(asset_index.size), &checksum, |_, _| {})?; + download::download_verified( + client, + &asset_index.url, + &target, + Some(asset_index.size), + &checksum, + |_, _| {}, + )?; } let bytes = fs::read(&target)?; serde_json::from_slice(&bytes).map_err(MojangError::InvalidJson) } -pub fn ensure_assets(client: &Client, game_dir: &Path, index: &AssetIndex, on_progress: &ProgressCallback) -> Result<(), MojangError> { +pub fn ensure_assets( + client: &Client, + game_dir: &Path, + index: &AssetIndex, + on_progress: &ProgressCallback, +) -> Result<(), MojangError> { let objects_dir = game_dir.join("assets").join("objects"); let tasks = index .objects @@ -469,14 +584,17 @@ pub fn ensure_assets(client: &Client, game_dir: &Path, index: &AssetIndex, on_pr .map(|object| { let prefix = &object.hash[0..2]; DownloadTask { - url: format!("https://resources.download.minecraft.net/{prefix}/{}", object.hash), + url: format!( + "https://resources.download.minecraft.net/{prefix}/{}", + object.hash + ), target: objects_dir.join(prefix).join(&object.hash), size: object.size, checksum: Checksum::Sha1(object.hash.clone()), } }) .collect(); - download_many(client, tasks, on_progress) + download_many(client, tasks, on_progress, is_allowed_host) } struct DownloadTask { @@ -496,7 +614,14 @@ const MAX_DOWNLOAD_ATTEMPTS: u32 = 5; fn download_with_retries(client: &Client, task: &DownloadTask) -> Result { let mut last_error = None; for attempt in 1..=MAX_DOWNLOAD_ATTEMPTS { - match download::download_verified(client, &task.url, &task.target, Some(task.size), &task.checksum, |_, _| {}) { + match download::download_verified( + client, + &task.url, + &task.target, + Some(task.size), + &task.checksum, + |_, _| {}, + ) { Ok(bytes) => return Ok(bytes), Err(error) => { last_error = Some(error); @@ -512,7 +637,12 @@ fn download_with_retries(client: &Client, task: &DownloadTask) -> Result, on_progress: &ProgressCallback) -> Result<(), MojangError> { +fn download_many( + client: &Client, + tasks: Vec, + on_progress: &ProgressCallback, + is_allowed: fn(&str) -> bool, +) -> Result<(), MojangError> { let total: u64 = tasks.iter().map(|task| task.size).sum(); if total == 0 { return Ok(()); @@ -531,12 +661,16 @@ fn download_many(client: &Client, tasks: Vec, on_progress: &Progre if first_error.lock().unwrap().is_some() { break; } - let Some(task) = queue.lock().unwrap().pop() else { break }; - if !is_allowed_host(&task.url) { + let Some(task) = queue.lock().unwrap().pop() else { + break; + }; + if !is_allowed(&task.url) { *first_error.lock().unwrap() = Some(MojangError::DisallowedHost(task.url)); continue; } - let already_current = download::is_current(&task.target, Some(task.size), &task.checksum).unwrap_or(false); + let already_current = + download::is_current(&task.target, Some(task.size), &task.checksum) + .unwrap_or(false); if !already_current { if let Err(error) = download_with_retries(client, &task) { *first_error.lock().unwrap() = Some(MojangError::Download(error)); @@ -562,7 +696,10 @@ mod tests { fn rule(action: RuleAction, os_name: Option<&str>) -> Rule { Rule { action, - os: os_name.map(|name| RuleOs { name: Some(name.into()), arch: None }), + os: os_name.map(|name| RuleOs { + name: Some(name.into()), + arch: None, + }), features: None, } } @@ -580,7 +717,11 @@ mod tests { #[test] fn non_matching_os_rule_disallows() { - let other = if current_os_name() == "windows" { "linux" } else { "windows" }; + let other = if current_os_name() == "windows" { + "linux" + } else { + "windows" + }; let rules = vec![rule(RuleAction::Allow, Some(other))]; assert!(!rule_allows(&rules, &HashMap::new())); } @@ -589,7 +730,11 @@ mod tests { fn unsupported_feature_is_excluded_by_default() { let mut features = HashMap::new(); features.insert("is_demo_user".to_string(), true); - let rules = vec![Rule { action: RuleAction::Allow, os: None, features: Some(features) }]; + let rules = vec![Rule { + action: RuleAction::Allow, + os: None, + features: Some(features), + }]; // We never activate optional features, so a rule requiring one // must not match even though there's no OS constraint. assert!(!rule_allows(&rules, &HashMap::new())); @@ -610,7 +755,10 @@ mod tests { }, ]; let resolved = resolve_arguments(&args, &HashMap::new()); - assert_eq!(resolved, vec!["--username", "${auth_player_name}", "--this-os-only"]); + assert_eq!( + resolved, + vec!["--username", "${auth_player_name}", "--this-os-only"] + ); } #[test] @@ -640,17 +788,39 @@ mod tests { let merged = merge_versions(&parent, Some(&child)).unwrap(); assert_eq!(merged.id, "neoforge-21.1.248"); assert_eq!(merged.client_jar_version_id, "1.21.1"); - assert_eq!(merged.main_class, "cpw.mods.bootstraplauncher.BootstrapLauncher"); - assert_eq!(resolve_arguments(&merged.game_arguments, &HashMap::new()), vec!["--parentGame", "--childGame"]); - assert_eq!(resolve_arguments(&merged.jvm_arguments, &HashMap::new()), vec!["--parentJvm", "--childJvm"]); - assert_eq!(merged.libraries.iter().map(|library| library.name.as_str()).collect::>(), vec!["parent:lib:1", "child:lib:1"]); + assert_eq!( + merged.main_class, + "cpw.mods.bootstraplauncher.BootstrapLauncher" + ); + assert_eq!( + resolve_arguments(&merged.game_arguments, &HashMap::new()), + vec!["--parentGame", "--childGame"] + ); + assert_eq!( + resolve_arguments(&merged.jvm_arguments, &HashMap::new()), + vec!["--parentJvm", "--childJvm"] + ); + assert_eq!( + merged + .libraries + .iter() + .map(|library| library.name.as_str()) + .collect::>(), + vec!["parent:lib:1", "child:lib:1"] + ); assert_eq!(merged.asset_index.id, "17"); } #[test] fn disallowed_host_is_rejected() { assert!(!is_allowed_host("https://example.com/evil.jar")); - assert!(is_allowed_host("https://piston-data.mojang.com/v1/objects/x/client.jar")); + assert!(is_allowed_host( + "https://piston-data.mojang.com/v1/objects/x/client.jar" + )); + assert!(is_allowed_library_host( + "https://maven.neoforged.net/releases/net/neoforged/example.jar" + )); + assert!(!is_allowed_library_host("https://example.com/evil.jar")); } /// Live smoke test against the real Mojang CDN: manifest -> version JSON @@ -666,7 +836,8 @@ mod tests { let version = fetch_version_json(&client, entry).unwrap(); assert_eq!(version.main_class, "net.minecraft.client.main.Main"); - let game_dir = std::env::temp_dir().join(format!("shacraft-mojang-live-{}", std::process::id())); + let game_dir = + std::env::temp_dir().join(format!("shacraft-mojang-live-{}", std::process::id())); let merged = merge_versions(&version, None).unwrap(); let client_jar = ensure_client_jar(&client, &game_dir, &merged.id, &merged.client).unwrap(); @@ -678,11 +849,20 @@ mod tests { let mut small_libraries: Vec = merged .libraries .iter() - .filter(|library| library.downloads.as_ref().and_then(|downloads| downloads.artifact.as_ref()).is_some_and(|artifact| artifact.size < 200_000)) + .filter(|library| { + library + .downloads + .as_ref() + .and_then(|downloads| downloads.artifact.as_ref()) + .is_some_and(|artifact| artifact.size < 200_000) + }) .take(5) .cloned() .collect(); - assert!(!small_libraries.is_empty(), "expected at least one small library to sanity-check downloads with"); + assert!( + !small_libraries.is_empty(), + "expected at least one small library to sanity-check downloads with" + ); small_libraries.truncate(5); let progress: ProgressCallback = Arc::new(|_, _| {}); let paths = ensure_libraries(&client, &game_dir, &small_libraries, &progress).unwrap(); @@ -692,7 +872,8 @@ mod tests { // Re-running against already-downloaded files must be a no-op (the // `is_current` fast path), not re-download or fail. - let client_jar_again = ensure_client_jar(&client, &game_dir, &merged.id, &merged.client).unwrap(); + let client_jar_again = + ensure_client_jar(&client, &game_dir, &merged.id, &merged.client).unwrap(); assert_eq!(client_jar, client_jar_again); fs::remove_dir_all(&game_dir).ok(); diff --git a/src-tauri/src/msa.rs b/src-tauri/src/msa.rs index 64fa513..73e84e8 100644 --- a/src-tauri/src/msa.rs +++ b/src-tauri/src/msa.rs @@ -33,7 +33,7 @@ use std::{ /// access. Replace this before shipping login — see the module doc above. const MSA_CLIENT_ID: &str = "00000000-0000-0000-0000-000000000000"; -fn client_id_is_configured() -> bool { +pub fn is_configured() -> bool { MSA_CLIENT_ID != "00000000-0000-0000-0000-000000000000" } @@ -41,15 +41,21 @@ const DEVICE_CODE_URL: &str = "https://login.microsoftonline.com/consumers/oauth const TOKEN_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"; const XBOX_USER_AUTH_URL: &str = "https://user.auth.xboxlive.com/user/authenticate"; const XSTS_AUTHORIZE_URL: &str = "https://xsts.auth.xboxlive.com/xsts/authorize"; -const MINECRAFT_LOGIN_URL: &str = "https://api.minecraftservices.com/authentication/login_with_xbox"; +const MINECRAFT_LOGIN_URL: &str = + "https://api.minecraftservices.com/authentication/login_with_xbox"; const MINECRAFT_PROFILE_URL: &str = "https://api.minecraftservices.com/minecraft/profile"; const ACCOUNT_FILE: &str = "account.json"; pub fn http_client() -> Result { - crate::trusted_http::client(&[ - "login.microsoftonline.com", "user.auth.xboxlive.com", - "xsts.auth.xboxlive.com", "api.minecraftservices.com", - ], Duration::from_secs(30)) + crate::trusted_http::client( + &[ + "login.microsoftonline.com", + "user.auth.xboxlive.com", + "xsts.auth.xboxlive.com", + "api.minecraftservices.com", + ], + Duration::from_secs(30), + ) } #[derive(Debug)] @@ -113,12 +119,15 @@ struct DeviceCodeResponse { } pub fn start_device_code(client: &Client) -> Result { - if !client_id_is_configured() { + if !is_configured() { return Err(MsaError::NotConfigured); } let response = client .post(DEVICE_CODE_URL) - .form(&[("client_id", MSA_CLIENT_ID), ("scope", "XboxLive.signin offline_access")]) + .form(&[ + ("client_id", MSA_CLIENT_ID), + ("scope", "XboxLive.signin offline_access"), + ]) .send() .map_err(MsaError::Network)?; if !response.status().is_success() { @@ -151,7 +160,10 @@ struct TokenResponse { /// decline. This is the slow step in the whole login flow — the caller /// should already have shown `verification_uri`/`user_code` to the user /// before calling this (see `start_device_code`). -pub fn poll_device_code(client: &Client, start: &DeviceCodeStart) -> Result { +pub fn poll_device_code( + client: &Client, + start: &DeviceCodeStart, +) -> Result { let deadline = Instant::now() + Duration::from_secs(start.expires_in_seconds); let mut interval = Duration::from_secs(start.interval_seconds); @@ -174,10 +186,16 @@ pub fn poll_device_code(client: &Client, start: &DeviceCodeStart) -> Result Result return Err(MsaError::AuthorizationDeclined), Some("expired_token") => return Err(MsaError::AuthorizationExpired), - other => return Err(MsaError::UnexpectedResponse(other.unwrap_or("unknown device code error").into())), + other => { + return Err(MsaError::UnexpectedResponse( + other.unwrap_or("unknown device code error").into(), + )) + } } } } -pub fn refresh_microsoft_tokens(client: &Client, refresh_token: &str) -> Result { - if !client_id_is_configured() { +pub fn refresh_microsoft_tokens( + client: &Client, + refresh_token: &str, +) -> Result { + if !is_configured() { return Err(MsaError::NotConfigured); } let response = client @@ -212,9 +237,14 @@ pub fn refresh_microsoft_tokens(client: &Client, refresh_token: &str) -> Result< } let body: TokenResponse = response.json().map_err(MsaError::Network)?; let (Some(access_token), Some(refresh_token)) = (body.access_token, body.refresh_token) else { - return Err(MsaError::UnexpectedResponse("refresh response missing access_token/refresh_token".into())); + return Err(MsaError::UnexpectedResponse( + "refresh response missing access_token/refresh_token".into(), + )); }; - Ok(MicrosoftTokens { access_token, refresh_token }) + Ok(MicrosoftTokens { + access_token, + refresh_token, + }) } // --------------------------------------------------------------------- @@ -281,7 +311,10 @@ struct XboxUserHash { xid: Option, } -fn xbox_live_user_token(client: &Client, microsoft_access_token: &str) -> Result<(String, String), MsaError> { +fn xbox_live_user_token( + client: &Client, + microsoft_access_token: &str, +) -> Result<(String, String), MsaError> { let request = XboxUserAuthRequest { properties: XboxUserAuthProperties { auth_method: "RPS", @@ -291,22 +324,42 @@ fn xbox_live_user_token(client: &Client, microsoft_access_token: &str) -> Result relying_party: "http://auth.xboxlive.com", token_type: "JWT", }; - let response = client.post(XBOX_USER_AUTH_URL).json(&request).send().map_err(MsaError::Network)?; + let response = client + .post(XBOX_USER_AUTH_URL) + .json(&request) + .send() + .map_err(MsaError::Network)?; if !response.status().is_success() { return Err(MsaError::HttpStatus(response.status())); } let body: XboxTokenResponse = response.json().map_err(MsaError::Network)?; - let uhs = body.display_claims.xui.into_iter().next().map(|claim| claim.uhs).ok_or_else(|| MsaError::UnexpectedResponse("missing uhs".into()))?; + let uhs = body + .display_claims + .xui + .into_iter() + .next() + .map(|claim| claim.uhs) + .ok_or_else(|| MsaError::UnexpectedResponse("missing uhs".into()))?; Ok((body.token, uhs)) } -fn xsts_authorize(client: &Client, xbox_live_token: &str) -> Result<(String, String, Option), MsaError> { +fn xsts_authorize( + client: &Client, + xbox_live_token: &str, +) -> Result<(String, String, Option), MsaError> { let request = XstsRequest { - properties: XstsProperties { sandbox_id: "RETAIL", user_tokens: [xbox_live_token] }, + properties: XstsProperties { + sandbox_id: "RETAIL", + user_tokens: [xbox_live_token], + }, relying_party: "rp://api.minecraftservices.com/", token_type: "JWT", }; - let response = client.post(XSTS_AUTHORIZE_URL).json(&request).send().map_err(MsaError::Network)?; + let response = client + .post(XSTS_AUTHORIZE_URL) + .json(&request) + .send() + .map_err(MsaError::Network)?; let status = response.status(); if status.as_u16() == 401 { // XErr 2148916233 means the account has no Xbox profile at all @@ -319,7 +372,12 @@ fn xsts_authorize(client: &Client, xbox_live_token: &str) -> Result<(String, Str return Err(MsaError::HttpStatus(status)); } let body: XboxTokenResponse = response.json().map_err(MsaError::Network)?; - let claim = body.display_claims.xui.into_iter().next().ok_or_else(|| MsaError::UnexpectedResponse("missing uhs".into()))?; + let claim = body + .display_claims + .xui + .into_iter() + .next() + .ok_or_else(|| MsaError::UnexpectedResponse("missing uhs".into()))?; Ok((body.token, claim.uhs, claim.xid)) } @@ -335,8 +393,14 @@ struct MinecraftLoginResponse { } fn minecraft_login(client: &Client, user_hash: &str, xsts_token: &str) -> Result { - let request = MinecraftLoginRequest { identity_token: format!("XBL3.0 x={user_hash};{xsts_token}") }; - let response = client.post(MINECRAFT_LOGIN_URL).json(&request).send().map_err(MsaError::Network)?; + let request = MinecraftLoginRequest { + identity_token: format!("XBL3.0 x={user_hash};{xsts_token}"), + }; + let response = client + .post(MINECRAFT_LOGIN_URL) + .json(&request) + .send() + .map_err(MsaError::Network)?; if !response.status().is_success() { return Err(MsaError::HttpStatus(response.status())); } @@ -354,7 +418,10 @@ pub struct MinecraftProfile { /// Confirms game ownership. A 404 here means the account has no Java /// Edition profile — i.e. doesn't own the game — and nothing should /// install or launch. -fn fetch_minecraft_profile(client: &Client, minecraft_access_token: &str) -> Result { +fn fetch_minecraft_profile( + client: &Client, + minecraft_access_token: &str, +) -> Result { let response = client .get(MINECRAFT_PROFILE_URL) .bearer_auth(minecraft_access_token) @@ -383,15 +450,26 @@ fn complete_login(client: &Client, tokens: MicrosoftTokens) -> Result Result { +pub fn login_with_device_code( + client: &Client, + start: &DeviceCodeStart, +) -> Result { let tokens = poll_device_code(client, start)?; complete_login(client, tokens) } -pub fn login_with_refresh_token(client: &Client, refresh_token: &str) -> Result { +pub fn login_with_refresh_token( + client: &Client, + refresh_token: &str, +) -> Result { let tokens = refresh_microsoft_tokens(client, refresh_token)?; complete_login(client, tokens) } @@ -408,8 +486,15 @@ struct StoredAccount { pub fn save_refresh_token(data_dir: &Path, refresh_token: &str) -> io::Result<()> { fs::create_dir_all(data_dir)?; - let saved_at_unix = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); - let contents = serde_json::to_vec_pretty(&StoredAccount { refresh_token: refresh_token.to_string(), saved_at_unix }).expect("StoredAccount is serializable"); + let saved_at_unix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let contents = serde_json::to_vec_pretty(&StoredAccount { + refresh_token: refresh_token.to_string(), + saved_at_unix, + }) + .expect("StoredAccount is serializable"); let target = data_dir.join(ACCOUNT_FILE); crate::storage::write_atomic(&target, &contents) @@ -438,7 +523,10 @@ mod tests { let dir = std::env::temp_dir().join(format!("shacraft-msa-test-{}", std::process::id())); assert!(load_refresh_token(&dir).is_none()); save_refresh_token(&dir, "super-secret-refresh-token").unwrap(); - assert_eq!(load_refresh_token(&dir).as_deref(), Some("super-secret-refresh-token")); + assert_eq!( + load_refresh_token(&dir).as_deref(), + Some("super-secret-refresh-token") + ); clear_account(&dir).unwrap(); assert!(load_refresh_token(&dir).is_none()); fs::remove_dir_all(&dir).ok(); @@ -448,18 +536,26 @@ mod tests { #[test] fn stored_account_file_is_not_world_or_group_readable() { use std::os::unix::fs::PermissionsExt; - let dir = std::env::temp_dir().join(format!("shacraft-msa-perm-test-{}", std::process::id())); + let dir = + std::env::temp_dir().join(format!("shacraft-msa-perm-test-{}", std::process::id())); save_refresh_token(&dir, "secret").unwrap(); - let mode = fs::metadata(dir.join(ACCOUNT_FILE)).unwrap().permissions().mode() & 0o777; + let mode = fs::metadata(dir.join(ACCOUNT_FILE)) + .unwrap() + .permissions() + .mode() + & 0o777; assert_eq!(mode, 0o600); fs::remove_dir_all(&dir).ok(); } #[test] fn refuses_to_run_with_placeholder_client_id() { - assert!(!client_id_is_configured()); + assert!(!is_configured()); let client = Client::builder().build().unwrap(); - assert!(matches!(start_device_code(&client), Err(MsaError::NotConfigured))); + assert!(matches!( + start_device_code(&client), + Err(MsaError::NotConfigured) + )); } /// Live smoke test: requests a real device code from Microsoft and diff --git a/src-tauri/src/neoforge.rs b/src-tauri/src/neoforge.rs index 34af82b..b33f61e 100644 --- a/src-tauri/src/neoforge.rs +++ b/src-tauri/src/neoforge.rs @@ -40,7 +40,7 @@ use std::{ thread, }; -const NEOFORGE_HOST: &str = "maven.neoforged.net"; +pub(crate) const NEOFORGE_HOST: &str = "maven.neoforged.net"; /// Minimal `launcher_profiles.json` accepted by the legacy NeoForge/Forge /// installer as proof that a directory is a legitimate launcher data @@ -56,21 +56,34 @@ pub enum NeoForgeError { Download(DownloadError), Io(io::Error), InvalidJson(serde_json::Error), - InstallerFailed { exit_code: Option, output_tail: String }, + InstallerFailed { + exit_code: Option, + output_tail: String, + }, } impl fmt::Display for NeoForgeError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::DisallowedHost(url) => write!(formatter, "URL is not a recognised NeoForge host: {url}"), + Self::DisallowedHost(url) => { + write!(formatter, "URL is not a recognised NeoForge host: {url}") + } Self::Network(error) => write!(formatter, "network error: {error}"), Self::HttpStatus(status) => write!(formatter, "maven.neoforged.net returned {status}"), - Self::InvalidChecksum(text) => write!(formatter, "unexpected checksum response: {text}"), + Self::InvalidChecksum(text) => { + write!(formatter, "unexpected checksum response: {text}") + } Self::Download(error) => write!(formatter, "{error}"), Self::Io(error) => write!(formatter, "I/O error: {error}"), Self::InvalidJson(error) => write!(formatter, "invalid NeoForge version JSON: {error}"), - Self::InstallerFailed { exit_code, output_tail } => { - write!(formatter, "NeoForge installer failed (exit {exit_code:?}):\n{output_tail}") + Self::InstallerFailed { + exit_code, + output_tail, + } => { + write!( + formatter, + "NeoForge installer failed (exit {exit_code:?}):\n{output_tail}" + ) } } } @@ -87,7 +100,7 @@ impl From for NeoForgeError { } } -fn is_allowed_host(url: &str) -> bool { +pub(crate) fn is_allowed_host(url: &str) -> bool { crate::trusted_http::allows(url, &[NEOFORGE_HOST]) } @@ -102,18 +115,29 @@ fn installer_jar_url(loader_version: &str) -> String { /// Downloads (or reuses a cached, still-valid) NeoForge installer jar, /// verified against the `.sha256` sidecar Maven publishes next to every /// artifact. -pub fn ensure_installer(client: &Client, cache_dir: &Path, loader_version: &str) -> Result { +pub fn ensure_installer( + client: &Client, + cache_dir: &Path, + loader_version: &str, +) -> Result { let jar_url = installer_jar_url(loader_version); let checksum_url = format!("{jar_url}.sha256"); if !is_allowed_host(&jar_url) { return Err(NeoForgeError::DisallowedHost(jar_url)); } - let response = client.get(&checksum_url).send().map_err(NeoForgeError::Network)?; + let response = client + .get(&checksum_url) + .send() + .map_err(NeoForgeError::Network)?; if !response.status().is_success() { return Err(NeoForgeError::HttpStatus(response.status())); } - let sha256 = response.text().map_err(NeoForgeError::Network)?.trim().to_ascii_lowercase(); + let sha256 = response + .text() + .map_err(NeoForgeError::Network)? + .trim() + .to_ascii_lowercase(); if sha256.len() != 64 || !sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) { return Err(NeoForgeError::InvalidChecksum(sha256)); } @@ -142,6 +166,23 @@ pub fn installed_version_json_path(game_dir: &Path, loader_version: &str) -> Pat .join(format!("neoforge-{loader_version}.json")) } +fn patched_client_path(game_dir: &Path, loader_version: &str) -> PathBuf { + game_dir + .join("libraries/net/neoforged/neoforge") + .join(loader_version) + .join(format!("neoforge-{loader_version}-client.jar")) +} + +fn is_nonempty_file(path: &Path) -> bool { + path.metadata() + .is_ok_and(|metadata| metadata.is_file() && metadata.len() > 0) +} + +fn installation_complete(game_dir: &Path, loader_version: &str) -> bool { + is_nonempty_file(&installed_version_json_path(game_dir, loader_version)) + && is_nonempty_file(&patched_client_path(game_dir, loader_version)) +} + /// The installer jar bundles its own `install_profile.json`, which lists /// exactly which libraries it will download and which processors it will /// run to patch the client — the same manifest the installer itself reads. @@ -164,7 +205,14 @@ fn read_install_profile_counts(installer_path: &Path) -> Option<(u64, u64)> { /// installer logging a couple of extra non-library downloads) never exceeds /// or exceeds `total` by much. `total_libraries` caps the download half so /// those extra lines cannot crowd out the processor half of the bar. -fn observe_installer_line(line: &str, downloads_done: &AtomicU64, processors_done: &AtomicU64, total_libraries: u64, total: u64, on_progress: &ProgressCallback) { +fn observe_installer_line( + line: &str, + downloads_done: &AtomicU64, + processors_done: &AtomicU64, + total_libraries: u64, + total: u64, + on_progress: &ProgressCallback, +) { let trimmed = line.trim_start(); if trimmed.starts_with("Download completed") { downloads_done.fetch_add(1, Ordering::Relaxed); @@ -176,12 +224,19 @@ fn observe_installer_line(line: &str, downloads_done: &AtomicU64, processors_don } else { return; } - let current = downloads_done.load(Ordering::Relaxed).min(total_libraries) + processors_done.load(Ordering::Relaxed); + let current = downloads_done.load(Ordering::Relaxed).min(total_libraries) + + processors_done.load(Ordering::Relaxed); on_progress(current.min(total), total); } fn truncate_tail(text: &str) -> String { - text.chars().rev().take(4000).collect::().chars().rev().collect() + text.chars() + .rev() + .take(4000) + .collect::() + .chars() + .rev() + .collect() } /// Runs the installer with piped output, reporting live progress as its own @@ -222,7 +277,14 @@ fn run_installer_with_progress( let on_progress = Arc::clone(on_progress); thread::spawn(move || { for line in BufReader::new(stdout).lines().map_while(Result::ok) { - observe_installer_line(&line, &downloads_done, &processors_done, total_libraries, total, &on_progress); + observe_installer_line( + &line, + &downloads_done, + &processors_done, + total_libraries, + total, + &on_progress, + ); let mut log = combined_log.lock().unwrap(); log.push_str(&line); log.push('\n'); @@ -246,7 +308,10 @@ fn run_installer_with_progress( let tail = truncate_tail(&combined_log.lock().unwrap()); if !status.success() { - return Err(NeoForgeError::InstallerFailed { exit_code: status.code(), output_tail: tail }); + return Err(NeoForgeError::InstallerFailed { + exit_code: status.code(), + output_tail: tail, + }); } Ok((status.code(), tail)) } @@ -261,19 +326,48 @@ fn run_installer_with_progress( /// progress (installer-confirmed library downloads plus patch-processor /// steps, read from the installer's own `install_profile.json`) while it /// runs; it fires once with `(1, 1)` when already installed. -pub fn ensure_client_installed(client: &Client, java_executable: &Path, game_dir: &Path, cache_dir: &Path, loader_version: &str, on_progress: &ProgressCallback) -> Result { +pub fn ensure_client_installed( + client: &Client, + java_executable: &Path, + game_dir: &Path, + cache_dir: &Path, + loader_version: &str, + on_progress: &ProgressCallback, +) -> Result { let version_json_path = installed_version_json_path(game_dir, loader_version); - if !version_json_path.exists() { + if !installation_complete(game_dir, loader_version) { ensure_launcher_profiles_stub(game_dir)?; let installer_path = ensure_installer(client, cache_dir, loader_version)?; - let (total_libraries, total_processors) = read_install_profile_counts(&installer_path).unwrap_or((0, 0)); + // A leftover version JSON makes some installer versions treat the + // profile as already installed even when the patched client was + // deleted or quarantined. Remove only that generated marker so the + // official installer is forced to rebuild the incomplete profile. + match fs::remove_file(&version_json_path) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(NeoForgeError::Io(error)), + } + + let (total_libraries, total_processors) = + read_install_profile_counts(&installer_path).unwrap_or((0, 0)); let total = (total_libraries + total_processors).max(1); on_progress(0, total); - let (exit_code, tail) = run_installer_with_progress(java_executable, &installer_path, game_dir, cache_dir, total_libraries, total, on_progress)?; - if !version_json_path.exists() { - return Err(NeoForgeError::InstallerFailed { exit_code, output_tail: tail }); + let (exit_code, tail) = run_installer_with_progress( + java_executable, + &installer_path, + game_dir, + cache_dir, + total_libraries, + total, + on_progress, + )?; + if !installation_complete(game_dir, loader_version) { + return Err(NeoForgeError::InstallerFailed { + exit_code, + output_tail: tail, + }); } on_progress(total, total); } else { @@ -299,12 +393,15 @@ mod tests { #[test] fn rejects_non_neoforge_hosts() { assert!(!is_allowed_host("https://example.com/evil.jar")); - assert!(is_allowed_host("https://maven.neoforged.net/releases/x.jar")); + assert!(is_allowed_host( + "https://maven.neoforged.net/releases/x.jar" + )); } #[test] fn launcher_profiles_stub_is_idempotent() { - let dir = std::env::temp_dir().join(format!("shacraft-neoforge-test-{}", std::process::id())); + let dir = + std::env::temp_dir().join(format!("shacraft-neoforge-test-{}", std::process::id())); ensure_launcher_profiles_stub(&dir).unwrap(); let first = fs::read_to_string(dir.join("launcher_profiles.json")).unwrap(); fs::write(dir.join("launcher_profiles.json"), "custom-content").unwrap(); @@ -315,6 +412,25 @@ mod tests { fs::remove_dir_all(&dir).unwrap(); } + #[test] + fn incomplete_install_is_not_accepted() { + let dir = std::env::temp_dir().join(format!( + "shacraft-neoforge-completeness-test-{}", + std::process::id() + )); + let version = "21.1.248"; + let json = installed_version_json_path(&dir, version); + fs::create_dir_all(json.parent().unwrap()).unwrap(); + fs::write(&json, b"{}").unwrap(); + assert!(!installation_complete(&dir, version)); + + let client = patched_client_path(&dir, version); + fs::create_dir_all(client.parent().unwrap()).unwrap(); + fs::write(&client, b"patched").unwrap(); + assert!(installation_complete(&dir, version)); + fs::remove_dir_all(dir).unwrap(); + } + #[test] fn observe_installer_line_counts_downloads_and_processor_headers() { let downloads_done = AtomicU64::new(0); @@ -329,12 +445,47 @@ mod tests { // A "Downloading library from ..." start line reports nothing by // itself; only its "Download completed" confirmation counts. - observe_installer_line("Downloading library from https://example/a.jar", &downloads_done, &processors_done, total_libraries, total, &on_progress); - observe_installer_line("Download completed: Checksum validated.", &downloads_done, &processors_done, total_libraries, total, &on_progress); - observe_installer_line("Download completed: Checksum validated.", &downloads_done, &processors_done, total_libraries, total, &on_progress); - observe_installer_line("Processor: net.neoforged.installertools:jarsplitter", &downloads_done, &processors_done, total_libraries, total, &on_progress); + observe_installer_line( + "Downloading library from https://example/a.jar", + &downloads_done, + &processors_done, + total_libraries, + total, + &on_progress, + ); + observe_installer_line( + "Download completed: Checksum validated.", + &downloads_done, + &processors_done, + total_libraries, + total, + &on_progress, + ); + observe_installer_line( + "Download completed: Checksum validated.", + &downloads_done, + &processors_done, + total_libraries, + total, + &on_progress, + ); + observe_installer_line( + "Processor: net.neoforged.installertools:jarsplitter", + &downloads_done, + &processors_done, + total_libraries, + total, + &on_progress, + ); // A processor's sub-step lines (three colons) must not double-count. - observe_installer_line("Processor: net.neoforged.installertools:jarsplitter: Loading patch files", &downloads_done, &processors_done, total_libraries, total, &on_progress); + observe_installer_line( + "Processor: net.neoforged.installertools:jarsplitter: Loading patch files", + &downloads_done, + &processors_done, + total_libraries, + total, + &on_progress, + ); assert_eq!(*calls.lock().unwrap(), vec![(1, 3), (2, 3), (3, 3)]); } @@ -353,7 +504,8 @@ mod tests { use crate::{java, mojang}; let client = Client::builder().build().unwrap(); - let root = std::env::temp_dir().join(format!("shacraft-neoforge-pipeline-{}", std::process::id())); + let root = + std::env::temp_dir().join(format!("shacraft-neoforge-pipeline-{}", std::process::id())); let game_dir = root.join("game"); let cache_dir = root.join("cache"); fs::create_dir_all(&cache_dir).unwrap(); @@ -363,7 +515,8 @@ mod tests { let vanilla = mojang::fetch_version_json(&client, entry).unwrap(); let no_progress: ProgressCallback = Arc::new(|_, _| {}); - let java_install = java::ensure_java(&client, &root.join("runtime"), 21, &no_progress).unwrap(); + let java_install = + java::ensure_java(&client, &root.join("runtime"), 21, &no_progress).unwrap(); // The installer fetches and patches vanilla itself; we don't // pre-download it. It only needs a Java runtime and an empty dir. @@ -372,25 +525,65 @@ mod tests { let progress_calls = Arc::clone(&progress_calls); Arc::new(move |current, total| progress_calls.lock().unwrap().push((current, total))) }; - let neoforge_version = ensure_client_installed(&client, Path::new(&java_install.executable), &game_dir, &cache_dir, "21.1.248", &progress).unwrap(); + let neoforge_version = ensure_client_installed( + &client, + Path::new(&java_install.executable), + &game_dir, + &cache_dir, + "21.1.248", + &progress, + ) + .unwrap(); let merged = mojang::merge_versions(&vanilla, Some(&neoforge_version)).unwrap(); - assert_eq!(merged.main_class, "cpw.mods.bootstraplauncher.BootstrapLauncher"); - assert!(merged.libraries.len() > 100, "expected vanilla (97) + neoforge (47) libraries, got {}", merged.libraries.len()); + assert_eq!( + merged.main_class, + "cpw.mods.bootstraplauncher.BootstrapLauncher" + ); + assert!( + merged.libraries.len() > 100, + "expected vanilla (97) + neoforge (47) libraries, got {}", + merged.libraries.len() + ); - let patched_client = game_dir.join("libraries/net/neoforged/neoforge/21.1.248/neoforge-21.1.248-client.jar"); - assert!(patched_client.exists(), "FancyModLoader needs this at runtime even though it is not on the generic classpath"); + let patched_client = + game_dir.join("libraries/net/neoforged/neoforge/21.1.248/neoforge-21.1.248-client.jar"); + assert!( + patched_client.exists(), + "FancyModLoader needs this at runtime even though it is not on the generic classpath" + ); let calls = progress_calls.lock().unwrap(); - assert!(calls.len() > 5, "expected many incremental progress calls, got {}", calls.len()); + assert!( + calls.len() > 5, + "expected many incremental progress calls, got {}", + calls.len() + ); let (last_current, last_total) = *calls.last().unwrap(); - assert_eq!(last_current, last_total, "progress must reach 100% on success"); - assert!(calls.windows(2).all(|pair| pair[0].0 <= pair[1].0), "reported progress must never go backwards"); + assert_eq!( + last_current, last_total, + "progress must reach 100% on success" + ); + assert!( + calls.windows(2).all(|pair| pair[0].0 <= pair[1].0), + "reported progress must never go backwards" + ); drop(calls); // Re-running must skip straight to reading the cached version JSON // rather than invoking the installer again. - let neoforge_again = ensure_client_installed(&client, Path::new(&java_install.executable), &game_dir, &cache_dir, "21.1.248", &no_progress).unwrap(); - assert_eq!(neoforge_again.libraries.len(), neoforge_version.libraries.len()); + let neoforge_again = ensure_client_installed( + &client, + Path::new(&java_install.executable), + &game_dir, + &cache_dir, + "21.1.248", + &no_progress, + ) + .unwrap(); + assert_eq!( + neoforge_again.libraries.len(), + neoforge_version.libraries.len() + ); fs::remove_dir_all(&root).ok(); } diff --git a/src-tauri/src/operations.rs b/src-tauri/src/operations.rs index 11cc177..5e73c32 100644 --- a/src-tauri/src/operations.rs +++ b/src-tauri/src/operations.rs @@ -11,6 +11,7 @@ use std::sync::{ pub(crate) struct LauncherOperations { pub installation: Operation, pub account: Operation, + pub shacraft_account: Operation, } #[derive(Clone, Default)] diff --git a/src-tauri/src/profile.rs b/src-tauri/src/profile.rs index c5705a6..59e91ae 100644 --- a/src-tauri/src/profile.rs +++ b/src-tauri/src/profile.rs @@ -254,6 +254,27 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + #[test] + fn preserves_changed_seed_files_as_current() { + let root = std::env::temp_dir().join(format!( + "shacraft-launcher-seed-test-{}-{}", + process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let mut expected = manifest("0".repeat(64), 42); + expected.files[0].policy = FilePolicy::Seed; + fs::create_dir_all(root.join("mods")).unwrap(); + fs::write(root.join("mods/example.jar"), b"player customization").unwrap(); + let inspection = inspect(&root, &expected).unwrap(); + assert_eq!(inspection.missing_files, 0); + assert_eq!(inspection.mismatched_files, 0); + assert!(inspection.up_to_date); + fs::remove_dir_all(root).unwrap(); + } + #[cfg(unix)] #[test] fn refuses_linked_profile_directories() { diff --git a/src-tauri/src/remote.rs b/src-tauri/src/remote.rs index 7deb7b2..0cd269f 100644 --- a/src-tauri/src/remote.rs +++ b/src-tauri/src/remote.rs @@ -1,20 +1,32 @@ use crate::manifest::{self, Manifest}; use base64::{engine::general_purpose::STANDARD, Engine}; use ed25519_dalek::{Signature, VerifyingKey}; +use reqwest::header::ACCEPT_ENCODING; use reqwest::{blocking::Client, redirect::Policy}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::{ fmt, io::{self, Read}, + thread, time::Duration, }; const AERONAUTICS_MANIFEST: &str = "https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest"; +const AERONAUTICS_ONLINE: &str = "https://shacraft.ru/api/online/aoc"; const MANIFEST_PUBLIC_KEY: &str = "2S3FRdZj4Xw5nJpZ3IhqVITBg3nTH9AtGSo1Ew9+qVQ="; const MANIFEST_KEY_ID: &str = "2026-09-06"; -// Allow the base64 envelope around a payload of up to 2 MiB. -const MAX_ENVELOPE_BYTES: usize = 3 * 1024 * 1024; +const MAX_ENVELOPE_BYTES: usize = 2 * 1024 * 1024; +const MANIFEST_ATTEMPTS: u32 = 3; + +/// Display-only status: never used to select executable files or versions. +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ServerStatus { + pub online: Option, + pub max: Option, + pub reachable: bool, +} #[derive(Deserialize)] #[serde(rename_all = "camelCase")] @@ -62,21 +74,13 @@ pub fn fetch_manifest(profile_id: &str) -> Result { _ => return Err(RemoteError::UnknownProfile), }; let client = Client::builder() + .https_only(true) + .connect_timeout(Duration::from_secs(10)) .timeout(Duration::from_secs(30)) .redirect(Policy::none()) .build() .map_err(RemoteError::Network)?; - let response = client.get(url).send().map_err(RemoteError::Network)?; - if !response.status().is_success() { - return Err(RemoteError::Status(response.status())); - } - if response - .content_length() - .is_some_and(|size| size > MAX_ENVELOPE_BYTES as u64) - { - return Err(RemoteError::TooLarge); - } - let source = read_envelope(response)?; + let source = fetch_manifest_bytes(&client, url)?; let public_key_bytes = STANDARD .decode(MANIFEST_PUBLIC_KEY) .expect("embedded public key must be valid"); @@ -89,6 +93,55 @@ pub fn fetch_manifest(profile_id: &str) -> Result { verify_envelope(&source, profile_id, &public_key) } +fn fetch_manifest_bytes(client: &Client, url: &str) -> Result, RemoteError> { + for attempt in 1..=MANIFEST_ATTEMPTS { + let request = || { + let response = client + .get(url) + .header(ACCEPT_ENCODING, "identity") + .send() + .map_err(RemoteError::Network)?; + if !response.status().is_success() { + return Err(RemoteError::Status(response.status())); + } + if response + .content_length() + .is_some_and(|size| size > MAX_ENVELOPE_BYTES as u64) + { + return Err(RemoteError::TooLarge); + } + read_envelope(response) + }; + match request() { + Err(RemoteError::Network(_) | RemoteError::Read(_)) if attempt < MANIFEST_ATTEMPTS => { + thread::sleep(Duration::from_millis(250 * attempt as u64)); + } + result => return result, + } + } + unreachable!("the last attempt always returns") +} + +pub fn fetch_server_status(profile_id: &str) -> Result { + let url = match profile_id { + "aeronautics" => AERONAUTICS_ONLINE, + _ => return Err(RemoteError::UnknownProfile), + }; + let client = Client::builder() + .https_only(true) + .timeout(Duration::from_secs(10)) + .redirect(Policy::none()) + .build() + .map_err(RemoteError::Network)?; + let response = client.get(url).send().map_err(RemoteError::Network)?; + if !response.status().is_success() { + return Err(RemoteError::Status(response.status())); + } + response + .json::() + .map_err(RemoteError::Network) +} + fn read_envelope(source: impl Read) -> Result, RemoteError> { let mut bytes = Vec::new(); source @@ -213,4 +266,37 @@ mod tests { Err(RemoteError::TooLarge) )); } + + #[test] + fn retries_truncated_manifest_transfers_and_requests_identity_encoding() { + use std::{ + io::{Read, Write}, + net::TcpListener, + }; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/manifest", listener.local_addr().unwrap()); + let server = std::thread::spawn(move || { + for body in [ + b"HTTP/1.1 200 OK\r\nContent-Length: 10\r\nConnection: close\r\n\r\nbad".as_slice(), + b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}".as_slice(), + ] { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut request = [0_u8; 4096]; + let length = stream.read(&mut request).unwrap(); + assert!(String::from_utf8_lossy(&request[..length]) + .to_ascii_lowercase() + .contains("accept-encoding: identity")); + stream.write_all(body).unwrap(); + } + }); + let client = Client::builder() + .timeout(Duration::from_secs(2)) + .build() + .unwrap(); + assert_eq!(fetch_manifest_bytes(&client, &url).unwrap(), b"{}"); + server.join().unwrap(); + } } diff --git a/src-tauri/src/shacraft_account.rs b/src-tauri/src/shacraft_account.rs new file mode 100644 index 0000000..39fb801 --- /dev/null +++ b/src-tauri/src/shacraft_account.rs @@ -0,0 +1,278 @@ +//! ShaCraft local-account client. +//! +//! The API origin is fixed in the binary. Passwords are sent only over HTTPS +//! and are never persisted; only the random, revocable session token is kept. + +use reqwest::blocking::{Client, Response}; +use reqwest::redirect::Policy; +use serde::{Deserialize, Serialize}; +use std::{fmt, fs, io, path::Path, time::Duration}; + +const API_ORIGIN: &str = "https://shacraft.ru"; +const SESSION_FILE: &str = "shacraft-session"; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct AccountLink { + pub server_id: String, + pub mc_username: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Account { + pub username: String, + pub links: Vec, +} + +#[derive(Deserialize)] +struct AuthResponse { + session_token: String, + account: Account, + recovery_codes: Vec, +} + +#[derive(Serialize)] +struct Credentials<'a> { + username: &'a str, + password: &'a str, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LoginResult { + pub account: Account, + pub recovery_codes: Vec, +} + +#[derive(Clone, Deserialize, Serialize)] +pub struct LinkStart { + pub challenge_id: i64, + pub expires_in_seconds: u64, + pub registered_on_server: bool, +} + +#[derive(Clone, Deserialize, Serialize)] +pub struct LinkStatus { + pub status: String, + pub detail: Option, +} + +#[derive(Debug)] +pub enum AccountError { + Network(reqwest::Error), + Api(String), + Io(io::Error), + InvalidSession, + NoLinkedNickname, +} + +impl fmt::Display for AccountError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Network(error) => write!(formatter, "Нет связи с аккаунтами ShaCraft: {error}"), + Self::Api(message) => formatter.write_str(message), + Self::Io(error) => write!(formatter, "Не удалось сохранить сессию: {error}"), + Self::InvalidSession => formatter.write_str("Сессия ShaCraft истекла — войдите снова"), + Self::NoLinkedNickname => { + formatter.write_str("Сначала привяжите игровой ник к серверу Aeronautics") + } + } + } +} + +fn client() -> Result { + Client::builder() + .https_only(true) + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(20)) + .redirect(Policy::none()) + .build() + .map_err(AccountError::Network) +} + +fn api_error(response: Response) -> AccountError { + #[derive(Deserialize)] + struct ErrorBody { + detail: Option, + } + let status = response.status(); + let detail = response + .json::() + .ok() + .and_then(|body| body.detail); + AccountError::Api(detail.unwrap_or_else(|| format!("ShaCraft API: HTTP {status}"))) +} + +fn session_path(data_dir: &Path) -> std::path::PathBuf { + data_dir.join(SESSION_FILE) +} + +fn save_session(data_dir: &Path, token: &str) -> Result<(), AccountError> { + fs::create_dir_all(data_dir).map_err(AccountError::Io)?; + let path = session_path(data_dir); + crate::storage::write_atomic(&path, token.as_bytes()).map_err(AccountError::Io) +} + +fn load_session(data_dir: &Path) -> Result { + let token = fs::read_to_string(session_path(data_dir)).map_err(|error| { + if error.kind() == io::ErrorKind::NotFound { + AccountError::InvalidSession + } else { + AccountError::Io(error) + } + })?; + let token = token.trim(); + if token.len() < 32 || token.bytes().any(|byte| byte.is_ascii_whitespace()) { + return Err(AccountError::InvalidSession); + } + Ok(token.to_owned()) +} + +pub fn authenticate( + data_dir: &Path, + username: &str, + password: &str, + register: bool, +) -> Result { + let endpoint = if register { + "/api/launcher/auth/register" + } else { + "/api/launcher/auth/login" + }; + let response = client()? + .post(format!("{API_ORIGIN}{endpoint}")) + .json(&Credentials { username, password }) + .send() + .map_err(AccountError::Network)?; + if !response.status().is_success() { + return Err(api_error(response)); + } + let payload = response + .json::() + .map_err(AccountError::Network)?; + save_session(data_dir, &payload.session_token)?; + Ok(LoginResult { + account: payload.account, + recovery_codes: payload.recovery_codes, + }) +} + +pub fn get_account(data_dir: &Path) -> Result { + let token = load_session(data_dir)?; + let response = client()? + .get(format!("{API_ORIGIN}/api/launcher/account")) + .bearer_auth(token) + .send() + .map_err(AccountError::Network)?; + if response.status() == reqwest::StatusCode::UNAUTHORIZED { + let _ = fs::remove_file(session_path(data_dir)); + return Err(AccountError::InvalidSession); + } + if !response.status().is_success() { + return Err(api_error(response)); + } + response.json::().map_err(AccountError::Network) +} + +pub fn logout(data_dir: &Path) -> Result<(), AccountError> { + if let Ok(token) = load_session(data_dir) { + let _ = client()? + .post(format!("{API_ORIGIN}/api/launcher/auth/logout")) + .bearer_auth(token) + .json(&serde_json::json!({})) + .send(); + } + match fs::remove_file(session_path(data_dir)) { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(AccountError::Io(error)), + } +} + +pub fn start_link( + data_dir: &Path, + server_id: &str, + nickname: &str, +) -> Result { + let token = load_session(data_dir)?; + let response = client()? + .post(format!("{API_ORIGIN}/api/account/link/start")) + .bearer_auth(token) + .json(&serde_json::json!({"server_id": server_id, "mc_username": nickname})) + .send() + .map_err(AccountError::Network)?; + if !response.status().is_success() { + return Err(api_error(response)); + } + response.json::().map_err(AccountError::Network) +} + +pub fn link_status(data_dir: &Path, challenge_id: i64) -> Result { + let token = load_session(data_dir)?; + let response = client()? + .get(format!( + "{API_ORIGIN}/api/account/link/status/{challenge_id}" + )) + .bearer_auth(token) + .send() + .map_err(AccountError::Network)?; + if !response.status().is_success() { + return Err(api_error(response)); + } + response.json::().map_err(AccountError::Network) +} + +pub fn aeronautics_nickname(data_dir: &Path) -> Result { + get_account(data_dir)? + .links + .into_iter() + .find(|link| link.server_id == "aoc") + .map(|link| link.mc_username) + .ok_or(AccountError::NoLinkedNickname) +} + +#[cfg(test)] +mod tests { + use super::{load_session, save_session, session_path}; + use std::{ + fs, process, + time::{SystemTime, UNIX_EPOCH}, + }; + + fn temporary_directory() -> std::path::PathBuf { + std::env::temp_dir().join(format!( + "shacraft-account-test-{}-{}", + process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )) + } + + #[test] + fn session_round_trips_without_password_storage() { + let directory = temporary_directory(); + let token = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG"; + save_session(&directory, token).unwrap(); + assert_eq!(load_session(&directory).unwrap(), token); + assert_eq!(fs::read_to_string(session_path(&directory)).unwrap(), token); + fs::remove_dir_all(directory).unwrap(); + } + + #[cfg(unix)] + #[test] + fn session_is_private_on_unix() { + use std::os::unix::fs::PermissionsExt; + let directory = temporary_directory(); + save_session(&directory, "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG").unwrap(); + assert_eq!( + fs::metadata(session_path(&directory)) + .unwrap() + .permissions() + .mode() + & 0o077, + 0 + ); + fs::remove_dir_all(directory).unwrap(); + } +} diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs index d5d1c19..20248a1 100644 --- a/src-tauri/src/storage.rs +++ b/src-tauri/src/storage.rs @@ -68,7 +68,7 @@ impl AtomicFile { pub fn commit(mut self) -> io::Result<()> { self.writer().sync_all()?; drop(self.file.take()); - fs::rename(&self.temporary, &self.target)?; + replace_file(&self.temporary, &self.target)?; self.committed = true; Ok(()) } @@ -89,6 +89,43 @@ pub(crate) fn write_atomic(target: &Path, bytes: &[u8]) -> io::Result<()> { output.commit() } +/// Prefer the platform's atomic replacement. If Windows refuses an existing +/// destination, retain the upstream recoverable replacement fallback, using +/// this transaction's unique temporary name instead of a shared backup path. +fn replace_file(temporary: &Path, target: &Path) -> io::Result<()> { + #[cfg(not(windows))] + { + fs::rename(temporary, target) + } + #[cfg(windows)] + { + match fs::rename(temporary, target) { + Ok(()) => return Ok(()), + Err(error) if !target.is_file() => return Err(error), + Err(_) => {} + } + let mut backup_name = temporary.as_os_str().to_os_string(); + backup_name.push(".backup"); + let backup = PathBuf::from(backup_name); + // Never overwrite a previous failed transaction's recovery file. + let reservation = OpenOptions::new() + .write(true) + .create_new(true) + .open(&backup)?; + drop(reservation); + fs::remove_file(&backup)?; + fs::rename(target, &backup)?; + if let Err(error) = fs::rename(temporary, target) { + if let Err(restore_error) = fs::rename(&backup, target) { + return Err(io::Error::new(error.kind(), format!("Cannot replace file: {error}; cannot restore it: {restore_error}; previous file is recoverable at {}", backup.display()))); + } + return Err(error); + } + let _ = fs::remove_file(backup); + Ok(()) + } +} + #[cfg(test)] mod tests { use super::*; @@ -132,4 +169,22 @@ mod tests { ); fs::remove_dir_all(root).unwrap(); } + + #[test] + fn replaces_an_existing_file() { + let root = std::env::temp_dir().join(format!( + "shacraft-replacement-test-{}-{}", + std::process::id(), + NEXT_TEMPORARY.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&root).unwrap(); + let target = root.join("old.txt"); + let temporary = root.join("new.part"); + fs::write(&target, b"old").unwrap(); + fs::write(&temporary, b"new").unwrap(); + replace_file(&temporary, &target).unwrap(); + assert_eq!(fs::read(&target).unwrap(), b"new"); + assert!(!temporary.exists()); + fs::remove_dir_all(root).unwrap(); + } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8143097..bffddb0 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ShaCraft Launcher", - "version": "0.1.0", + "version": "0.1.1", "identifier": "ru.shacraft.launcher", "build": { "beforeDevCommand": "npm run dev", @@ -28,6 +28,13 @@ }, "bundle": { "active": true, - "targets": "all" + "targets": "all", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ] } } diff --git a/src/App.tsx b/src/App.tsx index 6b4d191..da97d00 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import { useCallback, useState } from 'react' import { Library } from './components/Library' -import { LoginModal } from './components/LoginModal' +import { RecoveryCodesModal } from './components/RecoveryCodesModal' import { PlayDock } from './components/PlayDock' import { ServerStage } from './components/ServerStage' import { SettingsDrawer } from './components/SettingsDrawer' @@ -8,68 +8,71 @@ import { Titlebar } from './components/Titlebar' import { servers } from './data/servers' import { useAccount } from './hooks/useAccount' import { useLauncher } from './hooks/useLauncher' +import { useServerStatus } from './hooks/useServerStatus' import { useSettings } from './hooks/useSettings' import { isNative } from './services/native' +import { launchAccess } from './state/account' import { installStageLabels } from './state/game' export function App() { const [selected, setSelected] = useState(servers[0]) const [settingsOpen, setSettingsOpen] = useState(false) + const [windowError, setWindowError] = useState(null) const preferences = useSettings() const session = useAccount() const launcher = useLauncher() + const serverStatus = useServerStatus(selected.profileId) const closeSettings = useCallback(() => setSettingsOpen(false), []) const desktop = isNative() - const profile = selected.profileId ? launcher.profiles[selected.profileId] : undefined + const profile = launcher.profiles[selected.profileId] const ready = profile?.inspection?.upToDate === true const operation = launcher.game.operation const busy = operation.phase !== 'idle' - const microsoft = preferences.settings.accountMode === 'microsoft' - const needsLogin = microsoft && session.account === null - const accountLoading = microsoft && session.account === undefined - const checking = desktop && (!profile || profile.status === 'checking') && !selected.disabled - const settingsBlocked = !preferences.loaded || preferences.saving || !!preferences.error || (!microsoft && !!preferences.nicknameError) - const disabled = !desktop || !!selected.disabled || !selected.profileId || busy || checking || settingsBlocked || accountLoading || session.busy || !launcher.eventsReady || (needsLogin && !session.eventsReady) - const repairDisabled = !desktop || !!selected.disabled || !selected.profileId || busy || checking - const error = launcher.game.error ?? preferences.error ?? (!microsoft ? preferences.nicknameError : null) ?? launcher.environmentError ?? (microsoft ? session.error : null) ?? profile?.error ?? null + const access = launchAccess(session.account) + const checking = desktop && (!profile || profile.status === 'checking') + const settingsBlocked = !preferences.loaded || preferences.saving || !!preferences.error + const disabled = !desktop || busy || session.busy || access === 'loading' || + (access === 'ready' && (checking || settingsBlocked || !launcher.eventsReady)) + const repairDisabled = !desktop || busy || checking + const error = launcher.game.error ?? preferences.error ?? windowError ?? launcher.environmentError ?? session.error ?? profile?.error ?? null let label = 'Играть' - if (selected.disabled) label = 'Недоступно' - else if (!desktop) label = 'В приложении' + if (!desktop) label = 'В приложении' else if (operation.phase === 'running') label = 'Игра запущена' else if (operation.phase === 'launching') label = 'Запускаем…' else if (operation.phase === 'installing') label = operation.progress ? `${installStageLabels[operation.progress.stage]}…` : 'Подготовка…' else if (operation.phase === 'syncing') label = 'Обновление' + else if (access === 'loading') label = 'Загрузка…' + else if (access === 'login') label = 'Войти в ShaCraft' + else if (access === 'link') label = 'Привязать ник' else if (preferences.saving) label = 'Сохраняем…' - else if (accountLoading || !preferences.loaded) label = 'Загрузка…' - else if (needsLogin) label = session.busy ? 'Ждём вход…' : 'Войти через Microsoft' + else if (!preferences.loaded) label = 'Загрузка…' else if (checking) label = 'Проверяем…' else if (!ready) label = 'Проверить' const primary = () => { - if (disabled || !selected.profileId) return - if (needsLogin) void session.login() - else if (!ready) void launcher.repair(selected.profileId) + if (disabled) return + if (access !== 'ready') setSettingsOpen(true) else void launcher.launch(selected.profileId) } return (
- -
- setSettingsOpen(true)} onLogout={session.logout} /> - + +
0}> + setSettingsOpen(true)} /> + { if (!repairDisabled && selected.profileId) void launcher.repair(selected.profileId) }} /> + onPrimary={primary} onRepair={() => { if (!repairDisabled) void launcher.repair(selected.profileId) }} />
- - +
) } diff --git a/src/components/AccountSettings.tsx b/src/components/AccountSettings.tsx new file mode 100644 index 0000000..aad6ce9 --- /dev/null +++ b/src/components/AccountSettings.tsx @@ -0,0 +1,62 @@ +import { useState } from 'react' +import { LogOut, Users } from 'lucide-react' +import type { useAccount } from '../hooks/useAccount' +import { isNative } from '../services/native' + +export function AccountSettings({ session, locked }: { session: ReturnType; locked: boolean }) { + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [registering, setRegistering] = useState(false) + const [nickname, setNickname] = useState('') + const { account, linkedNickname, busy } = session + const disabled = locked || busy || account === undefined + + return ( + <> +
Аккаунт{account === undefined ? 'Проверяем…' : account?.username ?? 'Не авторизован'}
+ {!account && ( +
{ + event.preventDefault() + if (disabled) return + if (await session.authenticate(username, password, registering)) setPassword('') + }}> + + + + + {!isNative() &&

Вход и регистрация доступны в приложении лаунчера.

} +
+ )} + {account && !linkedNickname && ( +
{ event.preventDefault(); if (!disabled) void session.startLink(nickname) }}> + + +
+ )} + {account && linkedNickname &&
Игровой ник{linkedNickname}
} + {session.linkMessage &&

{session.linkMessage}

} + {session.error &&

{session.error}

} + {account && } + + ) +} diff --git a/src/components/Library.tsx b/src/components/Library.tsx index 71699bd..5d29bc5 100644 --- a/src/components/Library.tsx +++ b/src/components/Library.tsx @@ -1,60 +1,50 @@ -import { ChevronRight, Library as LibraryIcon, LogOut, MessageCircle, Newspaper, Settings } from 'lucide-react' +import { ChevronRight, Settings } from 'lucide-react' import { servers } from '../data/servers' +import { linkedNickname } from '../state/account' import type { ProfileState } from '../state/profiles' -import type { LauncherSettings, MinecraftProfile, Server } from '../types/launcher' +import type { ShaCraftAccount, Server } from '../types/launcher' interface LibraryProps { selected: Server profiles: Record - settings: LauncherSettings - account: MinecraftProfile | null | undefined + account: ShaCraftAccount | null | undefined locked: boolean native: boolean onSelect: (server: Server) => void onSettings: () => void - onLogout: () => void } -export function Library(props: LibraryProps) { - const { selected, profiles, settings, account, locked, onSelect, onSettings, onLogout } = props - const name = settings.accountMode === 'offline' ? settings.nickname - : account === undefined ? 'Проверяем…' : account?.name ?? 'Не авторизован' +export function Library({ selected, profiles, account, locked, native, onSelect, onSettings }: LibraryProps) { + const nickname = linkedNickname(account) + const name = account === undefined ? 'Проверяем…' : account === null ? 'Не авторизован' : nickname ?? account.username return ( <> -