From b0c2687677c8e7cb65de94fe15a6d42700ba0416 Mon Sep 17 00:00:00 2001 From: Emil Date: Sun, 6 Sep 2026 18:12:22 +0300 Subject: [PATCH 1/9] fix launcher UI and game launch pipeline --- .github/workflows/build.yml | 9 ++ AGENTS.md | 5 +- docs/game-trust-boundary.md | 12 +- docs/launcher-architecture.md | 5 + src-tauri/capabilities/default.json | 8 +- src-tauri/src/launch.rs | 26 +++- src-tauri/src/lib.rs | 30 +++- src-tauri/src/mojang.rs | 22 ++- src-tauri/src/msa.rs | 8 +- src-tauri/src/neoforge.rs | 2 +- src-tauri/src/profile.rs | 26 ++++ src-tauri/src/remote.rs | 30 +++- src/main.tsx | 204 +++++++++++++++++----------- src/styles.css | 8 +- 14 files changed, 287 insertions(+), 108 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5eb8325..74009d4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -34,3 +34,12 @@ jobs: - uses: dtolnay/rust-toolchain@stable - run: npm ci - run: npm run tauri:build -- ${{ matrix.args }} + - name: Upload Windows installers + if: runner.os == 'Windows' + uses: actions/upload-artifact@v4 + with: + name: shacraft-launcher-windows-x64 + if-no-files-found: error + path: | + src-tauri/target/release/bundle/nsis/*.exe + src-tauri/target/release/bundle/msi/*.msi diff --git a/AGENTS.md b/AGENTS.md index e275ad1..371e0b3 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 diff --git a/docs/game-trust-boundary.md b/docs/game-trust-boundary.md index e730490..804adf0 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 diff --git a/docs/launcher-architecture.md b/docs/launcher-architecture.md index b025464..bb85839 100644 --- a/docs/launcher-architecture.md +++ b/docs/launcher-architecture.md @@ -9,6 +9,10 @@ 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. +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 launcher itself. Do not represent these as completed in UI or release notes. @@ -28,6 +32,7 @@ Game itself (never controlled by the manifest above) -> Java 21 via Adoptium if none installed (runtime.rs) -> NeoForge's own installer, run headlessly (neoforge.rs) -> generic inheritsFrom merge of the two version JSONs (mojang.rs) + -> SHA-1-verified merged libraries + platform natives (mojang.rs) -> real Microsoft/Xbox/Minecraft Services login (msa.rs) -> java process spawned with the merged classpath/args (launch.rs) ``` 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/launch.rs b/src-tauri/src/launch.rs index db88ee5..a39a118 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,6 +57,12 @@ 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 @@ -67,6 +73,11 @@ fn build_classpath(game_dir: &Path, merged: &MergedVersion, client_jar: &Path) - .map(|artifact| game_dir.join("libraries").join(&artifact.path)) .collect(); entries.push(client_jar.to_path_buf()); + // 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()) } @@ -134,7 +145,12 @@ pub fn launch(request: &LaunchRequest) -> Result { 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()); @@ -203,4 +219,10 @@ mod tests { assert_eq!(substitute("${auth_player_name}", &vars), "Steve"); 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")]); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4dbf863..8ad6034 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -58,6 +58,13 @@ fn detect_java() -> Option { java::detect() } +/// Whether Microsoft sign-in was configured for this launcher build. +/// The UI uses this to avoid advertising a login flow that cannot start. +#[tauri::command] +fn microsoft_login_available() -> bool { + msa::is_configured() +} + /// Validates an untrusted profile manifest before any file is downloaded. #[tauri::command] fn validate_manifest(manifest_json: String) -> Result<(), String> { @@ -122,6 +129,15 @@ async fn sync_remote_profile(app: AppHandle, profile_id: String) -> Result 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}"))? +} + #[tauri::command] async fn load_settings(app: AppHandle) -> Result { let data_dir = app @@ -311,12 +327,12 @@ async fn ensure_game_installed(app: AppHandle, profile_id: String) -> Result<(), let java_install = java::ensure_java(&client, &runtime_root, manifest.minecraft.java_major, &stage_progress("java")).map_err(|error| error.to_string())?; let merged = resolve_merged_version(&client, &manifest, Path::new(&java_install.executable), &game_dir, &cache_dir, &stage_progress("neoforge"))?; - if manifest.minecraft.loader.kind != "neoforge" { - // Vanilla-only profiles skip the installer, which normally - // downloads vanilla itself; do it ourselves here instead. - mojang::ensure_client_jar(&client, &game_dir, &merged.client_jar_version_id, &merged.client).map_err(|error| error.to_string())?; - mojang::ensure_libraries(&client, &game_dir, &merged.libraries, &stage_progress("libraries")).map_err(|error| error.to_string())?; - }; + // The NeoForge installer creates the loader profile and patched + // client, but it does not guarantee that every vanilla runtime + // library (notably LWJGL and its platform natives) is present. + // Verify the complete merged launch set for every loader kind. + mojang::ensure_client_jar(&client, &game_dir, &merged.client_jar_version_id, &merged.client).map_err(|error| error.to_string())?; + mojang::ensure_libraries(&client, &game_dir, &merged.libraries, &stage_progress("libraries")).map_err(|error| error.to_string())?; let asset_index = mojang::ensure_asset_index(&client, &game_dir, &merged.asset_index).map_err(|error| error.to_string())?; mojang::ensure_assets(&client, &game_dir, &asset_index, &stage_progress("assets")).map_err(|error| error.to_string())?; @@ -391,11 +407,13 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ native_host, detect_java, + microsoft_login_available, validate_manifest, inspect_profile, sync_profile, inspect_remote_profile, sync_remote_profile, + get_server_status, load_settings, save_settings, start_microsoft_login, diff --git a/src-tauri/src/mojang.rs b/src-tauri/src/mojang.rs index ed81f44..497bb6c 100644 --- a/src-tauri/src/mojang.rs +++ b/src-tauri/src/mojang.rs @@ -44,6 +44,13 @@ pub fn is_allowed_host(url: &str) -> bool { Url::parse(url).ok().and_then(|parsed| parsed.host_str().map(|host| MOJANG_HOSTS.contains(&host))).unwrap_or(false) } +/// 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), @@ -430,7 +437,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) } @@ -473,7 +480,7 @@ pub fn ensure_assets(client: &Client, game_dir: &Path, index: &AssetIndex, on_pr } }) .collect(); - download_many(client, tasks, on_progress) + download_many(client, tasks, on_progress, is_allowed_host) } struct DownloadTask { @@ -509,7 +516,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(()); @@ -529,7 +541,7 @@ fn download_many(client: &Client, tasks: Vec, on_progress: &Progre break; } let Some(task) = queue.lock().unwrap().pop() else { break }; - if !is_allowed_host(&task.url) { + if !is_allowed(&task.url) { *first_error.lock().unwrap() = Some(MojangError::DisallowedHost(task.url)); continue; } @@ -648,6 +660,8 @@ mod tests { 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_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 diff --git a/src-tauri/src/msa.rs b/src-tauri/src/msa.rs index 75a5878..e215657 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" } @@ -106,7 +106,7 @@ 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 @@ -187,7 +187,7 @@ pub fn poll_device_code(client: &Client, start: &DeviceCodeStart) -> Result Result { - if !client_id_is_configured() { + if !is_configured() { return Err(MsaError::NotConfigured); } let response = client @@ -457,7 +457,7 @@ mod tests { #[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))); } diff --git a/src-tauri/src/neoforge.rs b/src-tauri/src/neoforge.rs index 32d5e02..a66bac6 100644 --- a/src-tauri/src/neoforge.rs +++ b/src-tauri/src/neoforge.rs @@ -88,7 +88,7 @@ impl From for NeoForgeError { } } -fn is_allowed_host(url: &str) -> bool { +pub(crate) fn is_allowed_host(url: &str) -> bool { Url::parse(url).ok().and_then(|parsed| parsed.host_str().map(|host| host == NEOFORGE_HOST)).unwrap_or(false) } diff --git a/src-tauri/src/profile.rs b/src-tauri/src/profile.rs index 23ea3ad..6e4c532 100644 --- a/src-tauri/src/profile.rs +++ b/src-tauri/src/profile.rs @@ -50,6 +50,12 @@ pub fn inspect(root: &Path, manifest: &Manifest) -> Result, + pub max: Option, + pub reachable: bool, +} + #[derive(Debug)] pub enum RemoteError { UnknownProfile, @@ -74,3 +85,20 @@ pub fn fetch_manifest(profile_id: &str) -> Result { let payload = String::from_utf8(payload).map_err(|_| RemoteError::InvalidSignature)?; manifest::validate_json(&payload).map_err(RemoteError::InvalidManifest) } + +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() + .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) +} diff --git a/src/main.tsx b/src/main.tsx index 1b4e896..d4df264 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,17 +2,15 @@ import React, { useEffect, useState } from 'react' import { createRoot } from 'react-dom/client' import { invoke } from '@tauri-apps/api/core' import { listen } from '@tauri-apps/api/event' +import { getCurrentWindow } from '@tauri-apps/api/window' import { ChevronRight, Download, FolderOpen, Gauge, Globe2, - Library, LogOut, - MessageCircle, Minus, - Newspaper, Play, RotateCcw, Settings, @@ -30,12 +28,8 @@ type Server = { kicker: string name: string subtitle: string - players: string version: string - memory: string - installed: boolean - profileId?: string - disabled?: boolean + profileId: string } type NativeHost = { @@ -86,6 +80,17 @@ type LoginResultPayload = { error?: string } +type ServerStatus = { + online: number | null + max: number | null + reachable: boolean +} + +type GameExitedPayload = { + profileId: string + exitCode: number | null +} + type InstallProgressPayload = { stage: 'java' | 'neoforge' | 'libraries' | 'assets' currentBytes: number @@ -105,29 +110,21 @@ const servers: Server[] = [ kicker: 'Основная сборка', name: 'Aeronautics', subtitle: 'Строй корабли. Поднимай города в небо.', - players: '7 / 20', - version: '1.21.1 · NeoForge', - memory: '6 ГБ', - installed: true, + version: '1.21.1 · NeoForge 21.1.248', profileId: 'aeronautics', }, - { - id: 'create', - kicker: 'На техобслуживании', - name: 'Create', - subtitle: 'Механизмы, фабрики и большие идеи.', - players: 'Сервер остановлен', - version: '1.21.1 · NeoForge', - memory: '4 ГБ', - installed: false, - disabled: true, - }, ] function isTauri() { return '__TAURI_INTERNALS__' in window } +function errorMessage(error: unknown, fallback: string) { + if (typeof error === 'string' && error.trim()) return error + if (error instanceof Error && error.message) return error.message + return fallback +} + function App() { const [selected, setSelected] = useState(servers[0]) const [progress, setProgress] = useState(null) @@ -148,8 +145,11 @@ function App() { const [loginError, setLoginError] = useState(null) const [loggingIn, setLoggingIn] = useState(false) const [installing, setInstalling] = useState(false) + const [gameRunning, setGameRunning] = useState(false) const [installProgress, setInstallProgress] = useState(null) const [launchError, setLaunchError] = useState(null) + const [serverStatus, setServerStatus] = useState(null) + const [microsoftLoginAvailable, setMicrosoftLoginAvailable] = useState(false) useEffect(() => { if (progress === null) return @@ -177,6 +177,9 @@ function App() { invoke('detect_java') .then(setJava) .catch(() => setJava(null)) + invoke('microsoft_login_available') + .then(setMicrosoftLoginAvailable) + .catch(() => setMicrosoftLoginAvailable(false)) invoke('inspect_remote_profile', { profileId: 'aeronautics' }) .then((inspection) => { setProfile(inspection) @@ -188,6 +191,26 @@ function App() { .catch(() => setAccount(null)) }, []) + useEffect(() => { + if (!isTauri()) return + let disposed = false + const refresh = () => { + invoke('get_server_status', { profileId: selected.profileId }) + .then((status) => { + if (!disposed) setServerStatus(status) + }) + .catch(() => { + if (!disposed) setServerStatus({ online: null, max: null, reachable: false }) + }) + } + refresh() + const timer = window.setInterval(refresh, 30_000) + return () => { + disposed = true + window.clearInterval(timer) + } + }, [selected.profileId]) + useEffect(() => { if (!isTauri()) return const unlisten = [ @@ -203,7 +226,15 @@ function App() { } }), listen('game-install-progress', (event) => setInstallProgress(event.payload)), - listen('game-exited', () => setInstalling(false)), + listen('game-exited', (event) => { + setInstalling(false) + setGameRunning(false) + setInstallProgress(null) + if (event.payload.exitCode !== 0) { + const suffix = event.payload.exitCode === null ? '' : ` (код ${event.payload.exitCode})` + setLaunchError(`Игра завершилась с ошибкой${suffix}. Подробности сохранены в журнале лаунчера.`) + } + }), ] return () => { unlisten.forEach((promise) => promise.then((off) => off())) @@ -233,8 +264,7 @@ function App() { } const repair = async () => { - if (selected.disabled) return - if (isTauri() && selected.profileId) { + if (isTauri()) { setSyncError(null) setSyncing(true) setReady(false) @@ -243,7 +273,7 @@ function App() { setProfile({ managedFiles: result.downloadedFiles + result.reusedFiles, missingFiles: 0, mismatchedFiles: 0, upToDate: true }) setReady(true) } catch (error) { - setSyncError(error instanceof Error ? error.message : 'Не удалось синхронизировать сборку') + setSyncError(errorMessage(error, 'Не удалось синхронизировать сборку')) } finally { setSyncing(false) } @@ -254,14 +284,17 @@ function App() { } const startLogin = async () => { - if (!isTauri()) return + if (!isTauri() || !microsoftLoginAvailable) { + setLoginError('Вход через Microsoft пока не настроен для этой версии лаунчера') + return + } setLoginError(null) setLoggingIn(true) try { await invoke('start_microsoft_login') } catch (error) { setLoggingIn(false) - setLoginError(error instanceof Error ? error.message : 'Не удалось начать вход через Microsoft') + setLoginError(errorMessage(error, 'Не удалось начать вход через Microsoft')) } } @@ -272,7 +305,11 @@ function App() { } const playOrLogin = async () => { - if (selected.disabled || !isTauri() || !selected.profileId) return + if (!isTauri()) return + if (accountMode === 'microsoft' && !microsoftLoginAvailable) { + setLaunchError('Вход Microsoft пока недоступен. Выберите Offline-аккаунт в настройках.') + return + } // In offline mode we can launch without any Microsoft session. In // Microsoft mode a signed-in account is still required first. if (accountMode === 'microsoft' && (account === null || account === undefined)) { @@ -280,51 +317,68 @@ function App() { return } setLaunchError(null) - setInstalling(true) + setSyncError(null) + setSyncing(true) setInstallProgress(null) try { + // A launch must always reconcile the signed ShaCraft profile first. + // Installing Minecraft/NeoForge alone produces a valid but unmodded + // game, so profile sync is deliberately part of the Play path. + const syncResult = await invoke('sync_remote_profile', { profileId: selected.profileId }) + setProfile({ managedFiles: syncResult.downloadedFiles + syncResult.reusedFiles, missingFiles: 0, mismatchedFiles: 0, upToDate: true }) + setReady(true) + setSyncing(false) + setInstalling(true) await invoke('ensure_game_installed', { profileId: selected.profileId }) + setInstallProgress(null) + setInstalling(false) + setGameRunning(true) await invoke('launch_game', { profileId: selected.profileId }) } catch (error) { - setLaunchError(error instanceof Error ? error.message : 'Не удалось запустить игру') + setLaunchError(errorMessage(error, 'Не удалось запустить игру')) + setSyncing(false) setInstalling(false) + setGameRunning(false) } } const playLabel = () => { - if (selected.disabled) return 'Недоступно' + if (accountMode === 'microsoft' && !microsoftLoginAvailable) return 'Microsoft недоступен' if (accountMode === 'microsoft' && account === undefined) return 'Загрузка…' if (accountMode === 'microsoft' && account === null) return loggingIn ? 'Ждём вход…' : 'Войти через Microsoft' + if (gameRunning) return 'Игра запущена' if (installing) return installProgress ? `${INSTALL_STAGE_LABEL[installProgress.stage]}…` : 'Подготовка…' if (syncing || progress !== null) return 'Обновление' return ready ? 'Играть' : 'Проверить' } const installPercent = installProgress && installProgress.totalBytes > 0 ? Math.min(100, Math.round((installProgress.currentBytes / installProgress.totalBytes) * 100)) : null + const onlineLabel = serverStatus?.reachable && serverStatus.online !== null && serverStatus.max !== null + ? `${serverStatus.online} / ${serverStatus.max}` + : serverStatus === null ? 'Проверяем…' : 'Нет связи' + + const minimizeWindow = () => { if (isTauri()) void getCurrentWindow().minimize() } + const toggleMaximizeWindow = () => { if (isTauri()) void getCurrentWindow().toggleMaximize() } + const closeWindow = () => { if (isTauri()) void getCurrentWindow().close() } return (
-
-
- - ShaCraft +
+
+ + ShaCraft
-
{nativeHost ? `Лаунчер · ${nativeHost.platform}` : 'Лаунчер'}
+
{nativeHost ? `Лаунчер · ${nativeHost.platform}` : 'Лаунчер'}
- - - + + +
- @@ -341,7 +395,7 @@ function App() { className={`server-row ${selected.id === server.id ? 'selected' : ''}`} onClick={() => { setSelected(server) - setReady(server.installed) + setReady(profile?.upToDate ?? false) setProgress(null) }} > @@ -350,51 +404,49 @@ function App() { {server.name} - {server.disabled ? 'На паузе' : 'Установлена'} + {profile?.upToDate ? 'Файлы проверены' : 'Требуется проверка'} ))}
-
+ - ) : ( - - )} -
+ +
-
- {selected.disabled ? 'Не в сети' : 'Сервер работает'} +
+ {serverStatus === null ? 'Проверяем сервер' : serverStatus.reachable ? 'Сервер доступен' : 'Сервер недоступен'}
-
{selected.players}
+
{onlineLabel}
-

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

+

{selected.kicker}

{selected.name}

{selected.subtitle}

-
Состав
{selected.id === 'aoc' ? '250 модов' : '41 мод'}
-
Загрузчик
{selected.id === 'aoc' ? 'NeoForge 21.1.248' : 'NeoForge 21.1.249'}
+
Загрузчик
NeoForge 21.1.248
Java
Версия 21
- {installing ? ( + {gameRunning ? ( + <> + + Игра запущенаЛаунчер готов к работе после выхода + + ) : installing ? ( <> @@ -415,17 +467,12 @@ function App() { Файлы и обновления · {progress}% - ) : selected.disabled ? ( - <> - - ТехобслуживаниеСообщим, когда сервер вернётся - ) : ( <> - {accountMode === 'microsoft' && account === null ? 'Нужен вход' : ready ? 'Сборка готова' : 'Требуется проверка'} - {launchError || syncError || loginError || (profile ? `${profile.managedFiles} файлов под контролем` : 'Проверяем локальные файлы')} + {accountMode === 'microsoft' && !microsoftLoginAvailable ? 'Microsoft пока недоступен' : accountMode === 'microsoft' && account === null ? 'Нужен вход' : ready ? 'Файлы сборки готовы' : 'Требуется проверка'} + {launchError || syncError || loginError || (profile ? `${profile.managedFiles} файлов сборки` : 'Проверяем локальные файлы')} )} @@ -438,12 +485,12 @@ function App() { {ram} ГБ памяти
-
{accountMode === 'microsoft' && account && ( @@ -501,7 +548,7 @@ function App() { Выйти из Microsoft )} - {accountMode === 'microsoft' && !account && ( + {accountMode === 'microsoft' && !account && microsoftLoginAvailable && ( @@ -522,7 +569,6 @@ function App() { : 'Лаунчер установит Java 21 автоматически'}
-
{nativeHost ? `Данные лаунчера: ${nativeHost.dataDir}` : 'Java 21 будет управляться лаунчером автоматически.'}
diff --git a/src/styles.css b/src/styles.css index 16785de..09c614b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -48,8 +48,7 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--green); out .window-actions .close:hover { background: #a93939; } .workspace { height: calc(100vh - 48px); display: grid; grid-template-columns: 64px 250px 1fr; } -.rail { background: #0c100d; border-right: 1px solid var(--line); padding: 18px 10px 14px; display: flex; flex-direction: column; justify-content: space-between; } -.rail-main { display: grid; gap: 8px; } +.rail { background: #0c100d; border-right: 1px solid var(--line); padding: 18px 10px 14px; display: flex; flex-direction: column; justify-content: flex-end; } .rail-button { border: 0; width: 44px; height: 44px; border-radius: 5px; display: grid; place-items: center; background: transparent; color: #69716a; cursor: pointer; } .rail-button svg { width: 20px; } .rail-button:hover { color: #cfd6cf; background: #171c18; } @@ -71,7 +70,8 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--green); out .server-copy strong { font-size: 13px; font-weight: 700; } .server-copy small { font-size: 10px; color: var(--green); margin-top: 2px; } .server-row:not(.selected) .server-copy small { color: #707871; } -.account-chip { margin-top: auto; padding: 10px; border-top: 1px solid var(--line); display: grid; grid-template-columns: 34px 1fr 16px; gap: 9px; align-items: center; } +.account-chip { width: 100%; margin-top: auto; padding: 10px; border: 0; border-top: 1px solid var(--line); background: transparent; color: inherit; display: grid; grid-template-columns: 34px 1fr 16px; gap: 9px; align-items: center; text-align: left; cursor: pointer; } +.account-chip:hover { background: #171c18; } .account-chip .avatar { width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center; background: #243127; color: #bfe1c1; font-size: 11px; font-weight: 800; } .account-chip > span:nth-child(2) { display: grid; } .account-chip strong { font-size: 12px; } @@ -85,7 +85,6 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--green); out linear-gradient(112deg, #0b130e 0%, #11291d 39%, #28301f 66%, #512713 100%); } .stage::after { content: ''; position: absolute; inset: 0; z-index: -1; pointer-events: none; background: linear-gradient(180deg, transparent 45%, rgba(5,8,6,.9) 100%); } -.stage-create::before { filter: grayscale(.55); background: linear-gradient(110deg, #152019, #27372b 55%, #151a16); } .stage-top { display: flex; justify-content: flex-end; align-items: center; gap: 12px; padding: 22px 26px; } .live-pill, .players { height: 28px; padding: 0 0 0 12px; border: 0; border-left: 1px solid rgba(255,255,255,.16); border-radius: 0; background: transparent; display: flex; align-items: center; gap: 7px; font-size: 11px; color: #c0c8c0; } .live-pill span { width: 7px; height: 7px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 4px rgba(119,209,122,.1), 0 0 12px rgba(119,209,122,.55); } @@ -96,7 +95,6 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--green); out .hero-copy p::before { content: ''; width: 26px; height: 2px; background: var(--copper); } .hero-copy h1 { font-family: 'Unbounded', sans-serif; font-size: clamp(40px, 5vw, 66px); line-height: 1; letter-spacing: -.045em; margin: 0; text-shadow: 0 8px 36px rgba(0,0,0,.3); } .hero-copy h2 { max-width: 440px; font-size: 15px; line-height: 1.5; font-weight: 500; color: #aeb7af; margin: 16px 0 0; } -.stage-create .hero-copy p { color: #9aa49c; } .hero-meta { display: flex; gap: 0; margin: 36px 0 0; padding: 0; } .hero-meta div { min-width: 130px; padding: 0 26px; border-left: 1px solid rgba(235,243,234,.14); } .hero-meta div:first-child { padding-left: 0; border-left: 0; } From 58c6a3168d7600130da752fb49044b1c92f8d45c Mon Sep 17 00:00:00 2001 From: Emil Date: Sun, 6 Sep 2026 18:14:14 +0300 Subject: [PATCH 2/9] ci: run release builds on main --- .github/workflows/build.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 74009d4..9f26291 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] jobs: build: From 9fd7ce377a55234bd0ddafafa063f62515bd2a05 Mon Sep 17 00:00:00 2001 From: Emil Date: Sun, 6 Sep 2026 18:28:13 +0300 Subject: [PATCH 3/9] fix windows installer icon configuration --- src-tauri/tauri.conf.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8143097..a5ead3b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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" + ] } } From e8ce211893b581e6a3f8249466d167dd3be9ad6b Mon Sep 17 00:00:00 2001 From: Emil Date: Sun, 6 Sep 2026 23:04:00 +0300 Subject: [PATCH 4/9] ci: build and upload all desktop packages --- .github/workflows/build.yml | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9f26291..d3cbb4d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,7 +22,7 @@ jobs: os: macos-14 args: --target aarch64-apple-darwin --bundles dmg - name: macOS Intel - os: macos-13 + os: macos-15-intel args: --target x86_64-apple-darwin --bundles dmg runs-on: ${{ matrix.os }} @@ -34,6 +34,11 @@ jobs: node-version: 22 cache: npm - uses: dtolnay/rust-toolchain@stable + - name: Install Linux system dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf - run: npm ci - run: npm run tauri:build -- ${{ matrix.args }} - name: Upload Windows installers @@ -45,3 +50,19 @@ jobs: path: | 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 From 6311a7c502c9dfaae694ae7360423ec435805cd4 Mon Sep 17 00:00:00 2001 From: Emil Date: Sun, 6 Sep 2026 23:38:08 +0300 Subject: [PATCH 5/9] docs: license project under MIT --- LICENSE | 21 +++++++++++++++++++++ package.json | 1 + src-tauri/Cargo.toml | 1 + 3 files changed, 23 insertions(+) create mode 100644 LICENSE 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/package.json b/package.json index a6c02a2..25b17bf 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "name": "shacraft-launcher-ui", + "license": "MIT", "private": true, "version": "0.1.0", "type": "module", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5320467..4a8d851 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -3,6 +3,7 @@ name = "shacraft-launcher" version = "0.1.0" description = "ShaCraft Minecraft launcher" authors = ["ShaCraft"] +license = "MIT" edition = "2021" [lib] From 513be6274fd79510969302120669b4ce5f09ac5d Mon Sep 17 00:00:00 2001 From: Emil Date: Mon, 7 Sep 2026 14:26:37 +0300 Subject: [PATCH 6/9] feat: require ShaCraft account for launcher identity --- AGENTS.md | 18 +-- docs/launcher-architecture.md | 19 ++- src-tauri/src/lib.rs | 71 +++++++--- src-tauri/src/shacraft_account.rs | 214 ++++++++++++++++++++++++++++++ src/main.tsx | 214 +++++++++++++++++------------- 5 files changed, 410 insertions(+), 126 deletions(-) create mode 100644 src-tauri/src/shacraft_account.rs diff --git a/AGENTS.md b/AGENTS.md index 371e0b3..31fe003 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,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 @@ -66,6 +64,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. diff --git a/docs/launcher-architecture.md b/docs/launcher-architecture.md index bb85839..ddfe8ed 100644 --- a/docs/launcher-architecture.md +++ b/docs/launcher-architecture.md @@ -4,10 +4,10 @@ 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: @@ -33,7 +33,8 @@ Game itself (never controlled by the manifest above) -> NeoForge's own installer, run headlessly (neoforge.rs) -> generic inheritsFrom merge of the two version JSONs (mojang.rs) -> SHA-1-verified merged libraries + platform natives (mojang.rs) - -> real Microsoft/Xbox/Minecraft Services login (msa.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) ``` @@ -42,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 @@ -57,6 +59,9 @@ 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 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8ad6034..c590295 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ mod profile; mod remote; mod runtime; mod session; +mod shacraft_account; mod settings; use reqwest::blocking::Client; @@ -162,6 +163,48 @@ async fn save_settings(app: AppHandle, settings: settings::LauncherSettings) -> .map_err(|error| error.to_string()) } +#[tauri::command] +async fn shacraft_authenticate(app: AppHandle, username: String, password: String, register: bool) -> Result { + let data_dir = app.path().app_data_dir().map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?; + tauri::async_runtime::spawn_blocking(move || shacraft_account::authenticate(&data_dir, &username, &password, register)) + .await.map_err(|error| format!("Account task failed: {error}"))? + .map_err(|error| error.to_string()) +} + +#[tauri::command] +async fn get_shacraft_account(app: AppHandle) -> Result, String> { + let data_dir = app.path().app_data_dir().map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?; + tauri::async_runtime::spawn_blocking(move || match shacraft_account::get_account(&data_dir) { + Ok(account) => Ok(Some(account)), + Err(shacraft_account::AccountError::InvalidSession) => Ok(None), + Err(error) => Err(error.to_string()), + }).await.map_err(|error| format!("Account task failed: {error}"))? +} + +#[tauri::command] +async fn shacraft_logout(app: AppHandle) -> Result<(), String> { + let data_dir = app.path().app_data_dir().map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?; + tauri::async_runtime::spawn_blocking(move || shacraft_account::logout(&data_dir)) + .await.map_err(|error| format!("Account task failed: {error}"))? + .map_err(|error| error.to_string()) +} + +#[tauri::command] +async fn shacraft_start_link(app: AppHandle, nickname: String) -> Result { + let data_dir = app.path().app_data_dir().map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?; + tauri::async_runtime::spawn_blocking(move || shacraft_account::start_link(&data_dir, "aoc", &nickname)) + .await.map_err(|error| format!("Link task failed: {error}"))? + .map_err(|error| error.to_string()) +} + +#[tauri::command] +async fn shacraft_link_status(app: AppHandle, challenge_id: i64) -> Result { + let data_dir = app.path().app_data_dir().map_err(|error| format!("Cannot resolve launcher data directory: {error}"))?; + tauri::async_runtime::spawn_blocking(move || shacraft_account::link_status(&data_dir, challenge_id)) + .await.map_err(|error| format!("Link task failed: {error}"))? + .map_err(|error| error.to_string()) +} + // --------------------------------------------------------------------- // Microsoft account login // --------------------------------------------------------------------- @@ -248,23 +291,12 @@ async fn logout(app: AppHandle) -> Result<(), String> { .map_err(|error| error.to_string()) } -/// Resolves the identity to launch as, based on the persisted `account_mode`. -/// In `Microsoft` mode this requires a real signed-in session (see -/// `msa::login_with_refresh_token`) and returns an error if there is none; -/// in `Offline` mode it uses the local nickname from settings, so no -/// Microsoft account is needed at all. Offline is never silently used in -/// place of a missing Microsoft session. -fn resolve_identity(client: &Client, data_dir: &Path) -> Result { - let settings = settings::load(data_dir).map_err(|error| error.to_string())?; - match settings.account_mode { - settings::AccountMode::Offline => Ok(session::PlayerIdentity::Offline { name: settings.nickname }), - settings::AccountMode::Microsoft => { - let refresh_token = msa::load_refresh_token(data_dir).ok_or("Not signed in with a Microsoft account")?; - let result = msa::login_with_refresh_token(client, &refresh_token).map_err(|error| error.to_string())?; - let _ = msa::save_refresh_token(data_dir, &result.refresh_token); - Ok(session::PlayerIdentity::Microsoft(result)) - } - } +/// Resolves the identity from the server-side ShaCraft account link. Local +/// settings are deliberately not trusted for a nickname, so editing an old +/// settings file cannot change the identity used by this launcher. +fn resolve_identity(_client: &Client, data_dir: &Path) -> Result { + let name = shacraft_account::aeronautics_nickname(data_dir).map_err(|error| error.to_string())?; + Ok(session::PlayerIdentity::Offline { name }) } // --------------------------------------------------------------------- @@ -416,6 +448,11 @@ pub fn run() { get_server_status, load_settings, save_settings, + shacraft_authenticate, + get_shacraft_account, + shacraft_logout, + shacraft_start_link, + shacraft_link_status, start_microsoft_login, get_account, logout, diff --git a/src-tauri/src/shacraft_account.rs b/src-tauri/src/shacraft_account.rs new file mode 100644 index 0000000..558e9e7 --- /dev/null +++ b/src-tauri/src/shacraft_account.rs @@ -0,0 +1,214 @@ +//! 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() + .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); + let temporary = data_dir.join(".shacraft-session.part"); + fs::write(&temporary, token.as_bytes()).map_err(AccountError::Io)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600)).map_err(AccountError::Io)?; + } + fs::rename(temporary, path).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/main.tsx b/src/main.tsx index d4df264..8ec1efc 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -63,21 +63,14 @@ type SyncResult = { downloadedBytes: number } -type MinecraftProfile = { - id: string - name: string +type ShaCraftAccount = { + username: string + links: { server_id: string; mc_username: string }[] } -type DeviceCodePayload = { - verificationUri: string - userCode: string - expiresInSeconds: number -} - -type LoginResultPayload = { - ok: boolean - profile?: MinecraftProfile - error?: string +type ShaCraftLoginResult = { + account: ShaCraftAccount + recoveryCodes: string[] } type ServerStatus = { @@ -140,8 +133,13 @@ function App() { const [syncError, setSyncError] = useState(null) // undefined = still checking for a saved session; null = signed out. - const [account, setAccount] = useState(undefined) - const [loginCode, setLoginCode] = useState(null) + const [account, setAccount] = useState(undefined) + const [accountUsername, setAccountUsername] = useState('') + const [accountPassword, setAccountPassword] = useState('') + const [registering, setRegistering] = useState(false) + const [recoveryCodes, setRecoveryCodes] = useState([]) + const [linkNickname, setLinkNickname] = useState('') + const [linkMessage, setLinkMessage] = useState(null) const [loginError, setLoginError] = useState(null) const [loggingIn, setLoggingIn] = useState(false) const [installing, setInstalling] = useState(false) @@ -149,7 +147,7 @@ function App() { const [installProgress, setInstallProgress] = useState(null) const [launchError, setLaunchError] = useState(null) const [serverStatus, setServerStatus] = useState(null) - const [microsoftLoginAvailable, setMicrosoftLoginAvailable] = useState(false) + const linkedNickname = account?.links.find((link) => link.server_id === 'aoc')?.mc_username useEffect(() => { if (progress === null) return @@ -177,16 +175,13 @@ function App() { invoke('detect_java') .then(setJava) .catch(() => setJava(null)) - invoke('microsoft_login_available') - .then(setMicrosoftLoginAvailable) - .catch(() => setMicrosoftLoginAvailable(false)) invoke('inspect_remote_profile', { profileId: 'aeronautics' }) .then((inspection) => { setProfile(inspection) setReady(inspection.upToDate) }) .catch(() => undefined) - invoke('get_account') + invoke('get_shacraft_account') .then(setAccount) .catch(() => setAccount(null)) }, []) @@ -214,17 +209,6 @@ function App() { useEffect(() => { if (!isTauri()) return const unlisten = [ - listen('msa-login-code', (event) => setLoginCode(event.payload)), - listen('msa-login-result', (event) => { - setLoggingIn(false) - setLoginCode(null) - if (event.payload.ok && event.payload.profile) { - setAccount(event.payload.profile) - setLoginError(null) - } else { - setLoginError(event.payload.error ?? 'Не удалось войти через Microsoft') - } - }), listen('game-install-progress', (event) => setInstallProgress(event.payload)), listen('game-exited', (event) => { setInstalling(false) @@ -252,17 +236,6 @@ function App() { saveSettings(memoryGb) } - const saveNickname = () => { - if (/^[A-Za-z0-9_]{3,16}$/.test(nickname)) { - saveSettings(ram, nickname) - } - } - - const setMode = (mode: 'microsoft' | 'offline') => { - setAccountMode(mode) - saveSettings(ram, nickname, mode) - } - const repair = async () => { if (isTauri()) { setSyncError(null) @@ -284,36 +257,79 @@ function App() { } const startLogin = async () => { - if (!isTauri() || !microsoftLoginAvailable) { - setLoginError('Вход через Microsoft пока не настроен для этой версии лаунчера') + if (!isTauri()) return + setLoginError(null) + if (!/^[A-Za-z0-9_]{3,32}$/.test(accountUsername) || accountPassword.length < 8) { + setLoginError('Логин: 3–32 символа; пароль: минимум 8 символов') return } - setLoginError(null) setLoggingIn(true) try { - await invoke('start_microsoft_login') + const result = await invoke('shacraft_authenticate', { + username: accountUsername, + password: accountPassword, + register: registering, + }) + setAccount(result.account) + setAccountPassword('') + setRecoveryCodes(result.recoveryCodes) + setLoginError(null) } catch (error) { + setLoginError(errorMessage(error, registering ? 'Не удалось зарегистрироваться' : 'Не удалось войти')) + } finally { setLoggingIn(false) - setLoginError(errorMessage(error, 'Не удалось начать вход через Microsoft')) } } const logout = async () => { if (!isTauri()) return - await invoke('logout').catch(() => undefined) + await invoke('shacraft_logout').catch(() => undefined) setAccount(null) } + const startNicknameLink = async () => { + if (!/^[A-Za-z0-9_]{3,16}$/.test(linkNickname)) { + setLinkMessage('Ник: 3–16 латинских букв, цифр или _') + return + } + setLinkMessage('Создаём проверку…') + try { + const started = await invoke<{ challenge_id: number; registered_on_server: boolean }>('shacraft_start_link', { nickname: linkNickname }) + setLinkMessage(started.registered_on_server + ? 'Зайдите на Aeronautics с этим ником и выполните /login.' + : 'Зайдите на Aeronautics с этим ником и выполните /register.') + const timer = window.setInterval(async () => { + try { + const result = await invoke<{ status: string; detail?: string }>('shacraft_link_status', { challengeId: started.challenge_id }) + if (result.status === 'verified') { + window.clearInterval(timer) + const refreshed = await invoke('get_shacraft_account') + setAccount(refreshed) + setLinkMessage('Ник подтверждён.') + } else if (result.status === 'expired' || result.status === 'conflict') { + window.clearInterval(timer) + setLinkMessage(result.detail ?? 'Проверка завершилась. Попробуйте ещё раз.') + } + } catch (error) { + window.clearInterval(timer) + setLinkMessage(errorMessage(error, 'Не удалось проверить ник')) + } + }, 3000) + } catch (error) { + setLinkMessage(errorMessage(error, 'Не удалось начать привязку')) + } + } + const playOrLogin = async () => { if (!isTauri()) return - if (accountMode === 'microsoft' && !microsoftLoginAvailable) { - setLaunchError('Вход Microsoft пока недоступен. Выберите Offline-аккаунт в настройках.') + if (!account) { + setSettingsOpen(true) + setLaunchError('Войдите в аккаунт ShaCraft, чтобы играть.') return } - // In offline mode we can launch without any Microsoft session. In - // Microsoft mode a signed-in account is still required first. - if (accountMode === 'microsoft' && (account === null || account === undefined)) { - await startLogin() + if (!linkedNickname) { + setSettingsOpen(true) + setLaunchError('Привяжите игровой ник к Aeronautics, чтобы играть.') return } setLaunchError(null) @@ -343,9 +359,9 @@ function App() { } const playLabel = () => { - if (accountMode === 'microsoft' && !microsoftLoginAvailable) return 'Microsoft недоступен' - if (accountMode === 'microsoft' && account === undefined) return 'Загрузка…' - if (accountMode === 'microsoft' && account === null) return loggingIn ? 'Ждём вход…' : 'Войти через Microsoft' + if (account === undefined) return 'Загрузка…' + if (account === null) return 'Войти в ShaCraft' + if (!linkedNickname) return 'Привязать ник' if (gameRunning) return 'Игра запущена' if (installing) return installProgress ? `${INSTALL_STAGE_LABEL[installProgress.stage]}…` : 'Подготовка…' if (syncing || progress !== null) return 'Обновление' @@ -412,10 +428,10 @@ function App() {
@@ -471,7 +487,7 @@ function App() { <> - {accountMode === 'microsoft' && !microsoftLoginAvailable ? 'Microsoft пока недоступен' : accountMode === 'microsoft' && account === null ? 'Нужен вход' : ready ? 'Файлы сборки готовы' : 'Требуется проверка'} + {account === null ? 'Нужен вход ShaCraft' : !linkedNickname ? 'Нужно привязать ник' : ready ? 'Файлы сборки готовы' : 'Требуется проверка'} {launchError || syncError || loginError || (profile ? `${profile.managedFiles} файлов сборки` : 'Проверяем локальные файлы')} @@ -490,7 +506,7 @@ function App() { )} @@ -523,34 +539,46 @@ function App() {
Аккаунт - {accountMode === 'offline' ? 'Offline' : (microsoftLoginAvailable ? (account ? account.name : 'Не авторизован') : 'Временно недоступен')} + {account ? account.username : 'Не авторизован'}
- {accountMode === 'offline' && ( - + {!account && ( + <> + + + {loginError &&
{loginError}
} + + + )} -
- Тип аккаунта - -
- {accountMode === 'microsoft' && account && ( + {account && !linkedNickname && ( + <> + + + {linkMessage &&
{linkMessage}
} + + )} + {account && linkedNickname && ( +
+ Игровой ник{linkedNickname} +
+ )} + {account && ( - )} - {accountMode === 'microsoft' && !account && microsoftLoginAvailable && ( - )}
From 5fa58b00130fe798e3c5e8708d151a08fa161093 Mon Sep 17 00:00:00 2001 From: Emil Date: Mon, 7 Sep 2026 16:23:27 +0300 Subject: [PATCH 7/9] chore: allow three-character passwords --- src/main.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main.tsx b/src/main.tsx index 8ec1efc..e32fea2 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -259,8 +259,8 @@ function App() { const startLogin = async () => { if (!isTauri()) return setLoginError(null) - if (!/^[A-Za-z0-9_]{3,32}$/.test(accountUsername) || accountPassword.length < 8) { - setLoginError('Логин: 3–32 символа; пароль: минимум 8 символов') + if (!/^[A-Za-z0-9_]{3,32}$/.test(accountUsername) || accountPassword.length < 3) { + setLoginError('Логин: 3–32 символа; пароль: минимум 3 символа') return } setLoggingIn(true) @@ -548,7 +548,7 @@ function App() { setAccountUsername(event.target.value)} placeholder="Логин" /> {loginError &&
{loginError}
} From ff666ba55f2ec76bf92ceeeab8160e51d4779d1d Mon Sep 17 00:00:00 2001 From: Emil Date: Mon, 7 Sep 2026 22:41:59 +0300 Subject: [PATCH 8/9] fix: harden Windows install and launch pipeline --- src-tauri/src/download.rs | 100 +++++++++-- src-tauri/src/java.rs | 35 +++- src-tauri/src/launch.rs | 121 +++++++++++-- src-tauri/src/mojang.rs | 235 ++++++++++++++++++++----- src-tauri/src/msa.rs | 151 ++++++++++++---- src-tauri/src/neoforge.rs | 274 +++++++++++++++++++++++++----- src-tauri/src/remote.rs | 78 +++++++-- src-tauri/src/settings.rs | 83 +++++++-- src-tauri/src/shacraft_account.rs | 130 ++++++++++---- 9 files changed, 1002 insertions(+), 205 deletions(-) diff --git a/src-tauri/src/download.rs b/src-tauri/src/download.rs index 7093bf3..0873918 100644 --- a/src-tauri/src/download.rs +++ b/src-tauri/src/download.rs @@ -73,12 +73,19 @@ pub fn file_hashes(path: &Path) -> io::Result<(String, String)> { sha1.update(&buffer[..read]); sha256.update(&buffer[..read]); } - Ok((format!("{:x}", sha1.finalize()), format!("{:x}", sha256.finalize()))) + Ok(( + format!("{:x}", sha1.finalize()), + format!("{:x}", sha256.finalize()), + )) } /// True if `path` already exists, matches `expected_size` (when given) and /// `checksum`. Used to skip re-downloading files that are already current. -pub fn is_current(path: &Path, expected_size: Option, checksum: &Checksum) -> io::Result { +pub fn is_current( + path: &Path, + expected_size: Option, + checksum: &Checksum, +) -> io::Result { let metadata = match path.metadata() { Ok(metadata) => metadata, Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), @@ -104,6 +111,42 @@ fn temp_path(target: &Path) -> Result { Ok(target.with_file_name(format!(".{file_name}.shacraft.part"))) } +/// Replaces `target` with a fully-written temporary sibling. Unix rename +/// replaces an existing file atomically, while Windows rename rejects an +/// existing destination. The backup dance keeps the old file recoverable if +/// the second rename fails (for example because antivirus briefly locks it). +pub(crate) fn replace_file(temporary: &Path, target: &Path) -> io::Result<()> { + #[cfg(not(windows))] + { + fs::rename(temporary, target) + } + #[cfg(windows)] + { + if !target.exists() { + return fs::rename(temporary, target); + } + let file_name = target + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "target has no valid filename") + })?; + let backup = target.with_file_name(format!(".{file_name}.shacraft.backup")); + match fs::remove_file(&backup) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + fs::rename(target, &backup)?; + if let Err(error) = fs::rename(temporary, target) { + let _ = fs::rename(&backup, target); + return Err(error); + } + let _ = fs::remove_file(backup); + Ok(()) + } +} + /// Downloads `url` to `target`, verifying size (if known ahead of time) and /// `checksum` before atomically renaming the temporary file into place. /// `on_progress(downloaded_bytes, total_bytes)` is called after every chunk; @@ -126,18 +169,28 @@ pub fn download_verified( let total = expected_size.or_else(|| response.content_length()); if let (Some(expected), Some(length)) = (expected_size, response.content_length()) { if expected != length { - return Err(DownloadError::SizeMismatch { expected, actual: length }); + return Err(DownloadError::SizeMismatch { + expected, + actual: length, + }); } } let temporary = temp_path(target)?; - let result = write_and_verify(&mut response, &temporary, expected_size, checksum, total, &mut on_progress); + let result = write_and_verify( + &mut response, + &temporary, + expected_size, + checksum, + total, + &mut on_progress, + ); if let Err(error) = result { let _ = fs::remove_file(&temporary); return Err(error); } let bytes = result.unwrap(); - fs::rename(&temporary, target).map_err(DownloadError::Io)?; + replace_file(&temporary, target).map_err(DownloadError::Io)?; Ok(bytes) } @@ -160,7 +213,9 @@ fn write_and_verify( if read == 0 { break; } - output.write_all(&buffer[..read]).map_err(DownloadError::Io)?; + output + .write_all(&buffer[..read]) + .map_err(DownloadError::Io)?; sha1.update(&buffer[..read]); sha256.update(&buffer[..read]); bytes += read as u64; @@ -170,7 +225,10 @@ fn write_and_verify( if let Some(expected) = expected_size { if bytes != expected { - return Err(DownloadError::SizeMismatch { expected, actual: bytes }); + return Err(DownloadError::SizeMismatch { + expected, + actual: bytes, + }); } } let sha1_hex = format!("{:x}", sha1.finalize()); @@ -183,14 +241,20 @@ fn write_and_verify( #[cfg(test)] mod tests { - use super::{file_hashes, is_current, Checksum}; - use std::{fs, process, time::{SystemTime, UNIX_EPOCH}}; + use super::{file_hashes, is_current, replace_file, Checksum}; + use std::{ + fs, process, + time::{SystemTime, UNIX_EPOCH}, + }; fn temp_file(contents: &[u8]) -> std::path::PathBuf { let path = std::env::temp_dir().join(format!( "shacraft-download-test-{}-{}", process::id(), - SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() )); fs::write(&path, contents).unwrap(); path @@ -201,7 +265,10 @@ mod tests { let path = temp_file(b"hello shacraft"); let (sha1_hex, sha256_hex) = file_hashes(&path).unwrap(); assert_eq!(sha1_hex, "124b319646ec08b4fb2a2b65bbd21c0431b4eaf4"); - assert_eq!(sha256_hex, "d34eb8ea6396e8492109813c717f7eefd0437c10ff55a7b11949cfae900c946d"); + assert_eq!( + sha256_hex, + "d34eb8ea6396e8492109813c717f7eefd0437c10ff55a7b11949cfae900c946d" + ); fs::remove_file(path).unwrap(); } @@ -220,4 +287,15 @@ mod tests { let path = std::env::temp_dir().join("shacraft-download-test-missing-file-xyz"); assert!(!is_current(&path, None, &Checksum::Sha256("0".repeat(64))).unwrap()); } + + #[test] + fn replaces_an_existing_file() { + let target = temp_file(b"old"); + let temporary = target.with_extension("replacement"); + fs::write(&temporary, b"new").unwrap(); + replace_file(&temporary, &target).unwrap(); + assert_eq!(fs::read(&target).unwrap(), b"new"); + assert!(!temporary.exists()); + fs::remove_file(target).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 a39a118..71ae513 100644 --- a/src-tauri/src/launch.rs +++ b/src-tauri/src/launch.rs @@ -69,7 +69,12 @@ fn build_classpath(game_dir: &Path, merged: &MergedVersion, client_jar: &Path) - .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()); @@ -78,7 +83,11 @@ fn build_classpath(game_dir: &Path, merged: &MergedVersion, client_jar: &Path) - // (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()) + entries + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(classpath_separator()) } /// A persistent-but-not-security-sensitive per-install identifier for the @@ -104,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; @@ -114,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) } @@ -129,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 @@ -140,7 +188,8 @@ 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(); @@ -155,7 +204,10 @@ pub fn launch(request: &LaunchRequest) -> Result { 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()); @@ -168,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 { @@ -199,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); @@ -217,12 +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")]); + 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/mojang.rs b/src-tauri/src/mojang.rs index 497bb6c..004cba5 100644 --- a/src-tauri/src/mojang.rs +++ b/src-tauri/src/mojang.rs @@ -27,7 +27,8 @@ use std::{ }; use url::Url; -const VERSION_MANIFEST_URL: &str = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"; +const VERSION_MANIFEST_URL: &str = + "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"; const MOJANG_HOSTS: [&str; 4] = [ "piston-meta.mojang.com", "piston-data.mojang.com", @@ -41,7 +42,10 @@ const MOJANG_HOSTS: [&str; 4] = [ const ASSET_WORKERS: usize = 48; pub fn is_allowed_host(url: &str) -> bool { - Url::parse(url).ok().and_then(|parsed| parsed.host_str().map(|host| MOJANG_HOSTS.contains(&host))).unwrap_or(false) + Url::parse(url) + .ok() + .and_then(|parsed| parsed.host_str().map(|host| MOJANG_HOSTS.contains(&host))) + .unwrap_or(false) } /// Library entries in a merged loader profile may point at the loader's @@ -59,6 +63,7 @@ pub enum MojangError { ChecksumMismatch(String), DisallowedHost(String), MissingField(String), + ConflictingLibrary(String), Download(DownloadError), Io(io::Error), } @@ -70,8 +75,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}"), } @@ -110,7 +121,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) } @@ -145,7 +159,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)] @@ -231,7 +248,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 } @@ -250,7 +271,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; @@ -265,7 +288,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; } @@ -275,7 +301,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 { @@ -293,11 +322,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())); } @@ -348,8 +384,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() @@ -401,34 +443,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(), @@ -452,20 +529,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 @@ -473,7 +569,10 @@ 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()), @@ -500,7 +599,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); @@ -540,12 +646,16 @@ fn download_many( if first_error.lock().unwrap().is_some() { break; } - let Some(task) = queue.lock().unwrap().pop() else { break }; + 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)); @@ -571,7 +681,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, } } @@ -589,7 +702,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())); } @@ -598,7 +715,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())); @@ -619,7 +740,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] @@ -649,18 +773,38 @@ 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_library_host("https://maven.neoforged.net/releases/net/neoforged/example.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")); } @@ -677,7 +821,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(); @@ -689,11 +834,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(); @@ -703,7 +857,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 e215657..362c46e 100644 --- a/src-tauri/src/msa.rs +++ b/src-tauri/src/msa.rs @@ -41,7 +41,8 @@ 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"; @@ -111,7 +112,10 @@ pub fn start_device_code(client: &Client) -> Result { } 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() { @@ -144,7 +148,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); @@ -167,10 +174,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 { +pub fn refresh_microsoft_tokens( + client: &Client, + refresh_token: &str, +) -> Result { if !is_configured() { return Err(MsaError::NotConfigured); } @@ -205,9 +225,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, + }) } // --------------------------------------------------------------------- @@ -274,7 +299,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", @@ -284,22 +312,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 @@ -312,7 +360,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)) } @@ -328,8 +381,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())); } @@ -347,7 +406,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) @@ -376,15 +438,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) } @@ -401,8 +474,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); let temporary = data_dir.join(".account.json.shacraft.part"); @@ -412,7 +492,7 @@ pub fn save_refresh_token(data_dir: &Path, refresh_token: &str) -> io::Result<() use std::os::unix::fs::PermissionsExt; fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600))?; } - fs::rename(temporary, target) + crate::download::replace_file(&temporary, &target) } pub fn load_refresh_token(data_dir: &Path) -> Option { @@ -438,7 +518,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,9 +531,14 @@ 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(); } @@ -459,7 +547,10 @@ mod tests { fn refuses_to_run_with_placeholder_client_id() { 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 a66bac6..436343b 100644 --- a/src-tauri/src/neoforge.rs +++ b/src-tauri/src/neoforge.rs @@ -57,21 +57,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}" + ) } } } @@ -89,7 +102,10 @@ impl From for NeoForgeError { } pub(crate) fn is_allowed_host(url: &str) -> bool { - Url::parse(url).ok().and_then(|parsed| parsed.host_str().map(|host| host == NEOFORGE_HOST)).unwrap_or(false) + Url::parse(url) + .ok() + .and_then(|parsed| parsed.host_str().map(|host| host == NEOFORGE_HOST)) + .unwrap_or(false) } fn installer_jar_url(loader_version: &str) -> String { @@ -99,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)); } @@ -139,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. @@ -161,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); @@ -173,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 @@ -219,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'); @@ -243,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)) } @@ -258,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 { @@ -296,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(); @@ -312,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); @@ -326,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)]); } @@ -350,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(); @@ -360,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. @@ -369,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/remote.rs b/src-tauri/src/remote.rs index f7e4808..40c73aa 100644 --- a/src-tauri/src/remote.rs +++ b/src-tauri/src/remote.rs @@ -1,14 +1,17 @@ use crate::manifest::{self, Manifest}; use base64::{engine::general_purpose::STANDARD, Engine}; use ed25519_dalek::{Signature, Verifier, VerifyingKey}; +use reqwest::header::ACCEPT_ENCODING; use reqwest::{blocking::Client, redirect::Policy}; use serde::{Deserialize, Serialize}; -use std::{fmt, time::Duration}; +use std::{fmt, 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 MAX_MANIFEST_SIZE: u64 = 2 * 1024 * 1024; +const MANIFEST_ATTEMPTS: u32 = 3; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] @@ -47,7 +50,9 @@ impl fmt::Display for RemoteError { Self::Status(status) => write!(formatter, "ShaCraft manifest request failed: {status}"), Self::TooLarge => formatter.write_str("ShaCraft manifest is too large"), Self::InvalidSignature => formatter.write_str("ShaCraft manifest signature is invalid"), - Self::InvalidManifest(error) => write!(formatter, "ShaCraft manifest is invalid: {error}"), + Self::InvalidManifest(error) => { + write!(formatter, "ShaCraft manifest is invalid: {error}") + } } } } @@ -62,26 +67,63 @@ pub fn fetch_manifest(profile_id: &str) -> Result { .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())); + let mut last_network_error = None; + let mut source = None; + for attempt in 1..=MANIFEST_ATTEMPTS { + match client.get(url).header(ACCEPT_ENCODING, "identity").send() { + Ok(response) => { + if !response.status().is_success() { + return Err(RemoteError::Status(response.status())); + } + if response + .content_length() + .is_some_and(|size| size > MAX_MANIFEST_SIZE) + { + return Err(RemoteError::TooLarge); + } + match response.bytes() { + Ok(bytes) if bytes.len() as u64 <= MAX_MANIFEST_SIZE => { + source = Some(bytes); + break; + } + Ok(_) => return Err(RemoteError::TooLarge), + Err(error) => last_network_error = Some(error), + } + } + Err(error) => last_network_error = Some(error), + } + if attempt < MANIFEST_ATTEMPTS { + thread::sleep(Duration::from_millis(250 * attempt as u64)); + } } - if response.content_length().is_some_and(|size| size > 2 * 1024 * 1024) { - return Err(RemoteError::TooLarge); - } - let source = response.text().map_err(RemoteError::Network)?; - let envelope = serde_json::from_str::(&source) + let source = source.ok_or_else(|| { + RemoteError::Network(last_network_error.expect("a network attempt failed")) + })?; + let envelope = serde_json::from_slice::(&source) .map_err(|_| RemoteError::InvalidSignature)?; if envelope.schema_version != 1 || envelope.key_id != "2026-09-06" { return Err(RemoteError::InvalidSignature); } - let payload = STANDARD.decode(envelope.payload).map_err(|_| RemoteError::InvalidSignature)?; - let signature_bytes = STANDARD.decode(envelope.signature).map_err(|_| RemoteError::InvalidSignature)?; - let public_key_bytes = STANDARD.decode(MANIFEST_PUBLIC_KEY).expect("embedded public key must be valid"); - let public_key = VerifyingKey::from_bytes(&public_key_bytes.try_into().expect("embedded public key must be 32 bytes")) + let payload = STANDARD + .decode(envelope.payload) + .map_err(|_| RemoteError::InvalidSignature)?; + let signature_bytes = STANDARD + .decode(envelope.signature) + .map_err(|_| RemoteError::InvalidSignature)?; + let public_key_bytes = STANDARD + .decode(MANIFEST_PUBLIC_KEY) .expect("embedded public key must be valid"); - let signature = Signature::from_slice(&signature_bytes).map_err(|_| RemoteError::InvalidSignature)?; - public_key.verify(&payload, &signature).map_err(|_| RemoteError::InvalidSignature)?; + let public_key = VerifyingKey::from_bytes( + &public_key_bytes + .try_into() + .expect("embedded public key must be 32 bytes"), + ) + .expect("embedded public key must be valid"); + let signature = + Signature::from_slice(&signature_bytes).map_err(|_| RemoteError::InvalidSignature)?; + public_key + .verify(&payload, &signature) + .map_err(|_| RemoteError::InvalidSignature)?; let payload = String::from_utf8(payload).map_err(|_| RemoteError::InvalidSignature)?; manifest::validate_json(&payload).map_err(RemoteError::InvalidManifest) } @@ -100,5 +142,7 @@ pub fn fetch_server_status(profile_id: &str) -> Result().map_err(RemoteError::Network) + response + .json::() + .map_err(RemoteError::Network) } diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index acf5645..e5422ae 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -1,3 +1,4 @@ +use crate::download; use serde::{Deserialize, Serialize}; use std::{fmt, fs, io, path::Path}; @@ -38,7 +39,11 @@ fn default_nickname() -> String { impl Default for LauncherSettings { fn default() -> Self { - Self { memory_mb: DEFAULT_MEMORY_MB, nickname: DEFAULT_NICKNAME.into(), account_mode: AccountMode::Offline } + Self { + memory_mb: DEFAULT_MEMORY_MB, + nickname: DEFAULT_NICKNAME.into(), + account_mode: AccountMode::Offline, + } } } @@ -55,8 +60,13 @@ impl fmt::Display for SettingsError { match self { Self::Io(error) => write!(formatter, "Cannot access launcher settings: {error}"), Self::InvalidJson(error) => write!(formatter, "Cannot read launcher settings: {error}"), - Self::InvalidMemory => write!(formatter, "Memory allocation must be between 3 and 12 GiB"), - Self::InvalidNickname => write!(formatter, "Nickname must be 3-16 ASCII letters, numbers, or underscores"), + Self::InvalidMemory => { + write!(formatter, "Memory allocation must be between 3 and 12 GiB") + } + Self::InvalidNickname => write!( + formatter, + "Nickname must be 3-16 ASCII letters, numbers, or underscores" + ), } } } @@ -65,7 +75,9 @@ pub fn load(data_dir: &Path) -> Result { let path = data_dir.join(SETTINGS_FILE); let source = match fs::read_to_string(path) { Ok(source) => source, - Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(LauncherSettings::default()), + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Ok(LauncherSettings::default()) + } Err(error) => return Err(SettingsError::Io(error)), }; let settings = serde_json::from_str(&source).map_err(SettingsError::InvalidJson)?; @@ -73,7 +85,10 @@ pub fn load(data_dir: &Path) -> Result { Ok(settings) } -pub fn save(data_dir: &Path, settings: LauncherSettings) -> Result { +pub fn save( + data_dir: &Path, + settings: LauncherSettings, +) -> Result { validate(&settings)?; fs::create_dir_all(data_dir).map_err(SettingsError::Io)?; @@ -81,15 +96,22 @@ pub fn save(data_dir: &Path, settings: LauncherSettings) -> Result Result<(), SettingsError> { - if !(MIN_MEMORY_MB..=MAX_MEMORY_MB).contains(&settings.memory_mb) || settings.memory_mb % 1024 != 0 { + if !(MIN_MEMORY_MB..=MAX_MEMORY_MB).contains(&settings.memory_mb) + || settings.memory_mb % 1024 != 0 + { return Err(SettingsError::InvalidMemory); } - if !(3..=16).contains(&settings.nickname.len()) || !settings.nickname.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') { + if !(3..=16).contains(&settings.nickname.len()) + || !settings + .nickname + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + { return Err(SettingsError::InvalidNickname); } Ok(()) @@ -98,13 +120,19 @@ fn validate(settings: &LauncherSettings) -> Result<(), SettingsError> { #[cfg(test)] mod tests { use super::{load, save, AccountMode, LauncherSettings}; - use std::{fs, process, time::{SystemTime, UNIX_EPOCH}}; + use std::{ + fs, process, + time::{SystemTime, UNIX_EPOCH}, + }; fn temporary_directory() -> std::path::PathBuf { std::env::temp_dir().join(format!( "shacraft-settings-test-{}-{}", process::id(), - SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() )) } @@ -116,9 +144,20 @@ mod tests { assert_eq!(default.nickname, "Emil"); assert_eq!(default.account_mode, AccountMode::Offline); - let saved = save(&directory, LauncherSettings { memory_mb: 8 * 1024, nickname: "Emil".into(), account_mode: AccountMode::Microsoft }).unwrap(); + let saved = save( + &directory, + LauncherSettings { + memory_mb: 8 * 1024, + nickname: "Emil".into(), + account_mode: AccountMode::Microsoft, + }, + ) + .unwrap(); assert_eq!(saved.memory_mb, 8 * 1024); - assert_eq!(load(&directory).unwrap().account_mode, AccountMode::Microsoft); + assert_eq!( + load(&directory).unwrap().account_mode, + AccountMode::Microsoft + ); fs::remove_dir_all(directory).unwrap(); } @@ -126,8 +165,24 @@ mod tests { #[test] fn rejects_unsafe_memory_values() { let directory = temporary_directory(); - assert!(save(&directory, LauncherSettings { memory_mb: 512, nickname: "Emil".into(), account_mode: AccountMode::Offline }).is_err()); - assert!(save(&directory, LauncherSettings { memory_mb: 6 * 1024, nickname: "невалидный".into(), account_mode: AccountMode::Offline }).is_err()); + assert!(save( + &directory, + LauncherSettings { + memory_mb: 512, + nickname: "Emil".into(), + account_mode: AccountMode::Offline + } + ) + .is_err()); + assert!(save( + &directory, + LauncherSettings { + memory_mb: 6 * 1024, + nickname: "невалидный".into(), + account_mode: AccountMode::Offline + } + ) + .is_err()); } #[test] diff --git a/src-tauri/src/shacraft_account.rs b/src-tauri/src/shacraft_account.rs index 558e9e7..26d7b03 100644 --- a/src-tauri/src/shacraft_account.rs +++ b/src-tauri/src/shacraft_account.rs @@ -72,7 +72,9 @@ impl fmt::Display for AccountError { 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"), + Self::NoLinkedNickname => { + formatter.write_str("Сначала привяжите игровой ник к серверу Aeronautics") + } } } } @@ -87,9 +89,14 @@ fn client() -> Result { fn api_error(response: Response) -> AccountError { #[derive(Deserialize)] - struct ErrorBody { detail: Option } + struct ErrorBody { + detail: Option, + } let status = response.status(); - let detail = response.json::().ok().and_then(|body| body.detail); + let detail = response + .json::() + .ok() + .and_then(|body| body.detail); AccountError::Api(detail.unwrap_or_else(|| format!("ShaCraft API: HTTP {status}"))) } @@ -105,14 +112,19 @@ fn save_session(data_dir: &Path, token: &str) -> Result<(), AccountError> { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600)).map_err(AccountError::Io)?; + fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600)) + .map_err(AccountError::Io)?; } - fs::rename(temporary, path).map_err(AccountError::Io) + crate::download::replace_file(&temporary, &path).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) } + 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()) { @@ -121,32 +133,59 @@ fn load_session(data_dir: &Path) -> Result { 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)?; +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 }) + 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)?; + 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)); } + 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(); + 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(()), @@ -155,25 +194,43 @@ pub fn logout(data_dir: &Path) -> Result<(), AccountError> { } } -pub fn start_link(data_dir: &Path, server_id: &str, nickname: &str) -> Result { +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)); } + 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)); } + 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() + get_account(data_dir)? + .links + .into_iter() .find(|link| link.server_id == "aoc") .map(|link| link.mc_username) .ok_or(AccountError::NoLinkedNickname) @@ -182,13 +239,19 @@ pub fn aeronautics_nickname(data_dir: &Path) -> Result { #[cfg(test)] mod tests { use super::{load_session, save_session, session_path}; - use std::{fs, process, time::{SystemTime, UNIX_EPOCH}}; + use std::{ + fs, process, + time::{SystemTime, UNIX_EPOCH}, + }; fn temporary_directory() -> std::path::PathBuf { std::env::temp_dir().join(format!( "shacraft-account-test-{}-{}", process::id(), - SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() )) } @@ -208,7 +271,14 @@ mod tests { 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); + assert_eq!( + fs::metadata(session_path(&directory)) + .unwrap() + .permissions() + .mode() + & 0o077, + 0 + ); fs::remove_dir_all(directory).unwrap(); } } From 2d60f5eb3c9d7e42eb2c592938aed15928e3e81d Mon Sep 17 00:00:00 2001 From: Emil Date: Mon, 7 Sep 2026 22:51:03 +0300 Subject: [PATCH 9/9] chore: bump launcher to 0.1.1 --- package-lock.json | 4 ++-- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index f0eac88..c430b47 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", "@vitejs/plugin-react": "latest", diff --git a/package.json b/package.json index 25b17bf..fb47f0e 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "shacraft-launcher-ui", "license": "MIT", "private": true, - "version": "0.1.0", + "version": "0.1.1", "type": "module", "scripts": { "dev": "vite", 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 4a8d851..64a2ba6 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "shacraft-launcher" -version = "0.1.0" +version = "0.1.1" description = "ShaCraft Minecraft launcher" authors = ["ShaCraft"] license = "MIT" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index a5ead3b..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",