diff --git a/AGENTS.md b/AGENTS.md index c20040c..5bcd9f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,6 +56,16 @@ payload are in `/root/shacraft` on the ShaCraft host; see requires LoginSystem authentication and an explicit one-time proof command. Existing links retain legacy provenance; status polling cannot create links. +- Launcher self-update is a separate trust domain in `updater.rs` and + `updater/protocol.rs`: the fixed GitHub repository's `latest.json` bytes are + authenticated with the pinned Tauri/minisign public key before JSON parsing. + Each exact version/platform artifact also requires that signature, SHA-256 + and size. Never reuse the ShaCraft mod-manifest key or accept IPC URLs/keys. +- `update_guard.rs` retains one launcher-instance OS lock, drains native writes + through `operations::Lifecycle`, and checks the existing detached-game lease. + `launcher-state/pending-update.json` survives installer handoff; only startup + of its exact target version acknowledges it. Never clear it on a timer. + ## Layout - `src/main.tsx` — React entrypoint; `src/App.tsx` composes the screen. @@ -68,7 +78,7 @@ payload are in `/root/shacraft` on the ShaCraft host; see - `lib.rs` — module/command registration only; `commands/` holds adapters for account/game/host/preferences/profiles. Unsigned sync/inspect IPC was removed; only verified remote manifests may drive profile mutations. - - `operations.rs` — process-local install/account permits owned by workers. + - `operations.rs` — lifecycle and install/account permits owned by workers. Launch must use the authenticated ShaCraft nickname; no settings fallback. - `storage.rs` — unique same-directory atomic writes, owner-only Unix files. - `trusted_http.rs` — HTTPS and exact-host redirect policy per game provider. @@ -96,8 +106,13 @@ payload are in `/root/shacraft` on the ShaCraft host; see - `docs/game-trust-boundary.md` — the Mojang/NeoForge/Microsoft/Adoptium trust domains used to install and run the game itself. - `.github/workflows/check.yml` — push/PR UI checks and Linux Rust tests. -- `.github/workflows/build.yml` — main-push/manual cross-platform builds with artifacts; - not a signed release or updater publication. +- `.github/workflows/build.yml` — four-platform CI packages with disposable test + signing keys, explicitly unusable as production releases. +- Release workflows and `scripts/release.py` implement protected draft → publish + gates; see `docs/updater-release.md`. Never publish assets piecemeal, reuse CI + test keys, or confuse updater signatures with OS signing/notarization. +- `src-tauri/updater-public-key.txt` is the public production trust root. + Private updater keys remain outside all repositories; never commit them. ## Verification diff --git a/docs/launcher-architecture.md b/docs/launcher-architecture.md index 881358b..514bb87 100644 --- a/docs/launcher-architecture.md +++ b/docs/launcher-architecture.md @@ -13,9 +13,13 @@ 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. +The launcher also checks signed GitHub releases for its own updates. AppImage, +Windows x64 installers and native Intel/Apple Silicon macOS app bundles use +explicit install-and-restart; Debian packages use manual package management. +Production publication and OS signing/notarization require operator setup; +see `updater-release.md`. Version 0.1.1 has no updater and must be upgraded +manually once. User-selectable profile directories and managed-only reset +remain unimplemented. ## Data flow @@ -71,7 +75,7 @@ be the system `.minecraft` directory. 1. User-selectable profile directory and structured launcher logs. 2. "Reset managed files only" recovery action that doesn't touch player worlds/screenshots/resourcepacks. -3. Signed, cross-platform release builds of the launcher itself. +3. Production release credentials/protected environments and OS beta validation. 4. Cancellation, structured logs and a full cold-install/recovery beta on every target OS. Install progress reports bytes or installer work counts depending on the stage; these units are not interchangeable. @@ -107,10 +111,12 @@ Hostile same-user TOCTOU is outside this protection; it is not an OS sandbox. `npm test` covers asynchronous helpers and state transitions; `npm run build` runs strict TypeScript before Vite. `cargo test --locked` covers native policy and storage. Push/PR CI repeats checks on Linux. -The package workflow runs on main pushes or manually and builds Windows -x64, Linux x64 and both macOS architectures with named artifacts. -Packages are not yet signed release artifacts. Native cold-install and -launch tests are required before calling a platform release-ready. +The package workflow builds and checks Windows x64, Linux x64 and both macOS +architectures with disposable test signing keys. These artifacts cannot be +published as production updater releases. Separate protected workflows assemble +a complete signed release as a draft, re-verify its assets and only then publish. +Native cold-install, real self-update and launch tests are required before +calling a platform release-ready. CI package builds do not prove those flows. ## Reconciliation and recovery @@ -195,3 +201,56 @@ both and repeats a healthy check. It never logs into a game server. It requires network access and sufficient disk space; the printed directory is retained for diagnosis. This does not replace Tauri IPC, graphical gameplay or the client/server proof matrix on each supported OS. + + +## Launcher self-update boundary (0.2.0) + +`updater/protocol.rs` pins `github.com/emil28092005/shacraft-launcher/releases`. +The exact raw `latest.json` response is verified using its detached minisign +signature and the committed updater public key before versions, URLs or notes +are parsed. This key is independent of ShaCraft's Ed25519 mod manifest. Signed +metadata binds a stable version and tag to an exact set of four platform and +four manual package descriptors; every descriptor has an exact repository/tag/ +filename, size, SHA-256 and Tauri signature. HTTPS redirects are restricted to +that repository and GitHub's release asset CDN. Stable downgrades, unknown +platforms, missing signatures and incomplete metadata fail closed. + +Only native commands check, download, install and open the fixed releases page. +The webview supplies no URLs, public keys, executable arguments, release version +or arbitrary file path. No generic updater plugin permission is granted to it. +The packaged native architecture selects the artifact: Windows preserves MSI +versus NSIS, macOS preserves Intel versus Apple Silicon, Linux only replaces an +AppImage. A Debian installation requires the user's package manager. + +Checks run once per application UI lifecycle and on explicit request. They do +not install automatically. The settings drawer shows installed/available +versions, plain-text notes, progress, actionable errors and retry. The explicit +install button includes restart. UI session recovery codes, unsaved/failed +settings and account/game operations inhibit that action; native permits are +the final authority for concurrent writes. Preferences, sessions and game data +live outside the executable and are not migrated or erased by the updater. + +`launcher-state/instance.lock` is a process-lifetime OS lock, preventing an idle +second cooperating launcher from retaining old code during replacement. All +native account/settings/game writes hold shared lifecycle permits. Replacement +holds the exclusive permit and `installation-state/writer.lock`, which also +checks a game process's durable PID/start-time lease. A normal window close is +inhibited during download/install. Worker permits survive a dropped IPC future. +Before invoking the platform installer, `pending-update.json` records current +and target versions. The Windows plugin hands off and exits; the marker remains. +Only startup of the exact target version clears it. Old versions and indeterminate +installer failures block native mutations and direct the user to manual recovery. +A corrupt marker opens recovery diagnostics and latches the mutation/launch ban +until application restart, even if that file is removed while the UI is open. +No guessed installer timeout +releases the gate. This cannot retroactively make old 0.1.1 binaries cooperate +with these locks. + +First metadata/signature and artifact reads are bounded. Tauri updater 2.11.0 +requires its own second check to construct a private installer context; that +check uses the fixed version endpoint, HTTPS host policy and a 30-second timeout, +and its parsed metadata must equal the previously authenticated document. +The plugin's secondary response has no byte-limit API, leaving a memory-use +risk if that trusted release endpoint serves an unexpectedly large response. +Immediately before install, native code re-verifies artifact size/hash/signature; +`Update::install` alone does not perform signature verification. diff --git a/package-lock.json b/package-lock.json index 79b527c..c6d9086 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "shacraft-launcher-ui", - "version": "0.1.1", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "shacraft-launcher-ui", - "version": "0.1.1", + "version": "0.2.0", "dependencies": { "@tauri-apps/api": "2.11.1", "lucide-react": "1.41.0", diff --git a/package.json b/package.json index d08e790..22c4454 100644 --- a/package.json +++ b/package.json @@ -2,13 +2,13 @@ "name": "shacraft-launcher-ui", "license": "MIT", "private": true, - "version": "0.1.1", + "version": "0.2.0", "type": "module", "scripts": { "dev": "vite", "build": "npm run typecheck && vite build", "typecheck": "tsc --noEmit", - "test": "tsx --test src/services/async.test.ts src/state/game.test.ts src/state/profiles.test.ts src/state/account.test.ts src/components/account.test.tsx", + "test": "tsx --test src/services/async.test.ts src/state/game.test.ts src/state/profiles.test.ts src/state/account.test.ts src/components/account.test.tsx src/state/updater.test.ts src/components/updater.test.tsx", "preview": "vite preview", "tauri": "tauri", "tauri:dev": "tauri dev", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 191fe81..5ae5edf 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1770,6 +1770,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -1975,6 +2005,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2226,6 +2262,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.1", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -2251,6 +2288,18 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -2314,12 +2363,32 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.20", +] + [[package]] name = "pango" version = "0.18.3" @@ -2836,15 +2905,20 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -2912,6 +2986,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -2922,6 +3008,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.15" @@ -2954,6 +3067,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -3011,6 +3133,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.36.1" @@ -3235,13 +3380,16 @@ dependencies = [ [[package]] name = "shacraft-launcher" -version = "0.1.1" +version = "0.2.0" dependencies = [ "base64 0.22.1", "ed25519-dalek", "flate2", "md-5", + "minisign-verify", "reqwest 0.12.28", + "reqwest 0.13.4", + "semver", "serde", "serde_json", "sha1", @@ -3250,6 +3398,8 @@ dependencies = [ "tar", "tauri", "tauri-build", + "tauri-plugin-updater", + "time", "url", "zip", ] @@ -3275,6 +3425,22 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.3" @@ -3511,7 +3677,7 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", @@ -3578,7 +3744,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", - "jni", + "jni 0.21.1", "libc", "log", "mime", @@ -3674,6 +3840,54 @@ dependencies = [ "tauri-utils", ] +[[package]] +name = "tauri-plugin" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-updater" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b28d8cabdeb0564f03ae261963de4bc3d98321cd3d213e76a81b7d344e5df606" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest 0.13.4", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.20", + "time", + "tokio", + "url", + "windows-sys 0.60.2", +] + [[package]] name = "tauri-runtime" version = "2.11.3" @@ -3684,7 +3898,7 @@ dependencies = [ "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2", "objc2-ui-kit", "objc2-web-kit", @@ -3707,7 +3921,7 @@ checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2", "objc2-app-kit", @@ -3774,6 +3988,19 @@ dependencies = [ "toml 1.1.5+spec-1.1.0", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "tendril" version = "0.5.1" @@ -4451,6 +4678,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "1.0.9" @@ -4750,6 +4986,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -4783,13 +5028,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows-threading" version = "0.1.0" @@ -4829,6 +5091,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -4841,6 +5109,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -4853,12 +5127,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -4871,6 +5157,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -4883,6 +5175,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -4895,6 +5193,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -4907,6 +5211,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.5.40" @@ -4971,7 +5281,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b3cb7a2..bcea1ad 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "shacraft-launcher" -version = "0.1.1" +version = "0.2.0" description = "ShaCraft Minecraft launcher" authors = ["ShaCraft"] license = "MIT" @@ -28,3 +28,9 @@ flate2 = "1" tar = "0.4" zip = { version = "2", default-features = false, features = ["deflate"] } sysinfo = { version = "0.39.6", default-features = false, features = ["system"] } + +tauri-plugin-updater = { version = "=2.11.0", default-features = false, features = ["rustls-tls"] } +minisign-verify = "=0.2.5" +semver = "1" +time = { version = "0.3", features = ["parsing", "formatting"] } +reqwest-updater = { package = "reqwest", version = "0.13", default-features = false, features = ["rustls-no-provider"] } diff --git a/src-tauri/build.rs b/src-tauri/build.rs index d860e1e..c8c7ba0 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,6 @@ fn main() { + println!("cargo:rerun-if-env-changed=SHACRAFT_UPDATER_PUBLIC_KEY"); + println!("cargo:rerun-if-env-changed=SHACRAFT_UPDATER_TEST_BUILD"); + println!("cargo:rerun-if-changed=updater-public-key.txt"); tauri_build::build() } diff --git a/src-tauri/src/commands/account.rs b/src-tauri/src/commands/account.rs index df58ca2..1e170f1 100644 --- a/src-tauri/src/commands/account.rs +++ b/src-tauri/src/commands/account.rs @@ -30,8 +30,10 @@ pub(crate) fn start_microsoft_login( state: State<'_, LauncherOperations>, ) -> Result<(), String> { let directory = data_dir(&app)?; + let lifecycle = crate::update_guard::begin_operation(&directory, &state)?; let permit = state.account.acquire("Account operation")?; tauri::async_runtime::spawn_blocking(move || { + let _lifecycle = lifecycle; let _permit = permit; let login = || -> Result { let client = msa::http_client().map_err(|error| error.to_string())?; @@ -76,8 +78,10 @@ pub(crate) async fn get_account( state: State<'_, LauncherOperations>, ) -> Result, String> { let data_dir = data_dir(&app)?; + let lifecycle = crate::update_guard::begin_operation(&data_dir, &state)?; let permit = state.account.acquire("Account operation")?; tauri::async_runtime::spawn_blocking(move || { + let _lifecycle = lifecycle; let _permit = permit; let Some(refresh_token) = msa::load_refresh_token(&data_dir) else { return Ok(None); @@ -102,8 +106,10 @@ pub(crate) async fn logout( state: State<'_, LauncherOperations>, ) -> Result<(), String> { let data_dir = data_dir(&app)?; + let lifecycle = crate::update_guard::begin_operation(&data_dir, &state)?; let permit = state.account.acquire("Account operation")?; tauri::async_runtime::spawn_blocking(move || { + let _lifecycle = lifecycle; let _permit = permit; msa::clear_account(&data_dir) }) diff --git a/src-tauri/src/commands/game.rs b/src-tauri/src/commands/game.rs index 8754300..f6e3175 100644 --- a/src-tauri/src/commands/game.rs +++ b/src-tauri/src/commands/game.rs @@ -151,8 +151,10 @@ pub(crate) async fn ensure_game_installed( profile_id: String, ) -> Result { let directory = data_dir(&app)?; + let lifecycle = crate::update_guard::begin_operation(&directory, &state)?; let permit = state.installation.acquire("Installation")?; tauri::async_runtime::spawn_blocking(move || { + let _lifecycle = lifecycle; let _permit = permit; let _lock = InstallationLock::acquire(&directory)?; let snapshot = remote::fetch_snapshot(&profile_id).map_err(|e| e.to_string())?; @@ -181,9 +183,11 @@ async fn play( onboarding_name: Option, ) -> Result { let directory = data_dir(&app)?; + let lifecycle = crate::update_guard::begin_operation(&directory, &state)?; let permit = state.installation.acquire("Installation")?; let account_operation = state.shacraft_account.clone(); tauri::async_runtime::spawn_blocking(move || { + let _lifecycle = lifecycle; let _permit = permit; let lock = InstallationLock::acquire(&directory)?; let snapshot = remote::fetch_snapshot(&profile_id).map_err(|e| e.to_string())?; diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 6f82e5a..9d9db7e 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -5,6 +5,7 @@ pub(crate) mod host; pub(crate) mod preferences; pub(crate) mod profiles; pub(crate) mod shacraft; +pub(crate) mod updater; use std::path::PathBuf; use tauri::{AppHandle, Manager}; diff --git a/src-tauri/src/commands/preferences.rs b/src-tauri/src/commands/preferences.rs index 1979170..2f61fb4 100644 --- a/src-tauri/src/commands/preferences.rs +++ b/src-tauri/src/commands/preferences.rs @@ -1,6 +1,6 @@ use super::data_dir; -use crate::settings; -use tauri::AppHandle; +use crate::{operations::LauncherOperations, settings}; +use tauri::{AppHandle, State}; #[tauri::command] pub(crate) async fn load_settings(app: AppHandle) -> Result { @@ -14,11 +14,16 @@ pub(crate) async fn load_settings(app: AppHandle) -> Result, settings: settings::LauncherSettings, ) -> Result { let data_dir = data_dir(&app)?; - tauri::async_runtime::spawn_blocking(move || settings::save(&data_dir, settings)) - .await - .map_err(|error| format!("Settings task failed: {error}"))? - .map_err(|error| error.to_string()) + let lifecycle = crate::update_guard::begin_operation(&data_dir, &state)?; + tauri::async_runtime::spawn_blocking(move || { + let _lifecycle = lifecycle; + settings::save(&data_dir, settings) + }) + .await + .map_err(|error| format!("Settings task failed: {error}"))? + .map_err(|error| error.to_string()) } diff --git a/src-tauri/src/commands/profiles.rs b/src-tauri/src/commands/profiles.rs index 934f8d2..2b46c6f 100644 --- a/src-tauri/src/commands/profiles.rs +++ b/src-tauri/src/commands/profiles.rs @@ -47,10 +47,13 @@ pub(crate) async fn get_server_status(profile_id: String) -> Result, profile_id: String, ) -> Result { let directory = data_dir(&app)?; + let lifecycle = crate::update_guard::begin_operation(&directory, &state)?; tauri::async_runtime::spawn_blocking(move || { + let _lifecycle = lifecycle; // Inspection must not report a partially applied journal as ready. let _lock = InstallationLock::acquire(&directory)?; let manifest = remote::fetch_manifest(&profile_id).map_err(|e| e.to_string())?; @@ -68,8 +71,10 @@ pub(crate) async fn sync_remote_profile( profile_id: String, ) -> Result { let directory = data_dir(&app)?; + let lifecycle = crate::update_guard::begin_operation(&directory, &state)?; let permit = state.installation.acquire("Installation")?; tauri::async_runtime::spawn_blocking(move || { + let _lifecycle = lifecycle; let _permit = permit; let _lock = InstallationLock::acquire(&directory)?; let snapshot = remote::fetch_snapshot(&profile_id).map_err(|e| e.to_string())?; @@ -86,10 +91,13 @@ pub(crate) async fn sync_remote_profile( #[tauri::command] pub(crate) async fn legacy_mods( app: AppHandle, + state: State<'_, LauncherOperations>, profile_id: String, ) -> Result, String> { let directory = data_dir(&app)?; + let lifecycle = crate::update_guard::begin_operation(&directory, &state)?; tauri::async_runtime::spawn_blocking(move || { + let _lifecycle = lifecycle; let _lock = InstallationLock::acquire(&directory)?; let manifest = remote::fetch_manifest(&profile_id).map_err(|e| e.to_string())?; profile::list_legacy_mods(&directory.join("profiles").join(&manifest.id), &manifest) @@ -107,8 +115,10 @@ pub(crate) async fn backup_legacy_mods( selections: Vec, ) -> Result { let directory = data_dir(&app)?; + let lifecycle = crate::update_guard::begin_operation(&directory, &state)?; let permit = state.installation.acquire("Legacy migration")?; tauri::async_runtime::spawn_blocking(move || { + let _lifecycle = lifecycle; let _permit = permit; let _lock = InstallationLock::acquire(&directory)?; let manifest = remote::fetch_manifest(&profile_id).map_err(|e| e.to_string())?; diff --git a/src-tauri/src/commands/shacraft.rs b/src-tauri/src/commands/shacraft.rs index fea726d..e1f5e9d 100644 --- a/src-tauri/src/commands/shacraft.rs +++ b/src-tauri/src/commands/shacraft.rs @@ -13,10 +13,12 @@ async fn account_task( work: impl FnOnce(&Path) -> Result + Send + 'static, ) -> Result { let directory = data_dir(app)?; + let lifecycle = crate::update_guard::begin_operation(&directory, operations)?; let permit = operations .shacraft_account .acquire("ShaCraft account operation")?; tauri::async_runtime::spawn_blocking(move || { + let _lifecycle = lifecycle; let _permit = permit; work(&directory).map_err(|error| error.to_string()) }) diff --git a/src-tauri/src/commands/updater.rs b/src-tauri/src/commands/updater.rs new file mode 100644 index 0000000..51edf5a --- /dev/null +++ b/src-tauri/src/commands/updater.rs @@ -0,0 +1,158 @@ +//! No updater command accepts a URL, key, target, version, executable or arguments. +use super::data_dir; +use crate::{ + operations::LauncherOperations, + update_guard, + updater::{self, UpdateStatus, UpdaterState}, +}; +use tauri::{AppHandle, Manager, State}; + +fn pending(app: &AppHandle, state: &UpdaterState) -> Result, String> { + let operations = app.state::(); + if let Err(reason) = operations.ensure_writable() { + return Ok(Some(state.recovery(app, reason))); + } + let directory = data_dir(app)?; + match update_guard::pending_reason(&directory, env!("CARGO_PKG_VERSION")) { + Ok(Some(reason)) | Err(reason) => { + // Recovery discovered after startup is just as permanent for this + // process. External marker deletion cannot resume native writes. + operations.latch_recovery(reason.clone()); + Ok(Some(state.recovery(app, reason))) + } + Ok(None) => Ok(None), + } +} + +fn contain_worker_panic( + work: impl FnOnce() -> Result, + panic_message: &str, +) -> Result { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(work)) + .unwrap_or_else(|_| Err(panic_message.to_string())) +} + +fn report_failure(app: &AppHandle, state: &UpdaterState, error: String) -> UpdateStatus { + match pending(app, state) { + Ok(Some(recovery)) => recovery, + Ok(None) => state.fail(app, error), + Err(reason) => { + app.state::() + .latch_recovery(reason.clone()); + state.recovery(app, reason) + } + } +} + +#[tauri::command] +pub(crate) fn updater_status( + app: AppHandle, + state: State<'_, UpdaterState>, +) -> Result { + if state.critical() { + return Ok(state.status()); + } + Ok(pending(&app, &state)?.unwrap_or_else(|| state.status())) +} + +#[tauri::command] +pub(crate) async fn updater_check( + app: AppHandle, + state: State<'_, UpdaterState>, +) -> Result { + let permit = state.acquire()?; + if let Some(status) = pending(&app, &state)? { + return Ok(status); + } + let state = state.inner().clone(); + let worker_app = app.clone(); + let worker_state = state.clone(); + let worker = tauri::async_runtime::spawn_blocking(move || { + let _permit = permit; + // Publish failure from the worker even if its IPC caller disappeared. + contain_worker_panic( + || updater::check(&worker_app, &worker_state), + "Проверка обновления прервалась. Повторите проверку.", + ) + .unwrap_or_else(|error| report_failure(&worker_app, &worker_state, error)) + }); + Ok(worker.await.unwrap_or_else(|_| { + report_failure( + &app, + &state, + "Проверка обновления прервалась. Повторите проверку.".into(), + ) + })) +} + +#[tauri::command] +pub(crate) async fn updater_download_install( + app: AppHandle, + state: State<'_, UpdaterState>, +) -> Result { + let permit = state.acquire()?; + if let Some(status) = pending(&app, &state)? { + return Ok(status); + } + let directory = data_dir(&app)?; + let state = state.inner().clone(); + let worker_app = app.clone(); + let worker_state = state.clone(); + let worker = tauri::async_runtime::spawn_blocking(move || { + let _permit = permit; + contain_worker_panic( + || { + let operations = worker_app.state::(); + updater::download_install(&worker_app, &worker_state, &directory, &operations) + }, + "Установка обновления прервалась; проверьте состояние перед повторной попыткой.", + ) + .unwrap_or_else(|error| report_failure(&worker_app, &worker_state, error)) + }); + Ok(worker.await.unwrap_or_else(|_| { + report_failure( + &app, + &state, + "Установка обновления прервалась; проверьте состояние перед повторной попыткой.".into(), + ) + })) +} + +#[tauri::command] +pub(crate) fn updater_restart( + app: AppHandle, + state: State<'_, UpdaterState>, +) -> Result<(), String> { + let _permit = state.acquire()?; + if !state.ready() { + return Err("Нет завершённого обновления для перезапуска".into()); + } + app.restart() +} + +#[tauri::command] +pub(crate) fn updater_open_release_page() -> Result<(), String> { + updater::open_release_page() +} + +#[cfg(test)] +mod tests { + use super::contain_worker_panic; + use crate::operations::Operation; + + #[test] + fn panicking_worker_releases_owned_permit_and_returns_a_retryable_boundary_error() { + let operation = Operation::default(); + let permit = operation.acquire("test updater").unwrap(); + let result: Result<(), String> = contain_worker_panic( + move || { + let _permit = permit; + panic!("synthetic worker failure"); + }, + "worker interrupted", + ); + assert_eq!(result, Err("worker interrupted".into())); + assert!(operation.acquire("retry").is_ok()); + assert_eq!(contain_worker_panic(|| Ok(42), "unused"), Ok(42)); + } +} diff --git a/src-tauri/src/installation_lock.rs b/src-tauri/src/installation_lock.rs index 38e029c..4d60e66 100644 --- a/src-tauri/src/installation_lock.rs +++ b/src-tauri/src/installation_lock.rs @@ -71,6 +71,16 @@ pub(crate) struct InstallationLock { lease: PathBuf, } +impl Drop for InstallationLock { + fn drop(&mut self) { + // A concurrent Unix spawn can retain an inherited copy until exec. + // Release this owner's lock explicitly instead of waiting for every + // duplicate descriptor to close. Never remove the durable game lease. + // If unlock fails, closing the file still leaves the OS fail-closed. + let _ = self._file.unlock(); + } +} + impl InstallationLock { pub fn acquire(data_dir: &Path) -> Result { ordinary_path(data_dir)?; @@ -166,7 +176,10 @@ mod tests { let a = InstallationLock::acquire(&p).unwrap(); assert!(InstallationLock::acquire(&p).is_err()); drop(a); - assert!(InstallationLock::acquire(&p).is_ok()); + drop( + InstallationLock::acquire(&p) + .unwrap_or_else(|error| panic!("expected released lock: {error}")), + ); fs::remove_dir_all(p).unwrap(); } #[test] @@ -176,9 +189,8 @@ mod tests { a.starting().unwrap(); a.running(std::process::id()).unwrap(); drop(a); - assert!(InstallationLock::acquire(&p) - .unwrap_err_string() - .contains("Minecraft")); + let error = InstallationLock::acquire(&p).unwrap_err_string(); + assert!(error.contains("Minecraft"), "unexpected refusal: {error}"); fs::remove_dir_all(p).unwrap(); } #[test] @@ -193,9 +205,52 @@ mod tests { }) .unwrap(); drop(a); - assert!(InstallationLock::acquire(&p).is_ok()); + drop( + InstallationLock::acquire(&p) + .unwrap_or_else(|error| panic!("expected released lock: {error}")), + ); fs::remove_dir_all(p).unwrap(); } + #[test] + fn inherited_file_description_does_not_extend_owner_guard_lifetime() { + let p = dir(); + let guard = InstallationLock::acquire(&p).unwrap(); + guard + .store(&Lease::Running { + game: ProcessIdentity { + pid: std::process::id(), + started: 1, + }, + }) + .unwrap(); + // A concurrent Unix spawn inherits this same open file description + // until exec closes its CLOEXEC copy. try_clone deterministically keeps + // that description alive without relying on fork timing or sleeps. + let inherited = guard._file.try_clone().unwrap(); + assert!(InstallationLock::acquire(&p).is_err()); + drop(guard); + let reacquired = InstallationLock::acquire(&p) + .unwrap_or_else(|error| panic!("owner dropped but lock remained: {error}")); + assert!(!p.join("installation-state/game-lease.json").exists()); + drop(inherited); + drop(reacquired); + fs::remove_dir_all(p).unwrap(); + } + + #[test] + fn releasing_owner_lock_keeps_live_game_lease_with_inherited_description() { + let p = dir(); + let guard = InstallationLock::acquire(&p).unwrap(); + guard.running(std::process::id()).unwrap(); + let inherited = guard._file.try_clone().unwrap(); + drop(guard); + let error = InstallationLock::acquire(&p).unwrap_err_string(); + assert!(error.contains("Minecraft"), "unexpected refusal: {error}"); + assert!(p.join("installation-state/game-lease.json").exists()); + drop(inherited); + fs::remove_dir_all(p).unwrap(); + } + #[test] fn interrupted_spawn_fails_closed() { let p = dir(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4650d8b..05e638e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,7 @@ mod download; +mod update_guard; +mod updater; +use tauri::Manager; mod installation_lock; mod inventory; mod java; @@ -22,7 +25,42 @@ mod operations; pub fn run() { tauri::Builder::default() .manage(operations::LauncherOperations::default()) + .manage(updater::UpdaterState::default()) + .plugin( + tauri_plugin_updater::Builder::new() + .pubkey(updater::configured_key().unwrap_or("")) + .build(), + ) + .setup(|app| { + let directory = app.path().app_data_dir()?; + let instance = + update_guard::InstanceGuard::acquire(&directory, env!("CARGO_PKG_VERSION")) + .map_err(std::io::Error::other)?; + if let Some(reason) = instance.recovery_reason() { + app.state::() + .latch_recovery(reason); + } + app.manage(instance); + Ok(()) + }) + .on_window_event(|window, event| { + if let tauri::WindowEvent::CloseRequested { api, .. } = event { + if window + .app_handle() + .state::() + .lifecycle + .is_updating() + { + api.prevent_close(); + } + } + }) .invoke_handler(tauri::generate_handler![ + commands::updater::updater_status, + commands::updater::updater_check, + commands::updater::updater_download_install, + commands::updater::updater_restart, + commands::updater::updater_open_release_page, commands::host::native_host, commands::host::detect_java, commands::host::microsoft_login_available, diff --git a/src-tauri/src/operations.rs b/src-tauri/src/operations.rs index 5e73c32..d8a1fc8 100644 --- a/src-tauri/src/operations.rs +++ b/src-tauri/src/operations.rs @@ -3,12 +3,14 @@ //! Acquire before scheduling the worker and move the permit into it. Dropping //! the caller's future cannot unlock an operation that is still running. use std::sync::{ - atomic::{AtomicBool, Ordering}, - Arc, + atomic::{AtomicBool, AtomicUsize, Ordering}, + Arc, Mutex, }; #[derive(Default)] pub(crate) struct LauncherOperations { + pub lifecycle: Lifecycle, + recovery: Mutex>, pub installation: Operation, pub account: Operation, pub shacraft_account: Operation, @@ -50,3 +52,62 @@ mod tests { assert!(operation.acquire("Installation").is_ok()); } } + +// Owned, Send permits are held by the worker, not the awaiting IPC future. +// Readers represent all native operations which can persist or launch; the +// sole writer represents launcher replacement, including its download stage. +#[derive(Default)] +pub(crate) struct Lifecycle(Arc); +const EXCLUSIVE: usize = usize::MAX; +impl Lifecycle { + pub fn shared(&self) -> Result { + self.0 + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |n| { + if n < EXCLUSIVE - 1 { + Some(n + 1) + } else { + None + } + }) + .map_err(|_| "Лаунчер обновляется. Дождитесь завершения и перезапуска.".to_string())?; + Ok(SharedPermit(self.0.clone())) + } + pub fn exclusive(&self) -> Result { + self.0 + .compare_exchange(0, EXCLUSIVE, Ordering::AcqRel, Ordering::Acquire) + .map_err(|_| { + "Завершите операцию с игрой, настройками или аккаунтом и повторите обновление." + .to_string() + })?; + Ok(ExclusivePermit(self.0.clone())) + } + pub fn is_updating(&self) -> bool { + self.0.load(Ordering::Acquire) == EXCLUSIVE + } +} +pub(crate) struct SharedPermit(Arc); +impl Drop for SharedPermit { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::Release); + } +} +pub(crate) struct ExclusivePermit(Arc); +impl Drop for ExclusivePermit { + fn drop(&mut self) { + self.0.store(0, Ordering::Release); + } +} + +impl LauncherOperations { + /// Startup recovery is latched for this process. Removing a marker in a + /// running application is never authority to resume writes or launch. + pub fn latch_recovery(&self, reason: String) { + *self.recovery.lock().unwrap() = Some(reason); + } + pub fn ensure_writable(&self) -> Result<(), String> { + match self.recovery.lock().unwrap().as_ref() { + Some(reason) => Err(reason.clone()), + None => Ok(()), + } + } +} diff --git a/src-tauri/src/update_guard.rs b/src-tauri/src/update_guard.rs new file mode 100644 index 0000000..930f0e4 --- /dev/null +++ b/src-tauri/src/update_guard.rs @@ -0,0 +1,355 @@ +//! Launcher replacement is a distinct trust and lifecycle boundary from game +//! installation. A process-local gate drains all native writes; the game lock +//! also checks the durable detached-game lease. A handoff record survives the +//! Windows updater's immediate process exit and is never cleared by a timeout. +use crate::{ + installation_lock::InstallationLock, + operations::{ExclusivePermit, LauncherOperations, Permit}, +}; +use serde::{Deserialize, Serialize}; +use std::{ + fs::{self, File, OpenOptions}, + io, + path::{Path, PathBuf}, +}; + +// A renamed durable marker must survive a power loss before installer handoff. +// Unix directory fsync persists the name itself, in addition to write_atomic's +// fsync of the file contents. Windows uses its native file replacement semantics. +fn sync_directory(path: &Path) -> Result<(), String> { + #[cfg(unix)] + { + File::open(path) + .and_then(|file| file.sync_all()) + .map_err(|e| e.to_string())?; + } + #[cfg(not(unix))] + let _ = path; + Ok(()) +} + +const RECOVERY: &str = "Предыдущая установка обновления не завершена или её запись повреждена. Автоматическое продолжение заблокировано. Закройте игру, установщик и лаунчер, затем восстановите приложение официальным пакетом того же типа. Настройки и игровые файлы удалять не нужно."; + +fn ordinary(path: &Path) -> Result<(), String> { + match fs::symlink_metadata(path) { + Ok(meta) if meta.file_type().is_symlink() => { + Err("Служебный путь обновления является ссылкой".into()) + } + Ok(_) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.to_string()), + } +} + +fn directory(data: &Path) -> Result { + ordinary(data)?; + fs::create_dir_all(data).map_err(|e| e.to_string())?; + let path = data.join("launcher-state"); + ordinary(&path)?; + fs::create_dir_all(&path).map_err(|e| e.to_string())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).map_err(|e| e.to_string())?; + } + sync_directory(data)?; + if let Some(parent) = data.parent() { + sync_directory(parent)?; + } + Ok(path) +} + +/// Retained in Tauri state for the entire process lifetime. In particular an +/// idle second launcher cannot keep an old executable loaded during replacement. +pub(crate) struct InstanceGuard { + _file: File, + recovery_reason: Option, +} +impl Drop for InstanceGuard { + fn drop(&mut self) { + // Release the owner lock even if a concurrent spawn retains a temporary + // inherited file description before exec. Never clear the handoff marker. + let _ = self._file.unlock(); + } +} + +impl InstanceGuard { + pub fn recovery_reason(&self) -> Option { + self.recovery_reason.clone() + } + pub fn acquire(data: &Path, current_version: &str) -> Result { + let dir = directory(data)?; + let path = dir.join("instance.lock"); + ordinary(&path)?; + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let file = options.open(&path).map_err(|e| e.to_string())?; + let marker = dir.join("pending-update.json"); + let mut acquired = file.try_lock().is_ok(); + if !acquired && read_pending(&marker)?.is_some_and(|pending| pending.to == current_version) + { + // Tauri starts the replacement child before its old process exits. + // Only the exact recorded target may wait for that legitimate handoff. + // Never remove a lock or treat elapsed time as successful acquisition. + let until = std::time::Instant::now() + std::time::Duration::from_secs(5); + while !acquired && std::time::Instant::now() < until { + std::thread::sleep(std::time::Duration::from_millis(25)); + acquired = file.try_lock().is_ok(); + } + } + if !acquired { + return Err( + "ShaCraft Launcher уже запущен. Откройте его окно или завершите другой экземпляр." + .into(), + ); + } + let recovery_reason = match read_pending(&marker) { + // A corrupt/unreadable marker becomes explicit recovery state, + // NEVER Ready/None. Setup latches this reason before any IPC runs. + Err(reason) => Some(reason), + Ok(Some(pending)) if pending.to == current_version => fs::remove_file(marker) + .map_err(|error| error.to_string()) + .and_then(|()| sync_directory(&dir)) + .err() + .map(|error| format!("{RECOVERY} {error}")), + Ok(Some(pending)) => Some(format!("{RECOVERY} Ожидаемая версия: {}.", pending.to)), + Ok(None) => None, + }; + Ok(Self { + _file: file, + recovery_reason, + }) + } +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct PendingUpdate { + from: String, + to: String, +} + +fn read_pending(path: &Path) -> Result, String> { + ordinary(path)?; + match fs::read(path) { + Ok(bytes) if bytes.len() <= 1024 => serde_json::from_slice(&bytes) + .map(Some) + .map_err(|_| RECOVERY.to_string()), + Ok(_) => Err(RECOVERY.into()), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.to_string()), + } +} + +pub(crate) fn ensure_no_pending(data: &Path) -> Result<(), String> { + if let Some(pending) = read_pending(&directory(data)?.join("pending-update.json"))? { + return Err(format!("{RECOVERY} Ожидаемая версия: {}.", pending.to)); + } + Ok(()) +} + +pub(crate) fn pending_reason( + data: &Path, + _current_version: &str, +) -> Result, String> { + // Startup is the only place allowed to acknowledge a completed handoff. + // Read-only status must never remove a marker while an installer is active. + match ensure_no_pending(data) { + Ok(()) => Ok(None), + Err(reason) => Ok(Some(reason)), + } +} + +pub(crate) fn begin_operation( + data: &Path, + operations: &LauncherOperations, +) -> Result { + operations.ensure_writable()?; + let permit = operations.lifecycle.shared()?; + ensure_no_pending(data)?; + Ok(permit) +} + +pub(crate) struct UpdateGuard<'a> { + operations: &'a LauncherOperations, + directory: PathBuf, + _exclusive: ExclusivePermit, + _operation: Permit, + _installation: InstallationLock, +} +impl<'a> UpdateGuard<'a> { + pub fn acquire(data: &Path, operations: &'a LauncherOperations) -> Result { + operations.ensure_writable()?; + let exclusive = operations.lifecycle.exclusive()?; + let operation = operations.installation.acquire("Обновление лаунчера")?; + ensure_no_pending(data)?; + let installation = InstallationLock::acquire(data)?; + Ok(Self { + operations, + directory: directory(data)?, + _exclusive: exclusive, + _operation: operation, + _installation: installation, + }) + } + + /// Call only after all package checks and immediately before the platform + /// installer. Drop intentionally preserves this record on uncertain errors. + pub fn begin_install(&self, current_version: &str, target_version: &str) -> Result<(), String> { + let from = semver::Version::parse(current_version).map_err(|e| e.to_string())?; + let to = semver::Version::parse(target_version).map_err(|e| e.to_string())?; + if to <= from || !to.pre.is_empty() || !to.build.is_empty() { + return Err("Установка этой версии обновления запрещена".into()); + } + let path = self.directory.join("pending-update.json"); + ordinary(&path)?; + crate::storage::write_atomic( + &path, + &serde_json::to_vec(&PendingUpdate { + from: current_version.into(), + to: target_version.into(), + }) + .map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?; + self.operations + .latch_recovery(format!("{RECOVERY} Ожидаемая версия: {target_version}.")); + sync_directory(&self.directory) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(0); + fn dir() -> PathBuf { + std::env::temp_dir().join(format!( + "shacraft-update-lock-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )) + } + + #[test] + fn instance_owner_drop_releases_lock_despite_inherited_description() { + let data = dir(); + let owner = InstanceGuard::acquire(&data, "0.2.0").unwrap(); + let inherited = owner._file.try_clone().unwrap(); + assert!(InstanceGuard::acquire(&data, "0.2.0").is_err()); + drop(owner); + let next = InstanceGuard::acquire(&data, "0.2.0") + .unwrap_or_else(|error| panic!("owner instance lock remained: {error}")); + drop(inherited); + drop(next); + fs::remove_dir_all(data).unwrap(); + } + + #[test] + fn excludes_account_settings_and_game_writes_in_both_directions() { + let dir = dir(); + let state = LauncherOperations::default(); + let write = begin_operation(&dir, &state).unwrap(); + assert!(UpdateGuard::acquire(&dir, &state).is_err()); + drop(write); + let update = UpdateGuard::acquire(&dir, &state).unwrap(); + assert!(begin_operation(&dir, &state).is_err()); + assert!(UpdateGuard::acquire(&dir, &state).is_err()); + drop(update); + assert!(begin_operation(&dir, &state).is_ok()); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn checks_game_lease_after_launcher_has_exited() { + let dir = dir(); + let state = LauncherOperations::default(); + let game = InstallationLock::acquire(&dir).unwrap(); + game.running(std::process::id()).unwrap(); + drop(game); + assert!(UpdateGuard::acquire(&dir, &state).is_err()); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn failed_download_can_retry_but_installer_handoff_survives_exit() { + let dir = dir(); + let state = LauncherOperations::default(); + drop(UpdateGuard::acquire(&dir, &state).unwrap()); + let update = UpdateGuard::acquire(&dir, &state).unwrap(); + assert!(update.begin_install("0.2.0", "0.1.1").is_err()); + assert!(update.begin_install("0.2.0", "0.2.0").is_err()); + update.begin_install("0.2.0", "0.2.1").unwrap(); + drop(update); + assert!(begin_operation(&dir, &state).is_err()); + assert!(UpdateGuard::acquire(&dir, &state).is_err()); + let old = InstanceGuard::acquire(&dir, "0.2.0").unwrap(); + assert!(begin_operation(&dir, &state).is_err()); + drop(old); + let different = InstanceGuard::acquire(&dir, "0.3.0").unwrap(); + assert!(begin_operation(&dir, &state).is_err()); + drop(different); + let updated = InstanceGuard::acquire(&dir, "0.2.1").unwrap(); + assert!(begin_operation(&dir, &state).is_err()); // old process stays latched + assert!(begin_operation(&dir, &LauncherOperations::default()).is_ok()); + drop(updated); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn target_process_waits_for_real_lock_release_during_restart() { + let dir = dir(); + let instance = InstanceGuard::acquire(&dir, "0.2.0").unwrap(); + let state = LauncherOperations::default(); + let update = UpdateGuard::acquire(&dir, &state).unwrap(); + update.begin_install("0.2.0", "0.2.1").unwrap(); + drop(update); + // Model Tauri's spawn-before-exit with an OS lock held by the old owner. + let next_dir = dir.clone(); + let next = std::thread::spawn(move || InstanceGuard::acquire(&next_dir, "0.2.1")); + std::thread::sleep(std::time::Duration::from_millis(75)); + assert!(!next.is_finished()); + drop(instance); + let target = next.join().unwrap().unwrap(); + assert!(target.recovery_reason().is_none()); + assert!(begin_operation(&dir, &state).is_err()); + assert!(begin_operation(&dir, &LauncherOperations::default()).is_ok()); + drop(target); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn rejects_second_idle_instance_and_malformed_marker() { + let dir = dir(); + let instance = InstanceGuard::acquire(&dir, "0.2.0").unwrap(); + assert!(InstanceGuard::acquire(&dir, "0.2.0").is_err()); + drop(instance); + fs::write( + directory(&dir).unwrap().join("pending-update.json"), + b"invalid", + ) + .unwrap(); + let recovery = InstanceGuard::acquire(&dir, "0.2.1").unwrap(); + let state = LauncherOperations::default(); + state.latch_recovery( + recovery + .recovery_reason() + .expect("corruption must be explicit recovery"), + ); + // Read-only diagnostics are available, while every shared write and + // updater install remains denied, including after external deletion. + assert!(pending_reason(&dir, "0.2.1").unwrap().is_some()); + assert!(begin_operation(&dir, &state).is_err()); + assert!(UpdateGuard::acquire(&dir, &state).is_err()); + fs::remove_file(directory(&dir).unwrap().join("pending-update.json")).unwrap(); + assert!(begin_operation(&dir, &state).is_err()); + assert!(UpdateGuard::acquire(&dir, &state).is_err()); + drop(recovery); + fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/src-tauri/src/updater.rs b/src-tauri/src/updater.rs new file mode 100644 index 0000000..0d9a7df --- /dev/null +++ b/src-tauri/src/updater.rs @@ -0,0 +1,493 @@ +//! Native-only launcher updater. The webview controls timing, never trust inputs. +mod format; +mod protocol; +use crate::{ + operations::{LauncherOperations, Operation, Permit}, + update_guard::UpdateGuard, +}; +use protocol::{Artifact, VerifiedRelease}; +use reqwest::blocking::Client; +use serde::Serialize; +use std::{ + path::Path, + process::{Command, Stdio}, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; +use tauri::{AppHandle, Emitter}; +use tauri_plugin_updater::UpdaterExt; + +#[derive(Clone, Serialize, Debug, PartialEq)] +#[serde(rename_all = "snake_case")] +pub(crate) enum Phase { + Idle, + Checking, + Available, + Downloading, + Verifying, + Installing, + Ready, + NoUpdate, + Unconfigured, + Manual, + Error, +} + +#[derive(Clone, Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UpdateStatus { + revision: u64, + installed_version: String, + package_format: &'static str, + phase: Phase, + available_version: Option, + release_notes: Option, + downloaded_bytes: u64, + total_bytes: Option, + can_retry: bool, + message: Option, + test_build: bool, +} + +struct StateData { + status: UpdateStatus, + candidate: Option, +} + +#[derive(Clone)] +pub(crate) struct UpdaterState { + inner: Arc>, + operation: Operation, +} +impl Default for UpdaterState { + fn default() -> Self { + let configured = configured_key().is_some(); + Self { + inner: Arc::new(Mutex::new(StateData { + status: UpdateStatus { + revision: 0, + installed_version: env!("CARGO_PKG_VERSION").into(), + package_format: format::installed_label(), + phase: if configured { Phase::Idle } else { Phase::Unconfigured }, + available_version: None, release_notes: None, downloaded_bytes: 0, total_bytes: None, + can_retry: false, + message: (!configured).then(|| "Подписанные обновления ещё не настроены для этой сборки. Официальные выпуски доступны на GitHub.".into()), + test_build: protocol::test_build(), + }, + candidate: None, + })), + operation: Operation::default(), + } + } +} + +pub(crate) fn configured_key() -> Option<&'static str> { + protocol::configured_key() +} + +impl UpdaterState { + pub fn status(&self) -> UpdateStatus { + self.inner.lock().unwrap().status.clone() + } + pub fn acquire(&self) -> Result { + self.operation.acquire("Обновление лаунчера") + } + fn change(&self, app: &AppHandle, update: impl FnOnce(&mut StateData)) -> UpdateStatus { + let status = { + let mut data = self.inner.lock().unwrap(); + update(&mut data); + data.status.revision += 1; + data.status.clone() + }; + let _ = app.emit("launcher-update-status", &status); + status + } + fn phase(&self, app: &AppHandle, phase: Phase, message: Option) -> UpdateStatus { + self.change(app, |data| { + data.status.can_retry = phase == Phase::Error; + data.status.phase = phase; + data.status.message = message; + }) + } + pub fn fail(&self, app: &AppHandle, error: String) -> UpdateStatus { + self.change(app, |data| { + data.status.can_retry = data.status.phase != Phase::Installing; + data.status.phase = Phase::Error; + data.status.message = Some(error); + if !data.status.can_retry { + data.candidate = None; + } + }) + } + pub fn recovery(&self, app: &AppHandle, reason: String) -> UpdateStatus { + self.change(app, |data| { + data.candidate = None; + data.status.phase = Phase::Error; + data.status.can_retry = false; + data.status.message = Some(reason); + }) + } + pub fn may_check(&self) -> bool { + !matches!( + self.status().phase, + Phase::Downloading | Phase::Verifying | Phase::Installing | Phase::Ready + ) + } + pub fn ready(&self) -> bool { + self.status().phase == Phase::Ready + } + pub fn critical(&self) -> bool { + matches!( + self.status().phase, + Phase::Downloading | Phase::Verifying | Phase::Installing | Phase::Ready + ) + } +} + +#[derive(Clone, Debug, PartialEq)] +enum PackageMode { + Automatic { platform: &'static str, msi: bool }, + Manual, +} + +fn package_mode( + os: &str, + arch: &str, + bundle: Option, +) -> PackageMode { + use tauri::utils::config::BundleType; + match (os, arch, bundle) { + ("linux", "x86_64", Some(BundleType::AppImage)) => PackageMode::Automatic { + platform: "linux-x86_64", + msi: false, + }, + ("windows", "x86_64", Some(BundleType::Nsis)) => PackageMode::Automatic { + platform: "windows-x86_64", + msi: false, + }, + ("windows", "x86_64", Some(BundleType::Msi)) => PackageMode::Automatic { + platform: "windows-x86_64", + msi: true, + }, + ("macos", "aarch64", Some(BundleType::App)) => PackageMode::Automatic { + platform: "darwin-aarch64", + msi: false, + }, + ("macos", "x86_64", Some(BundleType::App)) => PackageMode::Automatic { + platform: "darwin-x86_64", + msi: false, + }, + _ => PackageMode::Manual, + } +} + +fn current_mode() -> PackageMode { + // Bare binaries, distro packages and dev runs must never be overwritten as an AppImage/.app. + if cfg!(debug_assertions) { + return PackageMode::Manual; + } + #[cfg(target_os = "macos")] + { + let app_bundle = std::env::current_exe() + .ok() + .and_then(|path| path.parent().map(|p| p.ends_with("Contents/MacOS"))) + .unwrap_or(false); + if !app_bundle { + return PackageMode::Manual; + } + } + package_mode( + std::env::consts::OS, + std::env::consts::ARCH, + tauri::utils::platform::bundle_type(), + ) +} + +fn client() -> Result { + Client::builder() + .https_only(true) + .connect_timeout(Duration::from_secs(15)) + .timeout(Duration::from_secs(300)) + .user_agent("ShaCraft-Launcher-Updater/1") + .redirect(reqwest::redirect::Policy::custom(|attempt| { + if attempt.previous().len() <= 5 && protocol::redirect_allowed(attempt.url()) { + attempt.follow() + } else { + attempt.error("Untrusted update redirect") + } + })) + .build() + .map_err(|_| "Не удалось подготовить соединение для обновлений".into()) +} + +fn response_bytes( + client: &Client, + url: &str, + limit: u64, + progress: impl FnMut(u64), +) -> Result>, String> { + let response = client + .get(url) + .send() + .map_err(|_| "Не удалось связаться с GitHub. Проверьте сеть и повторите проверку.")?; + if response.status() == reqwest::StatusCode::NO_CONTENT { + return Ok(None); + } + if !response.status().is_success() { + return Err(format!( + "GitHub не отдал обновление (HTTP {}). Повторите позже.", + response.status().as_u16() + )); + } + if response.content_length().is_some_and(|size| size > limit) { + return Err("Размер ответа превышает подписанный предел".into()); + } + protocol::read_bounded(response, limit, progress).map(Some) +} + +pub(crate) fn check(app: &AppHandle, state: &UpdaterState) -> Result { + if !state.may_check() { + return Err("Сначала завершите текущее обновление лаунчера".into()); + } + let Some(key) = configured_key() else { + return Ok(state.phase( + app, + Phase::Unconfigured, + Some("Подписанные обновления ещё не настроены для этой сборки.".into()), + )); + }; + state.change(app, |data| { + data.candidate = None; + data.status.phase = Phase::Checking; + data.status.can_retry = false; + data.status.message = None; + data.status.downloaded_bytes = 0; + data.status.total_bytes = None; + data.status.available_version = None; + data.status.release_notes = None; + }); + let client = client()?; + let Some(verified) = protocol::fetch_release( + |url, limit| response_bytes(&client, url, limit, |_| {}), + key, + env!("CARGO_PKG_VERSION"), + )? + else { + return Ok(state.phase(app, Phase::NoUpdate, None)); + }; + let manual = current_mode() == PackageMode::Manual; + Ok(state.change(app, |data| { + data.status.available_version = Some(verified.release.version.clone()); + data.status.release_notes = Some(verified.release.notes.clone()); + data.status.phase = if manual { Phase::Manual } else { Phase::Available }; + data.status.message = manual.then(|| "Эта сборка обновляется вручную. Для .deb используйте менеджер пакетов; AppImage и установленные Windows/macOS пакеты поддерживают обновление внутри лаунчера.".into()); + data.candidate = Some(verified); + })) +} + +fn select_artifact(release: &VerifiedRelease, mode: &PackageMode) -> Result { + match mode { + PackageMode::Automatic { platform, msi } => { + let artifact = if *msi { + release.release.manual_packages.get("windows-x86_64-msi") + } else { + release.release.platforms.get(*platform) + }; + artifact + .cloned() + .ok_or_else(|| "Нет подписанного пакета для текущей платформы".into()) + } + PackageMode::Manual => { + Err("Эту сборку необходимо обновить вручную через официальный выпуск".into()) + } + } +} + +pub(crate) fn download_install( + app: &AppHandle, + state: &UpdaterState, + directory: &Path, + operations: &LauncherOperations, +) -> Result { + let key = configured_key().ok_or("Подписанные обновления не настроены")?; + let release = state + .inner + .lock() + .unwrap() + .candidate + .clone() + .ok_or("Сначала проверьте доступность обновления")?; + if !release.is_newer_than(env!("CARGO_PKG_VERSION"))? { + return Err("Эта версия уже установлена".into()); + } + let mode = current_mode(); + let artifact = select_artifact(&release, &mode)?; + // Includes cross-process game lease and lifecycle exclusion; held through restart/handoff. + let guard = UpdateGuard::acquire(directory, operations)?; + state.change(app, |data| { + data.status.phase = Phase::Downloading; + data.status.can_retry = false; + data.status.message = None; + data.status.downloaded_bytes = 0; + data.status.total_bytes = Some(artifact.size); + }); + let client = client()?; + let mut last = Instant::now(); + let bytes = response_bytes(&client, &artifact.url, artifact.size, |count| { + if last.elapsed() >= Duration::from_millis(100) || count == artifact.size { + state.change(app, |data| { + data.status.downloaded_bytes = count; + }); + last = Instant::now(); + } + })? + .ok_or("Сервер не вернул пакет обновления")?; + state.phase(app, Phase::Verifying, None); + protocol::verify_artifact(&bytes, &artifact, key)?; + format::verify(&mode, &bytes)?; + let platform = match mode { + PackageMode::Automatic { platform, .. } => platform, + PackageMode::Manual => unreachable!(), + }; + // The plugin's constructor is private. Its check creates the native installer + // context from a fixed version URL; accept only the signed metadata already read. + let builder = app + .updater_builder() + .pubkey(key) + .target(platform) + .endpoints(vec![release + .pinned_endpoint() + .parse() + .map_err(|_| "Некорректный адрес выпуска")?]) + .map_err(|_| "Не удалось настроить установщик обновления")? + .configure_client(|builder| { + builder + .https_only(true) + .connect_timeout(Duration::from_secs(15)) + .timeout(Duration::from_secs(30)) + .redirect(reqwest_updater::redirect::Policy::custom(|attempt| { + if attempt.previous().len() <= 5 && protocol::redirect_allowed(attempt.url()) { + attempt.follow() + } else { + attempt.error("Untrusted update redirect") + } + })) + }); + let mut update = tauri::async_runtime::block_on( + builder + .build() + .map_err(|_| "Не удалось подготовить установщик")? + .check(), + ) + .map_err(|_| "Не удалось сверить подписанный выпуск с установщиком. Повторите попытку.")? + .ok_or("Подписанный выпуск больше не доступен установщику")?; + if update.raw_json != release.json + || update.version != release.release.version + || update.target != platform + { + return Err( + "Метаданные выпуска изменились. Установка остановлена; проверьте обновления заново." + .into(), + ); + } + // MSI installations stay MSI; the signed descriptor is never supplied by JS. + update.download_url = artifact + .url + .parse() + .map_err(|_| "Некорректный адрес пакета")?; + update.signature = artifact.signature.clone(); + // Update::install does NOT verify bytes itself. Keep this immediately before it. + protocol::install_verified(&bytes, &artifact, key, |verified_bytes| { + guard.begin_install(env!("CARGO_PKG_VERSION"), &release.release.version)?; + state.phase( + app, + Phase::Installing, + Some("Лаунчер перезапустится после установки. Не выключайте компьютер.".into()), + ); + update.install(verified_bytes).map_err(|_| "Установка прервалась. Запись о незавершённом обновлении сохранена; следуйте инструкции восстановления.".to_string()) + })?; + // Windows exits inside plugin install after handing off to NSIS/MSI. There is + // no installer PID API; the persistent lifecycle marker guards the new process. + state.phase( + app, + Phase::Ready, + Some("Обновление установлено. Перезапускаем лаунчер.".into()), + ); + app.restart() +} + +pub(crate) fn open_release_page() -> Result<(), String> { + // Fixed executable/argument structure and URL; no shell or webview-supplied input. + #[cfg(target_os = "linux")] + let mut command = { + let mut command = Command::new("xdg-open"); + command.arg(protocol::RELEASES); + command + }; + #[cfg(target_os = "macos")] + let mut command = { + let mut command = Command::new("open"); + command.arg(protocol::RELEASES); + command + }; + #[cfg(target_os = "windows")] + let mut command = { + let mut command = Command::new("rundll32.exe"); + command.args(["url.dll,FileProtocolHandler", protocol::RELEASES]); + command + }; + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map(|_| ()) + .map_err(|_| "Не удалось открыть страницу официальных выпусков".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tauri::utils::config::BundleType; + #[test] + fn package_formats_never_cross_installers_or_architectures() { + assert_eq!( + package_mode("linux", "x86_64", Some(BundleType::Deb)), + PackageMode::Manual + ); + assert_eq!(package_mode("linux", "x86_64", None), PackageMode::Manual); + assert_eq!( + package_mode("windows", "aarch64", Some(BundleType::Nsis)), + PackageMode::Manual + ); + assert_eq!( + package_mode("windows", "x86_64", Some(BundleType::Msi)), + PackageMode::Automatic { + platform: "windows-x86_64", + msi: true + } + ); + assert_eq!( + package_mode("macos", "aarch64", Some(BundleType::App)), + PackageMode::Automatic { + platform: "darwin-aarch64", + msi: false + } + ); + assert_eq!( + package_mode("macos", "x86_64", Some(BundleType::App)), + PackageMode::Automatic { + platform: "darwin-x86_64", + msi: false + } + ); + } + #[test] + fn updater_single_flight_releases_after_failure() { + let state = UpdaterState::default(); + let permit = state.acquire().unwrap(); + assert!(state.acquire().is_err()); + drop(permit); + assert!(state.acquire().is_ok()); + } +} diff --git a/src-tauri/src/updater/format.rs b/src-tauri/src/updater/format.rs new file mode 100644 index 0000000..b476fb8 --- /dev/null +++ b/src-tauri/src/updater/format.rs @@ -0,0 +1,94 @@ +//! Do not let the plugin's byte sniffing select a different Windows installer +//! than the installed package. Metadata, signature and these checks all agree. +use super::PackageMode; + +pub(super) fn verify(mode: &PackageMode, bytes: &[u8]) -> Result<(), String> { + let valid = match mode { + PackageMode::Automatic { + platform: "windows-x86_64", + msi: true, + } => bytes.starts_with(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"), + PackageMode::Automatic { + platform: "windows-x86_64", + msi: false, + } => { + let pe = bytes + .get(0x3c..0x40) + .map(|raw| u32::from_le_bytes(raw.try_into().unwrap()) as usize); + bytes.starts_with(b"MZ") + && pe.and_then(|at| at.checked_add(4).and_then(|end| bytes.get(at..end))) + == Some(b"PE\0\0".as_slice()) + } + PackageMode::Automatic { + platform: "linux-x86_64", + msi: false, + } => { + bytes.starts_with(b"\x7fELF\x02\x01") // ELF64, little-endian + && bytes.get(8..11) == Some(b"AI\x02".as_slice()) // AppImage Type2 magic + && bytes.get(18..20) == Some(b"\x3e\x00".as_slice()) + } // EM_X86_64 + PackageMode::Automatic { + platform: "darwin-aarch64" | "darwin-x86_64", + msi: false, + } => bytes.starts_with(b"\x1f\x8b\x08"), // gzip archive; actual .app architecture is signed in metadata + _ => false, + }; + if valid { + Ok(()) + } else { + Err("Формат подписанного пакета не соответствует установленному лаунчеру. Автоматическая смена типа установки запрещена.".into()) + } +} + +pub(super) fn installed_label() -> &'static str { + use tauri::utils::{config::BundleType, platform::bundle_type}; + if cfg!(debug_assertions) { + return "development"; + } + match bundle_type() { + Some(BundleType::AppImage) => "AppImage", + Some(BundleType::Deb) => "deb", + Some(BundleType::Rpm) => "rpm", + Some(BundleType::Msi) => "MSI", + Some(BundleType::Nsis) => "NSIS", + Some(BundleType::App | BundleType::Dmg) => "app", + None => "unpackaged", + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn mode(platform: &'static str, msi: bool) -> PackageMode { + PackageMode::Automatic { platform, msi } + } + #[test] + fn windows_installers_cannot_silently_switch_formats() { + let msi = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"; + let mut exe = vec![0; 96]; + exe[..2].copy_from_slice(b"MZ"); + exe[0x3c] = 64; + exe[64..68].copy_from_slice(b"PE\0\0"); + assert!(verify(&mode("windows-x86_64", true), msi).is_ok()); + assert!(verify(&mode("windows-x86_64", false), &exe).is_ok()); + assert!(verify(&mode("windows-x86_64", true), &exe).is_err()); + assert!(verify(&mode("windows-x86_64", false), msi).is_err()); + exe[0x3c..0x40].fill(255); // malformed offset cannot panic/wrap + assert!(verify(&mode("windows-x86_64", false), &exe).is_err()); + } + #[test] + fn linux_requires_the_expected_appimage_arch_and_mac_requires_archive() { + let mut image = vec![0; 32]; + image[..6].copy_from_slice(b"\x7fELF\x02\x01"); + image[8..11].copy_from_slice(b"AI\x02"); + image[18..20].copy_from_slice(b"\x3e\x00"); + assert!(verify(&mode("linux-x86_64", false), &image).is_ok()); + image[18] = 183; // ARM64 ELF is never an x86_64 update. + assert!(verify(&mode("linux-x86_64", false), &image).is_err()); + for platform in ["darwin-aarch64", "darwin-x86_64"] { + assert!(verify(&mode(platform, false), b"\x1f\x8b\x08").is_ok()); + assert!(verify(&mode(platform, false), b"MSI").is_err()); + } + assert!(verify(&PackageMode::Manual, b"\x1f\x8b\x08").is_err()); + } +} diff --git a/src-tauri/src/updater/protocol.rs b/src-tauri/src/updater/protocol.rs new file mode 100644 index 0000000..ee2da8f --- /dev/null +++ b/src-tauri/src/updater/protocol.rs @@ -0,0 +1,294 @@ +//! Signed launcher releases are a separate trust domain from the ShaCraft pack. +//! Authenticate the exact metadata bytes before parsing any URLs or versions. +use base64::{engine::general_purpose::STANDARD, Engine}; +use minisign_verify::{PublicKey, Signature}; +use semver::Version; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::{collections::BTreeMap, io::Read}; +use url::Url; + +pub(crate) const RELEASES: &str = "https://github.com/emil28092005/shacraft-launcher/releases"; +pub(crate) const LATEST: &str = + "https://github.com/emil28092005/shacraft-launcher/releases/latest/download/latest.json"; +pub(crate) const MAX_METADATA: u64 = 32768; +pub(crate) const MAX_SIGNATURE: u64 = 2048; +pub(crate) const MAX_PACKAGE: u64 = 1024 * 1024 * 1024; +const PINNED_KEY: &str = include_str!("../../updater-public-key.txt"); +pub(crate) fn test_build() -> bool { + option_env!("SHACRAFT_UPDATER_TEST_BUILD") == Some("1") +} + +pub(crate) fn configured_key() -> Option<&'static str> { + let injected = option_env!("SHACRAFT_UPDATER_PUBLIC_KEY").map(str::trim); + let pinned = PINNED_KEY.trim(); + // Explicit CI-only builds may use a disposable real signing key. Release CI + // forbids this switch; every such binary exposes its test provenance in status. + if test_build() { + return injected.filter(|key| key_is_valid(key)); + } + if key_is_valid(pinned) && injected.is_none_or(|key| key == pinned) { + Some(pinned) + } else { + None + } +} + +const PLATFORMS: [(&str, &str); 4] = [ + ("windows-x86_64", "windows-x86_64-setup.exe"), + ("linux-x86_64", "linux-x86_64.AppImage"), + ("darwin-aarch64", "darwin-aarch64.app.tar.gz"), + ("darwin-x86_64", "darwin-x86_64.app.tar.gz"), +]; +const MANUAL: [(&str, &str); 4] = [ + ("windows-x86_64-msi", "windows-x86_64.msi"), + ("linux-x86_64-deb", "linux-x86_64.deb"), + ("darwin-aarch64-dmg", "darwin-aarch64.dmg"), + ("darwin-x86_64-dmg", "darwin-x86_64.dmg"), +]; + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Artifact { + pub url: String, + pub signature: String, + pub sha256: String, + pub size: u64, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct Release { + pub schema_version: u32, + pub version: String, + pub tag: String, + pub notes: String, + #[serde(rename = "pub_date")] + pub pub_date: String, + pub platforms: BTreeMap, + pub manual_packages: BTreeMap, +} + +#[derive(Clone)] +pub(crate) struct VerifiedRelease { + pub release: Release, + pub json: serde_json::Value, +} + +fn decode_text(encoded: &str) -> Result { + let bytes = STANDARD + .decode(encoded.trim()) + .map_err(|_| "Некорректный формат подписи обновления")?; + String::from_utf8(bytes).map_err(|_| "Некорректный формат подписи обновления".into()) +} + +pub(crate) fn key_is_valid(key: &str) -> bool { + key.len() <= MAX_SIGNATURE as usize + && decode_text(key) + .ok() + .and_then(|text| PublicKey::decode(&text).ok()) + .is_some() +} + +pub(crate) fn verify_signature(bytes: &[u8], signature: &str, key: &str) -> Result<(), String> { + if signature.len() > MAX_SIGNATURE as usize || key.len() > MAX_SIGNATURE as usize { + return Err("Некорректный размер подписи обновления".into()); + } + let public = PublicKey::decode(&decode_text(key)?) + .map_err(|_| "Не настроен доверенный ключ обновлений")?; + let signature = Signature::decode(&decode_text(signature)?) + .map_err(|_| "Некорректный формат подписи обновления")?; + // Same minisign primitive/legacy compatibility as tauri-plugin-updater 2.11.0. + public + .verify(bytes, &signature, true) + .map_err(|_| "Подпись обновления не прошла проверку".into()) +} + +fn stable_version(value: &str) -> Result { + let v = Version::parse(value).map_err(|_| "Некорректная версия обновления")?; + if !v.pre.is_empty() + || !v.build.is_empty() + || v.to_string() != value + || v.major > 255 + || v.minor > 255 + || v.patch > 65535 + { + return Err("Разрешены только стабильные версии обновлений".into()); + } + Ok(v) +} + +impl VerifiedRelease { + pub fn parse(bytes: &[u8], signature: &str, key: &str) -> Result { + if bytes.len() as u64 > MAX_METADATA { + return Err("Слишком большой список обновлений".into()); + } + verify_signature(bytes, signature, key)?; + let release: Release = serde_json::from_slice(bytes) + .map_err(|_| "Некорректные подписанные метаданные обновления")?; + stable_version(&release.version)?; + if release.schema_version != 1 + || release.tag != format!("v{}", release.version) + || release.notes.len() > 4096 + || release.pub_date.len() != 20 + || !release.pub_date.ends_with('Z') + { + return Err("Неподдерживаемые метаданные обновления".into()); + } + let date = time::OffsetDateTime::parse( + &release.pub_date, + &time::format_description::well_known::Rfc3339, + ) + .map_err(|_| "Некорректная дата подписанного выпуска")?; + if date + .format(&time::format_description::well_known::Rfc3339) + .map_err(|_| "Некорректная дата выпуска")? + != release.pub_date + { + return Err("Некорректная дата подписанного выпуска".into()); + } + validate_artifacts(&release.platforms, &PLATFORMS, &release)?; + validate_artifacts(&release.manual_packages, &MANUAL, &release)?; + Ok(Self { + release, + json: serde_json::from_slice(bytes).map_err(|_| "Некорректные метаданные")?, + }) + } + + pub fn is_newer_than(&self, current: &str) -> Result { + let available = stable_version(&self.release.version)?; + let installed = stable_version(current)?; + if available < installed { + return Err( + "Сервер предложил более старую версию. Понижение версии заблокировано.".into(), + ); + } + Ok(available > installed) + } + + pub fn pinned_endpoint(&self) -> String { + format!("{RELEASES}/download/{}/latest.json", self.release.tag) + } +} + +fn validate_artifacts( + values: &BTreeMap, + expected: &[(&str, &str)], + release: &Release, +) -> Result<(), String> { + if values.len() != expected.len() { + return Err("Неполный список платформ обновления".into()); + } + for (platform, suffix) in expected { + let artifact = values + .get(*platform) + .ok_or("Отсутствует ожидаемая платформа обновления")?; + let expected_url = format!( + "{RELEASES}/download/{}/shacraft-launcher_{}_{}", + release.tag, release.version, suffix + ); + if artifact.url != expected_url + || artifact.size == 0 + || artifact.size > MAX_PACKAGE + || artifact.sha256.len() != 64 + || !artifact + .sha256 + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + || artifact.signature.is_empty() + || artifact.signature.len() > MAX_SIGNATURE as usize + { + return Err("Неверная привязка пакета к версии, платформе или репозиторию".into()); + } + let text = decode_text(&artifact.signature)?; + Signature::decode(&text).map_err(|_| "Некорректная подпись пакета обновления")?; + } + Ok(()) +} + +/// Only these GitHub release hosts may participate in HTTPS redirects. CDN query +/// parameters are GitHub's signed delivery URLs; initial URLs are exact literals. +pub(crate) fn redirect_allowed(url: &Url) -> bool { + if url.scheme() != "https" + || !url.username().is_empty() + || url.password().is_some() + || url.port_or_known_default() != Some(443) + || url.fragment().is_some() + { + return false; + } + match url.host_str() { + Some("github.com") => { + url.query().is_none() + && url + .path() + .starts_with("/emil28092005/shacraft-launcher/releases/") + } + Some("release-assets.githubusercontent.com") => true, + _ => false, + } +} + +pub(crate) fn read_bounded( + mut reader: impl Read, + limit: u64, + mut progress: impl FnMut(u64), +) -> Result, String> { + let mut result = Vec::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let count = reader + .read(&mut buffer) + .map_err(|_| "Соединение прервалось. Повторите загрузку обновления.")?; + if count == 0 { + break; + } + if result.len() as u64 + count as u64 > limit { + return Err("Размер ответа превышает подписанный предел".into()); + } + result.extend_from_slice(&buffer[..count]); + progress(result.len() as u64); + } + Ok(result) +} + +pub(crate) fn verify_artifact(bytes: &[u8], artifact: &Artifact, key: &str) -> Result<(), String> { + if bytes.len() as u64 != artifact.size + || format!("{:x}", Sha256::digest(bytes)) != artifact.sha256 + { + return Err( + "Размер или SHA-256 пакета обновления не совпал. Установка остановлена.".into(), + ); + } + verify_signature(bytes, &artifact.signature, key) +} + +pub(crate) fn install_verified( + bytes: &[u8], + artifact: &Artifact, + key: &str, + installer: impl FnOnce(&[u8]) -> Result, +) -> Result { + verify_artifact(bytes, artifact, key)?; + installer(bytes) +} + +/// A narrow injectable read boundary; production uses only the fixed HTTPS client. +pub(crate) fn fetch_release( + mut fetch: impl FnMut(&str, u64) -> Result>, String>, + key: &str, + current: &str, +) -> Result, String> { + let Some(bytes) = fetch(LATEST, MAX_METADATA)? else { + return Ok(None); + }; + let signature = fetch(&format!("{LATEST}.sig"), MAX_SIGNATURE)? + .ok_or("Отсутствует подпись списка обновлений")?; + let signature = + std::str::from_utf8(&signature).map_err(|_| "Некорректная подпись списка обновлений")?; + let verified = VerifiedRelease::parse(&bytes, signature, key)?; + Ok(verified.is_newer_than(current)?.then_some(verified)) +} + +#[cfg(test)] +mod tests; diff --git a/src-tauri/src/updater/protocol/tests.rs b/src-tauri/src/updater/protocol/tests.rs new file mode 100644 index 0000000..ebefa50 --- /dev/null +++ b/src-tauri/src/updater/protocol/tests.rs @@ -0,0 +1,204 @@ +use super::*; +use std::{ + cell::Cell, + collections::VecDeque, + io::{self, Cursor}, +}; + +const KEY: &str = include_str!("../../../tests/fixtures/updater/public-key.txt"); +const METADATA: &[u8] = include_bytes!("../../../tests/fixtures/updater/latest.json"); +const SIG: &str = include_str!("../../../tests/fixtures/updater/latest.json.sig"); +const PACKAGE: &[u8] = include_bytes!("../../../tests/fixtures/updater/package.txt"); + +fn release() -> VerifiedRelease { + VerifiedRelease::parse(METADATA, SIG, KEY).unwrap() +} + +#[test] +fn real_tauri_signatures_authenticate_metadata_and_fake_installer_input() { + assert!(key_is_valid(KEY)); + assert!(!key_is_valid("unconfigured")); + let release = release(); + let artifact = &release.release.platforms["linux-x86_64"]; + let calls = Cell::new(0); + install_verified(PACKAGE, artifact, KEY, |bytes| { + calls.set(calls.get() + 1); + assert_eq!(bytes, PACKAGE); + Ok(()) + }) + .unwrap(); + assert_eq!(calls.get(), 1); + // Real signed test data only. This fake installer never executes the fixture. +} + +#[test] +fn tampered_metadata_version_platform_or_package_cannot_reach_installer() { + for (from, to) in [ + ("0.3.0", "0.9.0"), + ("darwin-aarch64", "darwin-mips64"), + ("shacraft-launcher/releases", "other-repository/releases"), + ] { + let changed = String::from_utf8(METADATA.to_vec()) + .unwrap() + .replace(from, to); + assert!(VerifiedRelease::parse(changed.as_bytes(), SIG, KEY).is_err()); + } + let release = release(); + let artifact = &release.release.platforms["linux-x86_64"]; + let calls = Cell::new(0); + let mut corrupt = PACKAGE.to_vec(); + corrupt[0] ^= 1; + assert!(install_verified(&corrupt, artifact, KEY, |_| { + calls.set(1); + Ok(()) + }) + .is_err()); + let mut wrong_signature = artifact.clone(); + wrong_signature.signature = release.release.platforms["windows-x86_64"] + .signature + .clone(); + assert!(install_verified(PACKAGE, &wrong_signature, KEY, |_| { + calls.set(1); + Ok(()) + }) + .is_err()); + assert_eq!(calls.get(), 0); +} + +#[test] +fn same_version_is_no_update_and_downgrades_or_prereleases_are_rejected() { + let release = release(); + assert!(release.is_newer_than("0.2.0").unwrap()); + assert!(!release.is_newer_than("0.3.0").unwrap()); + assert!(release.is_newer_than("0.4.0").is_err()); + for bad in [ + "v0.3.0", + "0.3.0-rc.1", + "0.3.0+other", + "01.2.3", + "256.0.0", + "0.256.0", + "0.0.65536", + ] { + assert!(stable_version(bad).is_err(), "{bad}"); + } +} + +#[test] +fn all_platform_descriptors_bind_repository_tag_filename_size_and_hash() { + let release = release(); + let mut missing = release.release.platforms.clone(); + missing.remove("darwin-aarch64"); + assert!(validate_artifacts(&missing, &PLATFORMS, &release.release).is_err()); + let mut wrong_arch = release.release.platforms.clone(); + wrong_arch.insert( + "darwin-aarch64".into(), + release.release.platforms["darwin-x86_64"].clone(), + ); + assert!(validate_artifacts(&wrong_arch, &PLATFORMS, &release.release).is_err()); + for url in [ + "https://github.com/other/repo/releases/download/v0.3.0/file", + "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.2.0/file", + "https://evil.invalid/update", + ] { + let mut wrong = release.release.platforms.clone(); + wrong.get_mut("linux-x86_64").unwrap().url = url.into(); + assert!(validate_artifacts(&wrong, &PLATFORMS, &release.release).is_err()); + } + for size in [0, MAX_PACKAGE + 1] { + let mut wrong = release.release.platforms.clone(); + wrong.get_mut("linux-x86_64").unwrap().size = size; + assert!(validate_artifacts(&wrong, &PLATFORMS, &release.release).is_err()); + } + let mut wrong = release.release.platforms.clone(); + wrong.get_mut("linux-x86_64").unwrap().sha256 = "A".repeat(64); + assert!(validate_artifacts(&wrong, &PLATFORMS, &release.release).is_err()); +} + +#[test] +fn github_redirects_cannot_leave_release_hosts_or_downgrade_https() { + for url in [ + LATEST, + "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/latest.json", + "https://release-assets.githubusercontent.com/github-production-release-asset/123?sig=test", + ] { + assert!(redirect_allowed(&Url::parse(url).unwrap()), "{url}"); + } + for url in [ + "http://github.com/emil28092005/shacraft-launcher/releases", + "https://github.com/other/repository/releases/latest", + "https://release-assets.githubusercontent.com.evil.invalid/file", + "https://evil.invalid/file", + "https://user@github.com/emil28092005/shacraft-launcher/releases/a", + "https://github.com:8443/emil28092005/shacraft-launcher/releases/a", + "https://github.com/emil28092005/shacraft-launcher/releases/a#extra", + ] { + assert!(!redirect_allowed(&Url::parse(url).unwrap()), "{url}"); + } +} + +#[test] +fn bounded_reads_reject_oversized_or_interrupted_downloads() { + assert!(read_bounded(Cursor::new(b"12345"), 4, |_| {}).is_err()); + assert_eq!( + read_bounded(Cursor::new(b"1234"), 4, |_| {}).unwrap(), + b"1234" + ); + struct Broken; + impl Read for Broken { + fn read(&mut self, _: &mut [u8]) -> io::Result { + Err(io::Error::other("test disconnect")) + } + } + assert!(read_bounded(Broken, 10, |_| {}).is_err()); +} + +#[test] +fn network_failure_and_bad_signature_retry_fetch_whole_signed_release() { + let mut replies = VecDeque::from([ + Err("offline".into()), + Ok(Some(METADATA.to_vec())), + Ok(Some(b"bad-signature".to_vec())), + Ok(Some(METADATA.to_vec())), + Ok(Some(SIG.as_bytes().to_vec())), + ]); + let mut urls = Vec::new(); + let mut fetch = |url: &str, _| { + urls.push(url.to_string()); + replies.pop_front().unwrap() + }; + assert!(fetch_release(&mut fetch, KEY, "0.2.0").is_err()); + assert!(fetch_release(&mut fetch, KEY, "0.2.0").is_err()); + assert_eq!( + fetch_release(&mut fetch, KEY, "0.2.0") + .unwrap() + .unwrap() + .release + .version, + "0.3.0" + ); + assert_eq!( + urls, + [ + LATEST, + LATEST, + &format!("{LATEST}.sig"), + LATEST, + &format!("{LATEST}.sig") + ] + ); + assert!(fetch_release(|_, _| Ok(None), KEY, "0.2.0") + .unwrap() + .is_none()); +} + +#[test] +fn installer_failure_is_reported_and_a_fresh_attempt_reverifies_input() { + let release = release(); + let artifact = &release.release.platforms["linux-x86_64"]; + assert!(install_verified(PACKAGE, artifact, KEY, |_| Err::<(), _>( + "simulated installer failure".into() + )) + .is_err()); + assert!(install_verified(PACKAGE, artifact, KEY, |_| Ok(())).is_ok()); +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index bffddb0..1ba8231 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.1", + "version": "0.2.0", "identifier": "ru.shacraft.launcher", "build": { "beforeDevCommand": "npm run dev", @@ -35,6 +35,22 @@ "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico" - ] + ], + "windows": { + "wix": { + "upgradeCode": "2058b1df-56a1-51ef-bd48-d296479cd59a" + }, + "nsis": { + "installMode": "currentUser" + } + } + }, + "plugins": { + "updater": { + "pubkey": "", + "windows": { + "installMode": "passive" + } + } } } diff --git a/src-tauri/tests/fixtures/updater/latest.json b/src-tauri/tests/fixtures/updater/latest.json new file mode 100644 index 0000000..b183575 --- /dev/null +++ b/src-tauri/tests/fixtures/updater/latest.json @@ -0,0 +1,59 @@ +{ + "manualPackages": { + "darwin-aarch64-dmg": { + "sha256": "409ed0537cccf2724f405fd0031f6e84718442b263b75513532e03ecc991a678", + "signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheUlYSjNaNG55a084WTlCTFlhQTdOdlNNZ3JYYjNaeXZLOThXWERjN3kwWkRtcnNCN0NMVDJBOEpUdVVlS2JJYkpZc3dkdWtyOXQvQ2k3dGJ1bldyRXc4PQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzIwCWZpbGU6c2hhY3JhZnQtbGF1bmNoZXJfMC4zLjBfZGFyd2luLWFhcmNoNjQuZG1nCnRnRlh2dnFUdnJ1MG4vTTF6SExvNzk3ZVpvUkZ5Nk0vdjRiSFpiaHFZUkw5a3N4TkkyODV3RDY0MmJyTXpNdFUwL1NBSlluTG5WeVlMdE1xa3pkWUNnPT0K", + "size": 97, + "url": "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/shacraft-launcher_0.3.0_darwin-aarch64.dmg" + }, + "darwin-x86_64-dmg": { + "sha256": "70496d49bf410231a2575c384a306023ab5eeaf312d4dd93b6e8aed3e2ee9492", + "signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheUJ2biswTzY5QmNNU2hmRHVRV2xpUEtBay94SW1NdHArVnJDQmhLZ3N4UEVQSkxJbG92dmF1bSt4RmJkSm0wOExzNlRDUHJJaVpEUWZvVGoxd0NkNFFrPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzIwCWZpbGU6c2hhY3JhZnQtbGF1bmNoZXJfMC4zLjBfZGFyd2luLXg4Nl82NC5kbWcKVEFaRkxCUFlSRUQ1T0NOa0doclVucnVLNWw0STcxV05ybkxvcWNrazlPdGVEdU5FTlVlMTFSNFlTUEV1RUNvREZqTFZ0UlN3MTJRcGttQWpxblZUQUE9PQo=", + "size": 96, + "url": "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/shacraft-launcher_0.3.0_darwin-x86_64.dmg" + }, + "linux-x86_64-deb": { + "sha256": "cefbadc09d008ff4b0f26ee788f524644b7dadf9de4549bd8d841e44ad92e292", + "signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheU9BMWNrS1hCWHdJWlNFczFJNklxMDJsdHMwZ1NFakpxTDExS0xFUXVwWVFRTVh3Q2Vpc0MvUEI4NXZmTjJjMnZaRFoydVk1SHdMSkkwNFRqMkZIUUFFPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzE5CWZpbGU6c2hhY3JhZnQtbGF1bmNoZXJfMC4zLjBfbGludXgteDg2XzY0LmRlYgpjMVZYeVJuUEZWWithQ2lFVHdkWXBqMDA4NGlKT3FYL3djclhyK1hTSE1RVzVrN0p6TVV1V3plOFlKZHQ5K1pzWDMzcmtPYWFPaCtZeEE4WWt6cW5EZz09Cg==", + "size": 95, + "url": "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/shacraft-launcher_0.3.0_linux-x86_64.deb" + }, + "windows-x86_64-msi": { + "sha256": "fac4bdeb3c95d4c0a91b5f03215a0dd8f3e676b7e7f9140eef1616d2fefa5c4b", + "signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheUdYODluWEtUd2RGNUVyRzNTQWpNV1VoOXpFaXVCZ05SbUpwQTQzYnU2WmI0bzIxVTBTdXFNN2p0Z3NYRndhNmlqTGFzRkZiYllhb0pzbFN4UkMrUUFrPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzE5CWZpbGU6c2hhY3JhZnQtbGF1bmNoZXJfMC4zLjBfd2luZG93cy14ODZfNjQubXNpClZxRlZTL2NobmcreDBLVnc5QzdyZHRkS01NVVZOY2ZpcDNBTWcvUGt6dm15bUNTUzEra09RRDk1Z1VWbmwyU1NjblFXQ0NCcXhTUlpnUEpocWpqQkRRPT0K", + "size": 97, + "url": "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/shacraft-launcher_0.3.0_windows-x86_64.msi" + } + }, + "notes": "Synthetic test metadata.", + "platforms": { + "darwin-aarch64": { + "sha256": "73f3eea52be58fb872d2f6423f5cc604b1599403a556a2208173f8b3ea1e66cb", + "signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheVBPN2xlbzEyQ3h2SkRCdzNlUnFxVEhzQjNYRGNNT1B2enc5TUxKQ1U5TDdwaGhkOUxqNTRnZ3FheDN6UE82SWpMR0YvQUZQR255bWRZcTdYYy9qMWdjPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzE5CWZpbGU6c2hhY3JhZnQtbGF1bmNoZXJfMC4zLjBfZGFyd2luLWFhcmNoNjQuYXBwLnRhci5negp5ckVPVnhvSE1yNitYZm9SRi9KRVNjTWhMVUY3dnNDYmViSHNSWmE2TlFpOVJqd2RxZVJuY0NqVHd6UU5PYUt2NXVQN1JKSHlhZVdiY3JvTjdLcUZCQT09Cg==", + "size": 104, + "url": "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/shacraft-launcher_0.3.0_darwin-aarch64.app.tar.gz" + }, + "darwin-x86_64": { + "sha256": "548d2042689d797dbcb29f09d206e1c36814880a831a26945bf0c550f2617a64", + "signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheVB5MVRuUU52OEdLZDJHYWhmMUtWaDNQMnM0U0xCcC9sdkE0TWhSK0svNzRuTVBtVW5NUTh6MEQ5amVKd0Vpa1l4cWZ2YzNtWW41Y0RQV0VrTitUbVF3PQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzE5CWZpbGU6c2hhY3JhZnQtbGF1bmNoZXJfMC4zLjBfZGFyd2luLXg4Nl82NC5hcHAudGFyLmd6ClFuY3IwOXZKaWsrUTIwMGZmTFdFZmh2K3ZEcDAzYXBpSi8wVnJNS05Zb25YdVgyd1IzLzZud1JqaFNHdXZwa0FpclEyQnplZTFjR1RqNUpzQm9qdUJnPT0K", + "size": 103, + "url": "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/shacraft-launcher_0.3.0_darwin-x86_64.app.tar.gz" + }, + "linux-x86_64": { + "sha256": "150e75098117b452b56bfd3925e2c6af03129b47e17be4529f4498b17a9ce9ae", + "signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheUh1Znd0d2k0c3VGd085c3N2RWVmZnliem04Tzc4MjBaOE1Sd1NWdU9pNU9zZjlMZWNoRnBxaDN6eGlER0hxTDVyRUtVdXdjOUhWK29oM1hSSC84Qmc4PQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzIwCWZpbGU6c2hhY3JhZnQtbGF1bmNoZXJfMC4zLjBfbGludXgteDg2XzY0LkFwcEltYWdlCmdFZTNDdmt0ckRySnB3NWtBeUdYV3drSVZidmNKZEpsOGptcERGK0VjQmpUUDU0c1ZWV1diTWVTZTlhRzVnUVJFRlVITVNhVGxrbS8rMkdTREtUQUNBPT0K", + "size": 100, + "url": "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/shacraft-launcher_0.3.0_linux-x86_64.AppImage" + }, + "windows-x86_64": { + "sha256": "a9773409a6545e98af6a9aad2de2b7d89be257c33af6566bd7ffefc1f443d1be", + "signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheUduVjJYSS83QzVIMXRBK0ZabHk1UGxZMUxkNVkzYXE4VXgranlqRnluSlhOdlBBWU5qaTB5MmQySTZ6M3pZMmZHNHI4dkZpRzk2Q2NXWS9GbXJ2N3dVPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzIwCWZpbGU6c2hhY3JhZnQtbGF1bmNoZXJfMC4zLjBfd2luZG93cy14ODZfNjQtc2V0dXAuZXhlCmlneXpmSGc0WlM3STgxTHUzbldxdVpqbjhraUp6K1c3djhQSWdmOHh0WTFwMnB2cmIwMHdjMVVOajJKVWR0MTVwTTBYd0NvYUk1OVVuemRUakh3SkJBPT0K", + "size": 103, + "url": "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/shacraft-launcher_0.3.0_windows-x86_64-setup.exe" + } + }, + "pub_date": "2026-09-09T00:00:00Z", + "schemaVersion": 1, + "tag": "v0.3.0", + "version": "0.3.0" +} diff --git a/src-tauri/tests/fixtures/updater/latest.json.sig b/src-tauri/tests/fixtures/updater/latest.json.sig new file mode 100644 index 0000000..e2ae893 --- /dev/null +++ b/src-tauri/tests/fixtures/updater/latest.json.sig @@ -0,0 +1 @@ +dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheUVnVElINVRoVDVpb2VGV21XT0wzYXN1WmxxTEhGZHVvYzg3S1FVcUFPVnVqa3FpRXFWbFl2T2FHN2ZmOU1FRVpQR1krREhCcU00RVpXMXYzd2s5UndnPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzIxCWZpbGU6bGF0ZXN0Lmpzb24KVkxhbGNUZE9NSUpTWlR3L1hDczFTTkFzbjkrK1ZyWE9EeG5xTUVBUnRYcW1kZHphNXVTTEs4OTU0WitORWdQdk9PaXNhVWVvbTFJRHFBeWRtQmdzQXc9PQo= \ No newline at end of file diff --git a/src-tauri/tests/fixtures/updater/package.txt b/src-tauri/tests/fixtures/updater/package.txt new file mode 100644 index 0000000..165edd4 --- /dev/null +++ b/src-tauri/tests/fixtures/updater/package.txt @@ -0,0 +1,2 @@ +ShaCraft synthetic fixture; never execute or install. +shacraft-launcher_0.3.0_linux-x86_64.AppImage diff --git a/src-tauri/tests/fixtures/updater/public-key.txt b/src-tauri/tests/fixtures/updater/public-key.txt new file mode 100644 index 0000000..6a90d79 --- /dev/null +++ b/src-tauri/tests/fixtures/updater/public-key.txt @@ -0,0 +1 @@ +dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEM4NUEyNTNEQjlERDZDMkIKUldRcmJOMjVQU1ZheUQ2UzVTN0NHS21ydFp1c1REajVucjlXYnFPK3ZSYWYrQVFDaUgvL3lpQ2UK \ No newline at end of file diff --git a/src-tauri/updater-public-key.txt b/src-tauri/updater-public-key.txt new file mode 100644 index 0000000..7d77859 --- /dev/null +++ b/src-tauri/updater-public-key.txt @@ -0,0 +1 @@ +dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDIyODAzRjlGOUFFNDM4QzYKUldUR09PU2FueitBSWhoU1Y4S2VFK21OeWRmamVkMlBreWRjbWdtRmNtbEZrTzMwNE92MDU2d3YK \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index d5f6e3b..d12df35 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ import { useAccount } from './hooks/useAccount' import { useLauncher } from './hooks/useLauncher' import { useServerStatus } from './hooks/useServerStatus' import { useSettings } from './hooks/useSettings' +import { useUpdater } from './hooks/useUpdater' import { isNative } from './services/native' import { launchAccess } from './state/account' import { installStageLabels } from './state/game' @@ -36,13 +37,23 @@ export function App() { const access = launchAccess(session.account) const checking = desktop && (!profile || profile.status === 'checking') const settingsBlocked = !preferences.loaded || preferences.saving || !!preferences.error - const disabled = !desktop || busy || session.busy || access === 'loading' || + const updaterBlocked = busy ? 'Завершите игру или дождитесь окончания работы со сборкой.' + : settingsBlocked ? 'Дождитесь сохранения настроек; при ошибке повторите сохранение.' + : session.busy || access === 'loading' ? 'Дождитесь завершения работы с аккаунтом.' + : session.linking ? 'Завершите подтверждение игрового ника перед обновлением лаунчера.' + : session.recoveryCodes.length ? 'Сначала сохраните коды восстановления аккаунта.' + : legacyOpen ? 'Закройте проверку старых модов перед обновлением лаунчера.' : null + const updater = useUpdater(updaterBlocked) + const updaterRecovery = updater.state.status?.phase === 'error' && !updater.state.status.canRetry + const updateLocked = updater.mutating || updater.state.status?.phase === 'ready' || updaterRecovery + const disabled = !desktop || busy || updateLocked || session.busy || access === 'loading' || (access === 'ready' && (checking || settingsBlocked || !launcher.eventsReady)) - const repairDisabled = !desktop || busy || checking + const repairDisabled = !desktop || busy || updateLocked || checking const error = launcher.game.error ?? preferences.error ?? windowError ?? launcher.environmentError ?? session.error ?? profile?.error ?? null let label = 'Играть' if (!desktop) label = 'В приложении' + else if (updateLocked) label = updaterRecovery ? 'Нужно восстановить лаунчер' : 'Обновление лаунчера' else if (operation.phase === 'running') label = 'Игра запущена' else if (operation.phase === 'launching') label = 'Запускаем…' else if (operation.phase === 'installing') label = operation.progress ? `${installStageLabels[operation.progress.stage]}…` : 'Подготовка…' @@ -56,7 +67,7 @@ export function App() { else if (!ready) label = 'Проверить' const onboard = async (nickname: string) => { - if (busy || settingsBlocked || !launcher.eventsReady) return + if (busy || updateLocked || settingsBlocked || !launcher.eventsReady) return const result = await launcher.onboard(selected.profileId, nickname) if (result?.onboarding) session.acceptChallenge(result.onboarding) } @@ -71,8 +82,14 @@ export function App() {
0 || legacyOpen}> setSettingsOpen(true)} /> + native={desktop} locked={busy || updateLocked || session.busy} onSelect={setSelected} onSettings={() => setSettingsOpen(true)} /> + {(updater.state.status?.phase === 'available' || (updater.state.status?.phase === 'manual' && updater.state.status.availableVersion) || updater.state.status?.phase === 'ready' || updater.mutating || updaterRecovery) && + } setLegacyOpen(true)} onPrimary={primary} onRepair={() => { if (!repairDisabled) void launcher.repair(selected.profileId) }} />
- {legacyOpen && setLegacyOpen(false)} onChanged={() => { void launcher.refreshProfile(selected.profileId) }} />} diff --git a/src/components/LauncherUpdate.tsx b/src/components/LauncherUpdate.tsx new file mode 100644 index 0000000..751462f --- /dev/null +++ b/src/components/LauncherUpdate.tsx @@ -0,0 +1,76 @@ +import { Download, RefreshCw } from 'lucide-react' +import { canRunUpdater, updaterPercent } from '../state/updater' +import type { UpdaterState } from '../state/updater' + +export interface LauncherUpdateProps { + state: UpdaterState + native: boolean + installedVersion?: string + onCheck: () => void + onInstall: () => void + onRestart: () => void + onOpenRelease: () => void +} + +export function LauncherUpdate({ state, native, installedVersion, onCheck, onInstall, onRestart, onOpenRelease }: LauncherUpdateProps) { + const status = state.status + const phase = status?.phase + const percent = updaterPercent(status) + const downloadedBytes = status?.downloadedBytes ?? 0 + const error = state.error ?? (phase === 'error' ? status?.message : null) + const needsRecovery = phase === 'error' && status?.canRetry === false + const checking = state.pending?.command === 'check' || phase === 'checking' || state.pending?.command === 'status' + const downloading = phase === 'downloading' + const progressing = downloading || phase === 'verifying' || phase === 'installing' || state.pending?.command === 'install' + const packageFormat = status?.packageFormat === 'development' ? 'для разработки' + : status?.packageFormat === 'unpackaged' ? 'без установщика' : status?.packageFormat + const titles = { + idle: 'Можно проверить новую версию', checking: 'Проверяем обновления…', + available: 'Доступно обновление', downloading: 'Скачиваем обновление…', + verifying: 'Проверяем подпись обновления…', installing: 'Устанавливаем обновление…', + ready: 'Обновление установлено — нужен перезапуск', no_update: 'Установлена актуальная версия', + unconfigured: 'Автообновление пока не настроено', manual: 'Обновление пакета вручную', error: 'Не удалось обновить лаунчер', + } + return ( +
+

Лаунчер

+ Версия {status?.installedVersion ?? installedVersion ?? 'уточняется'}{packageFormat && ` · ${packageFormat}`}
+ {status?.testBuild &&

Тестовая сборка · тестовый канал обновлений

} +

{!native ? 'Обновления доступны в приложении лаунчера.' + : phase ? titles[phase] : checking ? 'Читаем состояние обновления…' : 'Проверьте доступные обновления.'}

+ {status?.availableVersion &&

Новая версия: {status.availableVersion}

} + {phase === 'manual' &&

{status?.packageFormat === 'deb' + ? 'Этот пакет обновляется вручную. Для установки .deb используйте системный менеджер пакетов; версия и инструкция доступны на странице выпусков.' + : 'Эта сборка обновляется вручную. Выберите пакет для своей системы на странице выпусков.'}

} + {phase === 'unconfigured' &&

Для этой сборки канал обновлений недоступен. Проверка не меняет установленный лаунчер.

} + {phase === 'available' &&

Установка закроет и перезапустит лаунчер. Игру потребуется завершить, изменения настроек — сохранить.

} + {phase === 'ready' &&

Чтобы продолжить работу в новой версии, перезапустите лаунчер.

} + {needsRecovery &&

Автоматическое продолжение недоступно. Восстановите лаунчер из пакета на странице выпусков.

} + {status?.message && phase !== 'error' &&

{status.message}

} + {status?.releaseNotes &&
Что изменилось

{status.releaseNotes}

} + {progressing &&
+ +
} + {downloading &&

{percent === null + ? `Скачано: ${(Math.max(0, Number.isFinite(downloadedBytes) ? downloadedBytes : 0) / 1024 / 1024).toFixed(1)} МБ` + : `Скачано: ${percent}%`}

} + {error &&

{error}

} + {state.blockedReason && native &&

{state.blockedReason}

} +
+ {phase !== 'ready' && !needsRecovery && } + {phase === 'available' && } + {phase === 'ready' && } + {(phase === 'manual' || needsRecovery) && } +
+
+ ) +} diff --git a/src/components/SettingsDrawer.tsx b/src/components/SettingsDrawer.tsx index 556a3f4..e84253f 100644 --- a/src/components/SettingsDrawer.tsx +++ b/src/components/SettingsDrawer.tsx @@ -1,6 +1,8 @@ import { useEffect, useRef } from 'react' import { FolderOpen, Wrench, X } from 'lucide-react' import { AccountSettings } from './AccountSettings' +import { LauncherUpdate } from './LauncherUpdate' +import type { useUpdater } from '../hooks/useUpdater' import type { useAccount } from '../hooks/useAccount' import type { useSettings } from '../hooks/useSettings' import type { JavaInstallation, NativeHost } from '../types/launcher' @@ -15,9 +17,11 @@ interface SettingsDrawerProps { onClose: () => void requiredJava?: number onOnboard?: (nickname: string) => Promise + updater: ReturnType + native: boolean } -export function SettingsDrawer({ open, locked, host, java, preferences, session, onClose, requiredJava, onOnboard }: SettingsDrawerProps) { +export function SettingsDrawer({ open, locked, host, java, preferences, session, onClose, requiredJava, onOnboard, updater, native }: SettingsDrawerProps) { const { settings, loaded, saving, error } = preferences const closeButton = useRef(null) useEffect(() => { @@ -27,7 +31,9 @@ export function SettingsDrawer({ open, locked, host, java, preferences, session, const onKey = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose() if (event.key !== 'Tab') return - const elements = closeButton.current?.closest('aside')?.querySelectorAll('button:not(:disabled), input:not(:disabled), select:not(:disabled)') + const elements = Array.from(closeButton.current?.closest('aside')?.querySelectorAll( + 'button:not(:disabled), input:not(:disabled), select:not(:disabled), summary, [tabindex="0"]', + ) ?? []).filter((element) => element.getClientRects().length > 0) const first = elements?.[0] const last = elements?.[elements.length - 1] if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus() } @@ -49,6 +55,8 @@ export function SettingsDrawer({ open, locked, host, java, preferences, session,

Настройки

Игра

+