feat: add signed launcher updates with native lifecycle recovery

This commit is contained in:
Emil
2026-09-09 19:48:10 +03:00
parent 7c0eb20894
commit 1d08396b80
39 changed files with 2952 additions and 46 deletions
+18 -3
View File
@@ -56,6 +56,16 @@ payload are in `/root/shacraft` on the ShaCraft host; see
requires LoginSystem authentication and an explicit one-time proof command. requires LoginSystem authentication and an explicit one-time proof command.
Existing links retain legacy provenance; status polling cannot create links. 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 ## Layout
- `src/main.tsx` — React entrypoint; `src/App.tsx` composes the screen. - `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 - `lib.rs` — module/command registration only; `commands/` holds adapters
for account/game/host/preferences/profiles. Unsigned sync/inspect IPC was for account/game/host/preferences/profiles. Unsigned sync/inspect IPC was
removed; only verified remote manifests may drive profile mutations. 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. Launch must use the authenticated ShaCraft nickname; no settings fallback.
- `storage.rs` — unique same-directory atomic writes, owner-only Unix files. - `storage.rs` — unique same-directory atomic writes, owner-only Unix files.
- `trusted_http.rs` — HTTPS and exact-host redirect policy per game provider. - `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 - `docs/game-trust-boundary.md` — the Mojang/NeoForge/Microsoft/Adoptium
trust domains used to install and run the game itself. trust domains used to install and run the game itself.
- `.github/workflows/check.yml` — push/PR UI checks and Linux Rust tests. - `.github/workflows/check.yml` — push/PR UI checks and Linux Rust tests.
- `.github/workflows/build.yml`main-push/manual cross-platform builds with artifacts; - `.github/workflows/build.yml`four-platform CI packages with disposable test
not a signed release or updater publication. 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 ## Verification
+67 -8
View File
@@ -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: read-only `https://shacraft.ru/api/online/aoc` endpoint. It is display-only:
the result never controls files, versions, URLs, or the launch command. the result never controls files, versions, URLs, or the launch command.
Not yet implemented: a user-selectable profile directory, a "reset managed The launcher also checks signed GitHub releases for its own updates. AppImage,
files only" recovery action, and signed cross-platform release builds of the Windows x64 installers and native Intel/Apple Silicon macOS app bundles use
launcher itself. Do not represent these as completed in UI or release notes. 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 ## Data flow
@@ -71,7 +75,7 @@ be the system `.minecraft` directory.
1. User-selectable profile directory and structured launcher logs. 1. User-selectable profile directory and structured launcher logs.
2. "Reset managed files only" recovery action that doesn't touch player 2. "Reset managed files only" recovery action that doesn't touch player
worlds/screenshots/resourcepacks. worlds/screenshots/resourcepacks.
3. Signed, cross-platform release builds of the launcher itself. 3. Production release credentials/protected environments and OS beta validation.
4. Cancellation, structured logs and a full cold-install/recovery beta on 4. Cancellation, structured logs and a full cold-install/recovery beta on
every target OS. Install progress reports bytes or installer work counts every target OS. Install progress reports bytes or installer work counts
depending on the stage; these units are not interchangeable. 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 test` covers asynchronous helpers and state transitions;
`npm run build` runs strict TypeScript before Vite. `cargo test --locked` `npm run build` runs strict TypeScript before Vite. `cargo test --locked`
covers native policy and storage. Push/PR CI repeats checks on Linux. covers native policy and storage. Push/PR CI repeats checks on Linux.
The package workflow runs on main pushes or manually and builds Windows The package workflow builds and checks Windows x64, Linux x64 and both macOS
x64, Linux x64 and both macOS architectures with named artifacts. architectures with disposable test signing keys. These artifacts cannot be
Packages are not yet signed release artifacts. Native cold-install and published as production updater releases. Separate protected workflows assemble
launch tests are required before calling a platform release-ready. 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 ## 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 network access and sufficient disk space; the printed directory is retained
for diagnosis. This does not replace Tauri IPC, graphical gameplay or the for diagnosis. This does not replace Tauri IPC, graphical gameplay or the
client/server proof matrix on each supported OS. 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.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "shacraft-launcher-ui", "name": "shacraft-launcher-ui",
"version": "0.1.1", "version": "0.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "shacraft-launcher-ui", "name": "shacraft-launcher-ui",
"version": "0.1.1", "version": "0.2.0",
"dependencies": { "dependencies": {
"@tauri-apps/api": "2.11.1", "@tauri-apps/api": "2.11.1",
"lucide-react": "1.41.0", "lucide-react": "1.41.0",
+2 -2
View File
@@ -2,13 +2,13 @@
"name": "shacraft-launcher-ui", "name": "shacraft-launcher-ui",
"license": "MIT", "license": "MIT",
"private": true, "private": true,
"version": "0.1.1", "version": "0.2.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "npm run typecheck && vite build", "build": "npm run typecheck && vite build",
"typecheck": "tsc --noEmit", "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", "preview": "vite preview",
"tauri": "tauri", "tauri": "tauri",
"tauri:dev": "tauri dev", "tauri:dev": "tauri dev",
+317 -7
View File
@@ -1770,6 +1770,36 @@ dependencies = [
"windows-sys 0.45.0", "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]] [[package]]
name = "jni-sys" name = "jni-sys"
version = "0.3.1" version = "0.3.1"
@@ -1975,6 +2005,12 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@@ -2226,6 +2262,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.1",
"block2", "block2",
"libc",
"objc2", "objc2",
"objc2-core-foundation", "objc2-core-foundation",
] ]
@@ -2251,6 +2288,18 @@ dependencies = [
"objc2-core-foundation", "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]] [[package]]
name = "objc2-quartz-core" name = "objc2-quartz-core"
version = "0.3.2" version = "0.3.2"
@@ -2314,12 +2363,32 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]] [[package]]
name = "option-ext" name = "option-ext"
version = "0.2.0" version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" 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]] [[package]]
name = "pango" name = "pango"
version = "0.18.3" version = "0.18.3"
@@ -2836,15 +2905,20 @@ dependencies = [
"http-body", "http-body",
"http-body-util", "http-body-util",
"hyper", "hyper",
"hyper-rustls",
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde", "serde",
"serde_json", "serde_json",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-rustls",
"tokio-util", "tokio-util",
"tower", "tower",
"tower-http", "tower-http",
@@ -2912,6 +2986,18 @@ dependencies = [
"zeroize", "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]] [[package]]
name = "rustls-pki-types" name = "rustls-pki-types"
version = "1.15.1" version = "1.15.1"
@@ -2922,6 +3008,33 @@ dependencies = [
"zeroize", "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]] [[package]]
name = "rustls-webpki" name = "rustls-webpki"
version = "0.103.15" version = "0.103.15"
@@ -2954,6 +3067,15 @@ dependencies = [
"winapi-util", "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]] [[package]]
name = "schemars" name = "schemars"
version = "0.8.22" version = "0.8.22"
@@ -3011,6 +3133,29 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 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]] [[package]]
name = "selectors" name = "selectors"
version = "0.36.1" version = "0.36.1"
@@ -3235,13 +3380,16 @@ dependencies = [
[[package]] [[package]]
name = "shacraft-launcher" name = "shacraft-launcher"
version = "0.1.1" version = "0.2.0"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"ed25519-dalek", "ed25519-dalek",
"flate2", "flate2",
"md-5", "md-5",
"minisign-verify",
"reqwest 0.12.28", "reqwest 0.12.28",
"reqwest 0.13.4",
"semver",
"serde", "serde",
"serde_json", "serde_json",
"sha1", "sha1",
@@ -3250,6 +3398,8 @@ dependencies = [
"tar", "tar",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-updater",
"time",
"url", "url",
"zip", "zip",
] ]
@@ -3275,6 +3425,22 @@ version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" 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]] [[package]]
name = "siphasher" name = "siphasher"
version = "1.0.3" version = "1.0.3"
@@ -3511,7 +3677,7 @@ dependencies = [
"gdkwayland-sys", "gdkwayland-sys",
"gdkx11-sys", "gdkx11-sys",
"gtk", "gtk",
"jni", "jni 0.21.1",
"libc", "libc",
"log", "log",
"ndk", "ndk",
@@ -3578,7 +3744,7 @@ dependencies = [
"gtk", "gtk",
"heck 0.5.0", "heck 0.5.0",
"http", "http",
"jni", "jni 0.21.1",
"libc", "libc",
"log", "log",
"mime", "mime",
@@ -3674,6 +3840,54 @@ dependencies = [
"tauri-utils", "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]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "2.11.3" version = "2.11.3"
@@ -3684,7 +3898,7 @@ dependencies = [
"dpi", "dpi",
"gtk", "gtk",
"http", "http",
"jni", "jni 0.21.1",
"objc2", "objc2",
"objc2-ui-kit", "objc2-ui-kit",
"objc2-web-kit", "objc2-web-kit",
@@ -3707,7 +3921,7 @@ checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f"
dependencies = [ dependencies = [
"gtk", "gtk",
"http", "http",
"jni", "jni 0.21.1",
"log", "log",
"objc2", "objc2",
"objc2-app-kit", "objc2-app-kit",
@@ -3774,6 +3988,19 @@ dependencies = [
"toml 1.1.5+spec-1.1.0", "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]] [[package]]
name = "tendril" name = "tendril"
version = "0.5.1" version = "0.5.1"
@@ -4451,6 +4678,15 @@ dependencies = [
"system-deps", "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]] [[package]]
name = "webpki-roots" name = "webpki-roots"
version = "1.0.9" version = "1.0.9"
@@ -4750,6 +4986,15 @@ dependencies = [
"windows-targets 0.52.6", "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]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.61.2" version = "0.61.2"
@@ -4783,13 +5028,30 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6", "windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6", "windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 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_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6", "windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6", "windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 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]] [[package]]
name = "windows-threading" name = "windows-threading"
version = "0.1.0" version = "0.1.0"
@@ -4829,6 +5091,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.42.2" version = "0.42.2"
@@ -4841,6 +5109,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.42.2" version = "0.42.2"
@@ -4853,12 +5127,24 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]] [[package]]
name = "windows_i686_gnullvm" name = "windows_i686_gnullvm"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.42.2" version = "0.42.2"
@@ -4871,6 +5157,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.42.2" version = "0.42.2"
@@ -4883,6 +5175,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 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]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.42.2" version = "0.42.2"
@@ -4895,6 +5193,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 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]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.42.2" version = "0.42.2"
@@ -4907,6 +5211,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 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]] [[package]]
name = "winnow" name = "winnow"
version = "0.5.40" version = "0.5.40"
@@ -4971,7 +5281,7 @@ dependencies = [
"gtk", "gtk",
"http", "http",
"javascriptcore-rs", "javascriptcore-rs",
"jni", "jni 0.21.1",
"libc", "libc",
"ndk", "ndk",
"objc2", "objc2",
+7 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "shacraft-launcher" name = "shacraft-launcher"
version = "0.1.1" version = "0.2.0"
description = "ShaCraft Minecraft launcher" description = "ShaCraft Minecraft launcher"
authors = ["ShaCraft"] authors = ["ShaCraft"]
license = "MIT" license = "MIT"
@@ -28,3 +28,9 @@ flate2 = "1"
tar = "0.4" tar = "0.4"
zip = { version = "2", default-features = false, features = ["deflate"] } zip = { version = "2", default-features = false, features = ["deflate"] }
sysinfo = { version = "0.39.6", default-features = false, features = ["system"] } 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"] }
+3
View File
@@ -1,3 +1,6 @@
fn main() { 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() tauri_build::build()
} }
+6
View File
@@ -30,8 +30,10 @@ pub(crate) fn start_microsoft_login(
state: State<'_, LauncherOperations>, state: State<'_, LauncherOperations>,
) -> Result<(), String> { ) -> Result<(), String> {
let directory = data_dir(&app)?; let directory = data_dir(&app)?;
let lifecycle = crate::update_guard::begin_operation(&directory, &state)?;
let permit = state.account.acquire("Account operation")?; let permit = state.account.acquire("Account operation")?;
tauri::async_runtime::spawn_blocking(move || { tauri::async_runtime::spawn_blocking(move || {
let _lifecycle = lifecycle;
let _permit = permit; let _permit = permit;
let login = || -> Result<msa::MinecraftProfile, String> { let login = || -> Result<msa::MinecraftProfile, String> {
let client = msa::http_client().map_err(|error| error.to_string())?; let client = msa::http_client().map_err(|error| error.to_string())?;
@@ -76,8 +78,10 @@ pub(crate) async fn get_account(
state: State<'_, LauncherOperations>, state: State<'_, LauncherOperations>,
) -> Result<Option<msa::MinecraftProfile>, String> { ) -> Result<Option<msa::MinecraftProfile>, String> {
let data_dir = data_dir(&app)?; let data_dir = data_dir(&app)?;
let lifecycle = crate::update_guard::begin_operation(&data_dir, &state)?;
let permit = state.account.acquire("Account operation")?; let permit = state.account.acquire("Account operation")?;
tauri::async_runtime::spawn_blocking(move || { tauri::async_runtime::spawn_blocking(move || {
let _lifecycle = lifecycle;
let _permit = permit; let _permit = permit;
let Some(refresh_token) = msa::load_refresh_token(&data_dir) else { let Some(refresh_token) = msa::load_refresh_token(&data_dir) else {
return Ok(None); return Ok(None);
@@ -102,8 +106,10 @@ pub(crate) async fn logout(
state: State<'_, LauncherOperations>, state: State<'_, LauncherOperations>,
) -> Result<(), String> { ) -> Result<(), String> {
let data_dir = data_dir(&app)?; let data_dir = data_dir(&app)?;
let lifecycle = crate::update_guard::begin_operation(&data_dir, &state)?;
let permit = state.account.acquire("Account operation")?; let permit = state.account.acquire("Account operation")?;
tauri::async_runtime::spawn_blocking(move || { tauri::async_runtime::spawn_blocking(move || {
let _lifecycle = lifecycle;
let _permit = permit; let _permit = permit;
msa::clear_account(&data_dir) msa::clear_account(&data_dir)
}) })
+4
View File
@@ -151,8 +151,10 @@ pub(crate) async fn ensure_game_installed(
profile_id: String, profile_id: String,
) -> Result<PreparationResult, String> { ) -> Result<PreparationResult, String> {
let directory = data_dir(&app)?; let directory = data_dir(&app)?;
let lifecycle = crate::update_guard::begin_operation(&directory, &state)?;
let permit = state.installation.acquire("Installation")?; let permit = state.installation.acquire("Installation")?;
tauri::async_runtime::spawn_blocking(move || { tauri::async_runtime::spawn_blocking(move || {
let _lifecycle = lifecycle;
let _permit = permit; let _permit = permit;
let _lock = InstallationLock::acquire(&directory)?; let _lock = InstallationLock::acquire(&directory)?;
let snapshot = remote::fetch_snapshot(&profile_id).map_err(|e| e.to_string())?; let snapshot = remote::fetch_snapshot(&profile_id).map_err(|e| e.to_string())?;
@@ -181,9 +183,11 @@ async fn play(
onboarding_name: Option<String>, onboarding_name: Option<String>,
) -> Result<PreparationResult, String> { ) -> Result<PreparationResult, String> {
let directory = data_dir(&app)?; let directory = data_dir(&app)?;
let lifecycle = crate::update_guard::begin_operation(&directory, &state)?;
let permit = state.installation.acquire("Installation")?; let permit = state.installation.acquire("Installation")?;
let account_operation = state.shacraft_account.clone(); let account_operation = state.shacraft_account.clone();
tauri::async_runtime::spawn_blocking(move || { tauri::async_runtime::spawn_blocking(move || {
let _lifecycle = lifecycle;
let _permit = permit; let _permit = permit;
let lock = InstallationLock::acquire(&directory)?; let lock = InstallationLock::acquire(&directory)?;
let snapshot = remote::fetch_snapshot(&profile_id).map_err(|e| e.to_string())?; let snapshot = remote::fetch_snapshot(&profile_id).map_err(|e| e.to_string())?;
+1
View File
@@ -5,6 +5,7 @@ pub(crate) mod host;
pub(crate) mod preferences; pub(crate) mod preferences;
pub(crate) mod profiles; pub(crate) mod profiles;
pub(crate) mod shacraft; pub(crate) mod shacraft;
pub(crate) mod updater;
use std::path::PathBuf; use std::path::PathBuf;
use tauri::{AppHandle, Manager}; use tauri::{AppHandle, Manager};
+11 -6
View File
@@ -1,6 +1,6 @@
use super::data_dir; use super::data_dir;
use crate::settings; use crate::{operations::LauncherOperations, settings};
use tauri::AppHandle; use tauri::{AppHandle, State};
#[tauri::command] #[tauri::command]
pub(crate) async fn load_settings(app: AppHandle) -> Result<settings::LauncherSettings, String> { pub(crate) async fn load_settings(app: AppHandle) -> Result<settings::LauncherSettings, String> {
@@ -14,11 +14,16 @@ pub(crate) async fn load_settings(app: AppHandle) -> Result<settings::LauncherSe
#[tauri::command] #[tauri::command]
pub(crate) async fn save_settings( pub(crate) async fn save_settings(
app: AppHandle, app: AppHandle,
state: State<'_, LauncherOperations>,
settings: settings::LauncherSettings, settings: settings::LauncherSettings,
) -> Result<settings::LauncherSettings, String> { ) -> Result<settings::LauncherSettings, String> {
let data_dir = data_dir(&app)?; let data_dir = data_dir(&app)?;
tauri::async_runtime::spawn_blocking(move || settings::save(&data_dir, settings)) let lifecycle = crate::update_guard::begin_operation(&data_dir, &state)?;
.await tauri::async_runtime::spawn_blocking(move || {
.map_err(|error| format!("Settings task failed: {error}"))? let _lifecycle = lifecycle;
.map_err(|error| error.to_string()) settings::save(&data_dir, settings)
})
.await
.map_err(|error| format!("Settings task failed: {error}"))?
.map_err(|error| error.to_string())
} }
+10
View File
@@ -47,10 +47,13 @@ pub(crate) async fn get_server_status(profile_id: String) -> Result<remote::Serv
#[tauri::command] #[tauri::command]
pub(crate) async fn inspect_remote_profile( pub(crate) async fn inspect_remote_profile(
app: AppHandle, app: AppHandle,
state: State<'_, LauncherOperations>,
profile_id: String, profile_id: String,
) -> Result<profile::ProfileInspection, String> { ) -> Result<profile::ProfileInspection, String> {
let directory = data_dir(&app)?; let directory = data_dir(&app)?;
let lifecycle = crate::update_guard::begin_operation(&directory, &state)?;
tauri::async_runtime::spawn_blocking(move || { tauri::async_runtime::spawn_blocking(move || {
let _lifecycle = lifecycle;
// Inspection must not report a partially applied journal as ready. // Inspection must not report a partially applied journal as ready.
let _lock = InstallationLock::acquire(&directory)?; let _lock = InstallationLock::acquire(&directory)?;
let manifest = remote::fetch_manifest(&profile_id).map_err(|e| e.to_string())?; 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, profile_id: String,
) -> Result<profile::SyncResult, String> { ) -> Result<profile::SyncResult, String> {
let directory = data_dir(&app)?; let directory = data_dir(&app)?;
let lifecycle = crate::update_guard::begin_operation(&directory, &state)?;
let permit = state.installation.acquire("Installation")?; let permit = state.installation.acquire("Installation")?;
tauri::async_runtime::spawn_blocking(move || { tauri::async_runtime::spawn_blocking(move || {
let _lifecycle = lifecycle;
let _permit = permit; let _permit = permit;
let _lock = InstallationLock::acquire(&directory)?; let _lock = InstallationLock::acquire(&directory)?;
let snapshot = remote::fetch_snapshot(&profile_id).map_err(|e| e.to_string())?; 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] #[tauri::command]
pub(crate) async fn legacy_mods( pub(crate) async fn legacy_mods(
app: AppHandle, app: AppHandle,
state: State<'_, LauncherOperations>,
profile_id: String, profile_id: String,
) -> Result<Vec<profile::LegacyMod>, String> { ) -> Result<Vec<profile::LegacyMod>, String> {
let directory = data_dir(&app)?; let directory = data_dir(&app)?;
let lifecycle = crate::update_guard::begin_operation(&directory, &state)?;
tauri::async_runtime::spawn_blocking(move || { tauri::async_runtime::spawn_blocking(move || {
let _lifecycle = lifecycle;
let _lock = InstallationLock::acquire(&directory)?; let _lock = InstallationLock::acquire(&directory)?;
let manifest = remote::fetch_manifest(&profile_id).map_err(|e| e.to_string())?; let manifest = remote::fetch_manifest(&profile_id).map_err(|e| e.to_string())?;
profile::list_legacy_mods(&directory.join("profiles").join(&manifest.id), &manifest) profile::list_legacy_mods(&directory.join("profiles").join(&manifest.id), &manifest)
@@ -107,8 +115,10 @@ pub(crate) async fn backup_legacy_mods(
selections: Vec<profile::LegacySelection>, selections: Vec<profile::LegacySelection>,
) -> Result<profile::LegacyBackup, String> { ) -> Result<profile::LegacyBackup, String> {
let directory = data_dir(&app)?; let directory = data_dir(&app)?;
let lifecycle = crate::update_guard::begin_operation(&directory, &state)?;
let permit = state.installation.acquire("Legacy migration")?; let permit = state.installation.acquire("Legacy migration")?;
tauri::async_runtime::spawn_blocking(move || { tauri::async_runtime::spawn_blocking(move || {
let _lifecycle = lifecycle;
let _permit = permit; let _permit = permit;
let _lock = InstallationLock::acquire(&directory)?; let _lock = InstallationLock::acquire(&directory)?;
let manifest = remote::fetch_manifest(&profile_id).map_err(|e| e.to_string())?; let manifest = remote::fetch_manifest(&profile_id).map_err(|e| e.to_string())?;
+2
View File
@@ -13,10 +13,12 @@ async fn account_task<T: Send + 'static>(
work: impl FnOnce(&Path) -> Result<T, shacraft_account::AccountError> + Send + 'static, work: impl FnOnce(&Path) -> Result<T, shacraft_account::AccountError> + Send + 'static,
) -> Result<T, String> { ) -> Result<T, String> {
let directory = data_dir(app)?; let directory = data_dir(app)?;
let lifecycle = crate::update_guard::begin_operation(&directory, operations)?;
let permit = operations let permit = operations
.shacraft_account .shacraft_account
.acquire("ShaCraft account operation")?; .acquire("ShaCraft account operation")?;
tauri::async_runtime::spawn_blocking(move || { tauri::async_runtime::spawn_blocking(move || {
let _lifecycle = lifecycle;
let _permit = permit; let _permit = permit;
work(&directory).map_err(|error| error.to_string()) work(&directory).map_err(|error| error.to_string())
}) })
+158
View File
@@ -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<Option<UpdateStatus>, String> {
let operations = app.state::<LauncherOperations>();
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<T>(
work: impl FnOnce() -> Result<T, String>,
panic_message: &str,
) -> Result<T, String> {
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::<LauncherOperations>()
.latch_recovery(reason.clone());
state.recovery(app, reason)
}
}
}
#[tauri::command]
pub(crate) fn updater_status(
app: AppHandle,
state: State<'_, UpdaterState>,
) -> Result<UpdateStatus, String> {
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<UpdateStatus, String> {
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<UpdateStatus, String> {
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::<LauncherOperations>();
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));
}
}
+60 -5
View File
@@ -71,6 +71,16 @@ pub(crate) struct InstallationLock {
lease: PathBuf, 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 { impl InstallationLock {
pub fn acquire(data_dir: &Path) -> Result<Self, String> { pub fn acquire(data_dir: &Path) -> Result<Self, String> {
ordinary_path(data_dir)?; ordinary_path(data_dir)?;
@@ -166,7 +176,10 @@ mod tests {
let a = InstallationLock::acquire(&p).unwrap(); let a = InstallationLock::acquire(&p).unwrap();
assert!(InstallationLock::acquire(&p).is_err()); assert!(InstallationLock::acquire(&p).is_err());
drop(a); 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(); fs::remove_dir_all(p).unwrap();
} }
#[test] #[test]
@@ -176,9 +189,8 @@ mod tests {
a.starting().unwrap(); a.starting().unwrap();
a.running(std::process::id()).unwrap(); a.running(std::process::id()).unwrap();
drop(a); drop(a);
assert!(InstallationLock::acquire(&p) let error = InstallationLock::acquire(&p).unwrap_err_string();
.unwrap_err_string() assert!(error.contains("Minecraft"), "unexpected refusal: {error}");
.contains("Minecraft"));
fs::remove_dir_all(p).unwrap(); fs::remove_dir_all(p).unwrap();
} }
#[test] #[test]
@@ -193,9 +205,52 @@ mod tests {
}) })
.unwrap(); .unwrap();
drop(a); 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(); 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] #[test]
fn interrupted_spawn_fails_closed() { fn interrupted_spawn_fails_closed() {
let p = dir(); let p = dir();
+38
View File
@@ -1,4 +1,7 @@
mod download; mod download;
mod update_guard;
mod updater;
use tauri::Manager;
mod installation_lock; mod installation_lock;
mod inventory; mod inventory;
mod java; mod java;
@@ -22,7 +25,42 @@ mod operations;
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.manage(operations::LauncherOperations::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::<operations::LauncherOperations>()
.latch_recovery(reason);
}
app.manage(instance);
Ok(())
})
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
if window
.app_handle()
.state::<operations::LauncherOperations>()
.lifecycle
.is_updating()
{
api.prevent_close();
}
}
})
.invoke_handler(tauri::generate_handler![ .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::native_host,
commands::host::detect_java, commands::host::detect_java,
commands::host::microsoft_login_available, commands::host::microsoft_login_available,
+63 -2
View File
@@ -3,12 +3,14 @@
//! Acquire before scheduling the worker and move the permit into it. Dropping //! Acquire before scheduling the worker and move the permit into it. Dropping
//! the caller's future cannot unlock an operation that is still running. //! the caller's future cannot unlock an operation that is still running.
use std::sync::{ use std::sync::{
atomic::{AtomicBool, Ordering}, atomic::{AtomicBool, AtomicUsize, Ordering},
Arc, Arc, Mutex,
}; };
#[derive(Default)] #[derive(Default)]
pub(crate) struct LauncherOperations { pub(crate) struct LauncherOperations {
pub lifecycle: Lifecycle,
recovery: Mutex<Option<String>>,
pub installation: Operation, pub installation: Operation,
pub account: Operation, pub account: Operation,
pub shacraft_account: Operation, pub shacraft_account: Operation,
@@ -50,3 +52,62 @@ mod tests {
assert!(operation.acquire("Installation").is_ok()); 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<AtomicUsize>);
const EXCLUSIVE: usize = usize::MAX;
impl Lifecycle {
pub fn shared(&self) -> Result<SharedPermit, String> {
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<ExclusivePermit, String> {
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<AtomicUsize>);
impl Drop for SharedPermit {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::Release);
}
}
pub(crate) struct ExclusivePermit(Arc<AtomicUsize>);
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(()),
}
}
}
+355
View File
@@ -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<PathBuf, String> {
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<String>,
}
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<String> {
self.recovery_reason.clone()
}
pub fn acquire(data: &Path, current_version: &str) -> Result<Self, String> {
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<Option<PendingUpdate>, 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<Option<String>, 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<crate::operations::SharedPermit, String> {
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<Self, String> {
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();
}
}
+493
View File
@@ -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<String>,
release_notes: Option<String>,
downloaded_bytes: u64,
total_bytes: Option<u64>,
can_retry: bool,
message: Option<String>,
test_build: bool,
}
struct StateData {
status: UpdateStatus,
candidate: Option<VerifiedRelease>,
}
#[derive(Clone)]
pub(crate) struct UpdaterState {
inner: Arc<Mutex<StateData>>,
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<Permit, String> {
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<String>) -> 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<tauri::utils::config::BundleType>,
) -> 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, String> {
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<Option<Vec<u8>>, 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<UpdateStatus, String> {
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<Artifact, String> {
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<UpdateStatus, String> {
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());
}
}
+94
View File
@@ -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());
}
}
+294
View File
@@ -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<String, Artifact>,
pub manual_packages: BTreeMap<String, Artifact>,
}
#[derive(Clone)]
pub(crate) struct VerifiedRelease {
pub release: Release,
pub json: serde_json::Value,
}
fn decode_text(encoded: &str) -> Result<String, String> {
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<Version, String> {
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<Self, String> {
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<bool, String> {
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<String, Artifact>,
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<Vec<u8>, 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<T>(
bytes: &[u8],
artifact: &Artifact,
key: &str,
installer: impl FnOnce(&[u8]) -> Result<T, String>,
) -> Result<T, String> {
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<Option<Vec<u8>>, String>,
key: &str,
current: &str,
) -> Result<Option<VerifiedRelease>, 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;
+204
View File
@@ -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<usize> {
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());
}
+18 -2
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "ShaCraft Launcher", "productName": "ShaCraft Launcher",
"version": "0.1.1", "version": "0.2.0",
"identifier": "ru.shacraft.launcher", "identifier": "ru.shacraft.launcher",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
@@ -35,6 +35,22 @@
"icons/128x128@2x.png", "icons/128x128@2x.png",
"icons/icon.icns", "icons/icon.icns",
"icons/icon.ico" "icons/icon.ico"
] ],
"windows": {
"wix": {
"upgradeCode": "2058b1df-56a1-51ef-bd48-d296479cd59a"
},
"nsis": {
"installMode": "currentUser"
}
}
},
"plugins": {
"updater": {
"pubkey": "",
"windows": {
"installMode": "passive"
}
}
} }
} }
+59
View File
@@ -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"
}
+1
View File
@@ -0,0 +1 @@
dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheUVnVElINVRoVDVpb2VGV21XT0wzYXN1WmxxTEhGZHVvYzg3S1FVcUFPVnVqa3FpRXFWbFl2T2FHN2ZmOU1FRVpQR1krREhCcU00RVpXMXYzd2s5UndnPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzIxCWZpbGU6bGF0ZXN0Lmpzb24KVkxhbGNUZE9NSUpTWlR3L1hDczFTTkFzbjkrK1ZyWE9EeG5xTUVBUnRYcW1kZHphNXVTTEs4OTU0WitORWdQdk9PaXNhVWVvbTFJRHFBeWRtQmdzQXc9PQo=
+2
View File
@@ -0,0 +1,2 @@
ShaCraft synthetic fixture; never execute or install.
shacraft-launcher_0.3.0_linux-x86_64.AppImage
+1
View File
@@ -0,0 +1 @@
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEM4NUEyNTNEQjlERDZDMkIKUldRcmJOMjVQU1ZheUQ2UzVTN0NHS21ydFp1c1REajVucjlXYnFPK3ZSYWYrQVFDaUgvL3lpQ2UK
+1
View File
@@ -0,0 +1 @@
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDIyODAzRjlGOUFFNDM4QzYKUldUR09PU2FueitBSWhoU1Y4S2VFK21OeWRmamVkMlBreWRjbWdtRmNtbEZrTzMwNE92MDU2d3YK
+23 -5
View File
@@ -11,6 +11,7 @@ import { useAccount } from './hooks/useAccount'
import { useLauncher } from './hooks/useLauncher' import { useLauncher } from './hooks/useLauncher'
import { useServerStatus } from './hooks/useServerStatus' import { useServerStatus } from './hooks/useServerStatus'
import { useSettings } from './hooks/useSettings' import { useSettings } from './hooks/useSettings'
import { useUpdater } from './hooks/useUpdater'
import { isNative } from './services/native' import { isNative } from './services/native'
import { launchAccess } from './state/account' import { launchAccess } from './state/account'
import { installStageLabels } from './state/game' import { installStageLabels } from './state/game'
@@ -36,13 +37,23 @@ export function App() {
const access = launchAccess(session.account) const access = launchAccess(session.account)
const checking = desktop && (!profile || profile.status === 'checking') const checking = desktop && (!profile || profile.status === 'checking')
const settingsBlocked = !preferences.loaded || preferences.saving || !!preferences.error 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)) (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 const error = launcher.game.error ?? preferences.error ?? windowError ?? launcher.environmentError ?? session.error ?? profile?.error ?? null
let label = 'Играть' let label = 'Играть'
if (!desktop) label = 'В приложении' if (!desktop) label = 'В приложении'
else if (updateLocked) label = updaterRecovery ? 'Нужно восстановить лаунчер' : 'Обновление лаунчера'
else if (operation.phase === 'running') label = 'Игра запущена' else if (operation.phase === 'running') label = 'Игра запущена'
else if (operation.phase === 'launching') label = 'Запускаем…' else if (operation.phase === 'launching') label = 'Запускаем…'
else if (operation.phase === 'installing') label = operation.progress ? `${installStageLabels[operation.progress.stage]}` : 'Подготовка…' else if (operation.phase === 'installing') label = operation.progress ? `${installStageLabels[operation.progress.stage]}` : 'Подготовка…'
@@ -56,7 +67,7 @@ export function App() {
else if (!ready) label = 'Проверить' else if (!ready) label = 'Проверить'
const onboard = async (nickname: string) => { 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) const result = await launcher.onboard(selected.profileId, nickname)
if (result?.onboarding) session.acceptChallenge(result.onboarding) if (result?.onboarding) session.acceptChallenge(result.onboarding)
} }
@@ -71,8 +82,14 @@ export function App() {
<Titlebar host={launcher.host} onError={setWindowError} /> <Titlebar host={launcher.host} onError={setWindowError} />
<div className="workspace" inert={session.recoveryCodes.length > 0 || legacyOpen}> <div className="workspace" inert={session.recoveryCodes.length > 0 || legacyOpen}>
<Library selected={selected} profiles={launcher.profiles} account={session.account} <Library selected={selected} profiles={launcher.profiles} account={session.account}
native={desktop} locked={busy || session.busy} onSelect={setSelected} onSettings={() => setSettingsOpen(true)} /> native={desktop} locked={busy || updateLocked || session.busy} onSelect={setSelected} onSettings={() => setSettingsOpen(true)} />
<ServerStage server={displayed} status={serverStatus} javaMajor={metadata?.javaMajor}> <ServerStage server={displayed} status={serverStatus} javaMajor={metadata?.javaMajor}>
{(updater.state.status?.phase === 'available' || (updater.state.status?.phase === 'manual' && updater.state.status.availableVersion) || updater.state.status?.phase === 'ready' || updater.mutating || updaterRecovery) &&
<button className="launcher-update-notice" onClick={() => setSettingsOpen(true)}>
{updaterRecovery ? 'Нужно восстановить лаунчер' : updater.state.status?.phase === 'ready' ? 'Перезапустите лаунчер после обновления'
: updater.mutating ? 'Обновление лаунчера…' : `Доступен лаунчер ${updater.state.status?.availableVersion ?? ''}`}
<span>Открыть настройки</span>
</button>}
<PlayDock server={displayed} operation={operation} profile={profile} <PlayDock server={displayed} operation={operation} profile={profile}
memoryGb={preferences.settings.memoryMb / 1024} native={desktop} memoryGb={preferences.settings.memoryMb / 1024} native={desktop}
needsLogin={access === 'login'} needsLink={access === 'link'} needsLogin={access === 'login'} needsLink={access === 'link'}
@@ -80,7 +97,8 @@ export function App() {
onLegacy={() => setLegacyOpen(true)} onPrimary={primary} onRepair={() => { if (!repairDisabled) void launcher.repair(selected.profileId) }} /> onLegacy={() => setLegacyOpen(true)} onPrimary={primary} onRepair={() => { if (!repairDisabled) void launcher.repair(selected.profileId) }} />
</ServerStage> </ServerStage>
</div> </div>
<SettingsDrawer open={settingsOpen && !session.recoveryCodes.length} locked={busy} preferences={preferences} <SettingsDrawer open={settingsOpen && !session.recoveryCodes.length} locked={busy || updateLocked} preferences={preferences}
updater={updater} native={desktop}
session={session} host={launcher.host} java={launcher.java} requiredJava={metadata?.javaMajor} onOnboard={onboard} onClose={closeSettings} /> session={session} host={launcher.host} java={launcher.java} requiredJava={metadata?.javaMajor} onOnboard={onboard} onClose={closeSettings} />
{legacyOpen && <LegacyModsDialog profileId={selected.profileId} onClose={() => setLegacyOpen(false)} onChanged={() => { void launcher.refreshProfile(selected.profileId) }} />} {legacyOpen && <LegacyModsDialog profileId={selected.profileId} onClose={() => setLegacyOpen(false)} onChanged={() => { void launcher.refreshProfile(selected.profileId) }} />}
<RecoveryCodesModal codes={session.recoveryCodes} onAcknowledge={session.acknowledgeRecoveryCodes} /> <RecoveryCodesModal codes={session.recoveryCodes} onAcknowledge={session.acknowledgeRecoveryCodes} />
+76
View File
@@ -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 (
<section className="launcher-update" aria-labelledby="launcher-update-title">
<div className="launcher-update-heading"><h3 id="launcher-update-title">Лаунчер</h3>
<span>Версия {status?.installedVersion ?? installedVersion ?? 'уточняется'}{packageFormat && ` · ${packageFormat}`}</span></div>
{status?.testBuild && <p className="launcher-update-blocked">Тестовая сборка · тестовый канал обновлений</p>}
<p className="launcher-update-status" role="status">{!native ? 'Обновления доступны в приложении лаунчера.'
: phase ? titles[phase] : checking ? 'Читаем состояние обновления…' : 'Проверьте доступные обновления.'}</p>
{status?.availableVersion && <p>Новая версия: <strong>{status.availableVersion}</strong></p>}
{phase === 'manual' && <p>{status?.packageFormat === 'deb'
? 'Этот пакет обновляется вручную. Для установки .deb используйте системный менеджер пакетов; версия и инструкция доступны на странице выпусков.'
: 'Эта сборка обновляется вручную. Выберите пакет для своей системы на странице выпусков.'}</p>}
{phase === 'unconfigured' && <p>Для этой сборки канал обновлений недоступен. Проверка не меняет установленный лаунчер.</p>}
{phase === 'available' && <p>Установка закроет и перезапустит лаунчер. Игру потребуется завершить, изменения настроек сохранить.</p>}
{phase === 'ready' && <p>Чтобы продолжить работу в новой версии, перезапустите лаунчер.</p>}
{needsRecovery && <p>Автоматическое продолжение недоступно. Восстановите лаунчер из пакета на странице выпусков.</p>}
{status?.message && phase !== 'error' && <p>{status.message}</p>}
{status?.releaseNotes && <details className="launcher-update-notes"><summary>Что изменилось</summary><p tabIndex={0} aria-label="Описание изменений">{status.releaseNotes}</p></details>}
{progressing && <div className="launcher-update-progress" role="progressbar" aria-label="Обновление лаунчера"
aria-valuemin={0} aria-valuemax={100} aria-valuenow={downloading && percent !== null ? percent : undefined}>
<i style={downloading && percent !== null ? { width: `${percent}%` } : undefined} />
</div>}
{downloading && <p>{percent === null
? `Скачано: ${(Math.max(0, Number.isFinite(downloadedBytes) ? downloadedBytes : 0) / 1024 / 1024).toFixed(1)} МБ`
: `Скачано: ${percent}%`}</p>}
{error && <p className="status-error" role="alert">{error}</p>}
{state.blockedReason && native && <p className="launcher-update-blocked">{state.blockedReason}</p>}
<div className="launcher-update-actions">
{phase !== 'ready' && !needsRecovery && <button type="button" disabled={!native || !canRunUpdater(state, 'check')} onClick={onCheck}>
<RefreshCw size={15} />{checking ? 'Проверяем…' : error ? 'Повторить проверку' : 'Проверить обновления'}
</button>}
{phase === 'available' && <button type="button" className="launcher-update-primary" disabled={!native || !canRunUpdater(state, 'install')} onClick={onInstall}>
<Download size={15} />Установить и перезапустить
</button>}
{phase === 'ready' && <button type="button" className="launcher-update-primary" disabled={!native || !canRunUpdater(state, 'restart')} onClick={onRestart}>
{state.pending?.command === 'restart' ? 'Перезапускаем…' : 'Перезапустить лаунчер'}
</button>}
{(phase === 'manual' || needsRecovery) && <button type="button" disabled={!native || !canRunUpdater(state, 'open')} onClick={onOpenRelease}>
{state.pending?.command === 'open' ? 'Открываем…' : 'Открыть страницу выпусков'}
</button>}
</div>
</section>
)
}
+10 -2
View File
@@ -1,6 +1,8 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { FolderOpen, Wrench, X } from 'lucide-react' import { FolderOpen, Wrench, X } from 'lucide-react'
import { AccountSettings } from './AccountSettings' import { AccountSettings } from './AccountSettings'
import { LauncherUpdate } from './LauncherUpdate'
import type { useUpdater } from '../hooks/useUpdater'
import type { useAccount } from '../hooks/useAccount' import type { useAccount } from '../hooks/useAccount'
import type { useSettings } from '../hooks/useSettings' import type { useSettings } from '../hooks/useSettings'
import type { JavaInstallation, NativeHost } from '../types/launcher' import type { JavaInstallation, NativeHost } from '../types/launcher'
@@ -15,9 +17,11 @@ interface SettingsDrawerProps {
onClose: () => void onClose: () => void
requiredJava?: number requiredJava?: number
onOnboard?: (nickname: string) => Promise<void> onOnboard?: (nickname: string) => Promise<void>
updater: ReturnType<typeof useUpdater>
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 { settings, loaded, saving, error } = preferences
const closeButton = useRef<HTMLButtonElement>(null) const closeButton = useRef<HTMLButtonElement>(null)
useEffect(() => { useEffect(() => {
@@ -27,7 +31,9 @@ export function SettingsDrawer({ open, locked, host, java, preferences, session,
const onKey = (event: KeyboardEvent) => { const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose() if (event.key === 'Escape') onClose()
if (event.key !== 'Tab') return if (event.key !== 'Tab') return
const elements = closeButton.current?.closest('aside')?.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled)') const elements = Array.from(closeButton.current?.closest('aside')?.querySelectorAll<HTMLElement>(
'button:not(:disabled), input:not(:disabled), select:not(:disabled), summary, [tabindex="0"]',
) ?? []).filter((element) => element.getClientRects().length > 0)
const first = elements?.[0] const first = elements?.[0]
const last = elements?.[elements.length - 1] const last = elements?.[elements.length - 1]
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus() } if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus() }
@@ -49,6 +55,8 @@ export function SettingsDrawer({ open, locked, host, java, preferences, session,
<div><p>Настройки</p><h2 id="settings-title">Игра</h2></div> <div><p>Настройки</p><h2 id="settings-title">Игра</h2></div>
<button ref={closeButton} onClick={onClose} aria-label="Закрыть настройки"><X /></button> <button ref={closeButton} onClick={onClose} aria-label="Закрыть настройки"><X /></button>
</div> </div>
<LauncherUpdate state={updater.state} native={native} installedVersion={host?.launcherVersion}
onCheck={updater.check} onInstall={updater.install} onRestart={updater.restart} onOpenRelease={updater.openRelease} />
<label className="range-setting"> <label className="range-setting">
<span><strong>Оперативная память</strong><b>{settings.memoryMb / 1024} ГБ</b></span> <span><strong>Оперативная память</strong><b>{settings.memoryMb / 1024} ГБ</b></span>
<input type="range" min="3" max="12" step="1" value={settings.memoryMb / 1024} disabled={!loaded || locked} <input type="range" min="3" max="12" step="1" value={settings.memoryMb / 1024} disabled={!loaded || locked}
+86
View File
@@ -0,0 +1,86 @@
import { doesNotMatch, match } from 'node:assert/strict'
import { test } from 'node:test'
import { renderToStaticMarkup } from 'react-dom/server'
import { LauncherUpdate } from './LauncherUpdate'
import { initialUpdaterState } from '../state/updater'
import type { UpdaterState } from '../state/updater'
import type { UpdaterStatus } from '../types/updater'
function status(phase: UpdaterStatus['phase'], extra: Partial<UpdaterStatus> = {}): UpdaterStatus {
return { revision: 1, installedVersion: '0.2.0', testBuild: false, packageFormat: 'development', phase, availableVersion: null, releaseNotes: null,
downloadedBytes: 0, totalBytes: null, canRetry: false, message: null, ...extra }
}
function render(value: UpdaterStatus, extra: Partial<UpdaterState> = {}, native = true) {
return renderToStaticMarkup(<LauncherUpdate state={{ ...initialUpdaterState, status: value, ...extra }} native={native}
onCheck={() => {}} onInstall={() => {}} onRestart={() => {}} onOpenRelease={() => {}} />)
}
test('available updater notes are plain text and installation explicitly includes restart', () => {
const html = render(status('available', { availableVersion: '0.3.0', releaseNotes: '<img src=x onerror="alert(1)">\n[Link](https://example.invalid)' }))
match(html, /Версия 0\.2\.0/)
match(html, /Новая версия: <strong>0\.3\.0/)
match(html, /Установить и перезапустить/)
match(html, /Установка закроет и перезапустит лаунчер/)
match(html, /&lt;img src=x/)
doesNotMatch(html, /<img|<a\s|dangerouslySetInnerHTML|authenticode|notarization/i)
})
test('an unconfigured build still reports its installed native version', () => {
const html = render(status('unconfigured'))
match(html, /Версия 0\.2\.0/)
match(html, /Автообновление пока не настроено/)
doesNotMatch(html, /Установить и перезапустить/)
})
test('test updater builds have a visible native-provided notice', () => {
match(render(status('available', { testBuild: true })), /Тестовая сборка · тестовый канал обновлений/)
doesNotMatch(render(status('available')), /Тестовая сборка/)
})
test('manual Linux packages offer release instructions without a native install button or frontend link', () => {
const html = render(status('manual', { availableVersion: '0.3.0', packageFormat: 'deb' }))
match(html, /Версия 0\.2\.0 · deb/)
match(html, /системный менеджер пакетов/)
match(html, /Открыть страницу выпусков/)
doesNotMatch(html, /Установить и перезапустить|href=/)
})
test('pending game or settings work disables the explicit update action and explains why', () => {
const html = render(status('available'), { blockedReason: 'Игра запущена' })
match(html, /Игра запущена/)
match(html, /<button[^>]*class="launcher-update-primary"[^>]*disabled=""/)
})
test('unknown download size does not announce a false percentage', () => {
const html = render(status('downloading', { downloadedBytes: 1048576 }))
match(html, /Скачано: 1\.0 МБ/)
match(html, /role="progressbar"/)
doesNotMatch(html, /aria-valuenow|Скачано: 0%/)
match(render(status('downloading', { downloadedBytes: 50, totalBytes: 100 })), /aria-valuenow="50"/)
})
test('verification, installation and restart fallback remain distinct', () => {
match(render(status('verifying')), /Проверяем подпись обновления/)
match(render(status('installing')), /Устанавливаем обновление/)
const ready = render(status('ready'), { error: 'Не удалось перезапустить' })
match(ready, /Перезапустить лаунчер/)
match(ready, /role="alert">Не удалось перезапустить/)
doesNotMatch(ready, /Установить и перезапустить|Проверить обновления/)
})
test('check failures allow a clear retry, and an up-to-date result needs no install action', () => {
match(render(status('error', { canRetry: true, message: 'Сеть недоступна' })), /Повторить проверку/)
const current = render(status('no_update'))
match(current, /Установлена актуальная версия/)
doesNotMatch(current, /Установить и перезапустить/)
const preview = render(status('available'), {}, false)
match(preview, /Обновления доступны в приложении лаунчера/)
match(preview, /class="launcher-update-primary" disabled=""/)
})
test('an indeterminate install failure offers manual recovery rather than retrying installation', () => {
const html = render(status('error', { canRetry: false, message: 'Не удалось определить результат установки.' }))
match(html, /Восстановите лаунчер из пакета/)
match(html, /Открыть страницу выпусков/)
doesNotMatch(html, /Повторить проверку|Установить и перезапустить|Перезапустить лаунчер/)
})
+26
View File
@@ -0,0 +1,26 @@
import { useEffect, useRef, useSyncExternalStore } from 'react'
import { isNative, native, watchUpdater } from '../services/native'
import { createUpdaterController } from '../services/updater'
import { initialUpdaterState, updaterMutating } from '../state/updater'
export function useUpdater(blockedReason: string | null) {
const controller = useRef(createUpdaterController({
status: native.updaterStatus,
check: native.checkUpdater,
install: native.installUpdater,
restart: native.restartUpdater,
open: native.openUpdaterRelease,
watch: watchUpdater,
})).current
const state = useSyncExternalStore(controller.subscribe, controller.snapshot, () => initialUpdaterState)
useEffect(() => { controller.setBlockedReason(blockedReason) }, [controller, blockedReason])
useEffect(() => {
if (isNative()) return controller.connect()
}, [controller])
return { state, mutating: updaterMutating(state),
check: () => { void controller.check() },
install: () => { void controller.install() },
restart: () => { void controller.restart() },
openRelease: () => { void controller.open() },
}
}
+12
View File
@@ -2,6 +2,7 @@ import { invoke, isTauri } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event' import { listen } from '@tauri-apps/api/event'
import { getCurrentWindow } from '@tauri-apps/api/window' import { getCurrentWindow } from '@tauri-apps/api/window'
import { createSerialQueue, createSubscription, singleFlight } from './async' import { createSerialQueue, createSubscription, singleFlight } from './async'
import type { UpdaterStatus } from '../types/updater'
import type { import type {
GameExitedPayload, InstallProgressPayload, JavaInstallation, LauncherSettings, GameExitedPayload, InstallProgressPayload, JavaInstallation, LauncherSettings,
LinkChallenge, LinkStatus, NativeHost, ProfileInspection, ServerStatus, LinkChallenge, LinkStatus, NativeHost, ProfileInspection, ServerStatus,
@@ -15,6 +16,11 @@ const restoreAccount = singleFlight(() => accountRequests.enqueue(() => invoke<S
// Keep the IPC contract in one place. UI components never invoke native // Keep the IPC contract in one place. UI components never invoke native
// commands directly and cannot pass arbitrary URLs or filesystem paths. // commands directly and cannot pass arbitrary URLs or filesystem paths.
export const native = { export const native = {
updaterStatus: () => invoke<UpdaterStatus>('updater_status'),
checkUpdater: () => invoke<UpdaterStatus>('updater_check'),
installUpdater: () => invoke<UpdaterStatus>('updater_download_install'),
restartUpdater: () => invoke<void>('updater_restart'),
openUpdaterRelease: () => invoke<void>('updater_open_release_page'),
host: () => invoke<NativeHost>('native_host'), host: () => invoke<NativeHost>('native_host'),
metadata: (profileId: string) => invoke<ProfileMetadata>('profile_metadata', { profileId }), metadata: (profileId: string) => invoke<ProfileMetadata>('profile_metadata', { profileId }),
legacyMods: (profileId: string) => invoke<LegacyMod[]>('legacy_mods', { profileId }), legacyMods: (profileId: string) => invoke<LegacyMod[]>('legacy_mods', { profileId }),
@@ -36,6 +42,12 @@ export const native = {
launchOnboarding: (profileId: string, nickname: string) => invoke<PreparationResult>('launch_onboarding', { profileId, nickname }), launchOnboarding: (profileId: string, nickname: string) => invoke<PreparationResult>('launch_onboarding', { profileId, nickname }),
} }
export function watchUpdater(receive: (status: UpdaterStatus) => void) {
return createSubscription([
listen<UpdaterStatus>('launcher-update-status', ({ payload }) => receive(payload)),
])
}
export const windowControls = { export const windowControls = {
minimize: () => getCurrentWindow().minimize(), minimize: () => getCurrentWindow().minimize(),
toggleMaximize: () => getCurrentWindow().toggleMaximize(), toggleMaximize: () => getCurrentWindow().toggleMaximize(),
+112
View File
@@ -0,0 +1,112 @@
import { errorMessage } from './async'
import { canRunUpdater, initialUpdaterState, updaterReducer } from '../state/updater'
import type { UpdaterAction, UpdaterCommand, UpdaterState } from '../state/updater'
import type { UpdaterStatus } from '../types/updater'
export interface UpdaterApi {
status: () => Promise<UpdaterStatus>
check: () => Promise<UpdaterStatus>
install: () => Promise<UpdaterStatus>
restart: () => Promise<void>
open: () => Promise<void>
watch: (receive: (status: UpdaterStatus) => void) => { ready: Promise<void>; dispose: () => void }
}
/** One window lifecycle, including StrictMode reconnects; never installs on its own. */
export function createUpdaterController(api: UpdaterApi) {
let state = initialUpdaterState
const subscribers = new Set<() => void>()
let request = 0
let generation = 0
let connected = false
let initialized = false
let automaticCheckStarted = false
let subscription: ReturnType<UpdaterApi['watch']> | null = null
const dispatch = (action: UpdaterAction) => {
const next = updaterReducer(state, action)
if (next === state) return
state = next
subscribers.forEach((notify) => notify())
}
const ensureEvents = () => {
if (!subscription) {
const current = generation
const created = api.watch((status) => {
if (connected && generation === current) dispatch({ type: 'status', status })
})
subscription = created
void created.ready.catch(() => {
if (subscription === created) { created.dispose(); subscription = null }
})
}
return subscription.ready
}
const run = async (command: Exclude<UpdaterCommand, 'status'>): Promise<boolean> => {
if (!connected || !canRunUpdater(state, command)) return false
const current = ++request
const currentGeneration = generation
dispatch({ type: 'begin', command, request: current })
try {
if (command !== 'open') await ensureEvents()
// Settings/account work may have started while the listener registered.
if (!connected || generation !== currentGeneration || (command !== 'open' && state.blockedReason)) return false
const status = await api[command]()
if (status) dispatch({ type: 'status', status })
return true
} catch (reason) {
dispatch({ type: 'failed', request: current, error: errorMessage(reason, 'Не удалось выполнить обновление лаунчера. Повторите попытку.') })
return false
} finally {
dispatch({ type: 'settled', request: current })
}
}
const automaticCheck = () => {
if (!connected || !initialized || automaticCheckStarted || !canRunUpdater(state, 'check')) return
automaticCheckStarted = true
void run('check')
}
const connect = () => {
connected = true
const current = ++generation
const read = ++request
// A user-started native operation can outlive a UI reconnect.
const ownsPending = state.pending === null
if (ownsPending) dispatch({ type: 'begin', command: 'status', request: read })
void ensureEvents().then(() => connected && generation === current ? api.status() : null).then((status) => {
if (!connected || generation !== current) return
if (status) dispatch({ type: 'status', status })
initialized = true
}).catch((reason) => {
if (connected && generation === current && ownsPending) dispatch({ type: 'failed', request: read,
error: errorMessage(reason, 'Не удалось прочитать состояние обновления лаунчера.') })
}).finally(() => {
if (ownsPending) dispatch({ type: 'settled', request: read })
if (connected && generation === current) automaticCheck()
})
return () => {
if (generation !== current) return
connected = false
initialized = false
generation++
subscription?.dispose()
subscription = null
if (ownsPending) dispatch({ type: 'settled', request: read })
}
}
return {
snapshot: (): UpdaterState => state,
subscribe: (notify: () => void) => { subscribers.add(notify); return () => { subscribers.delete(notify) } },
connect,
setBlockedReason: (reason: string | null) => { dispatch({ type: 'blocked', reason }); automaticCheck() },
check: () => { automaticCheckStarted = true; return run('check') },
install: () => run('install'),
restart: () => run('restart'),
open: () => run('open'),
}
}
+222
View File
@@ -0,0 +1,222 @@
import { deepStrictEqual, equal, match } from 'node:assert/strict'
import { test } from 'node:test'
import { canRunUpdater, initialUpdaterState, updaterMutating, updaterPercent, updaterReducer } from './updater'
import { createUpdaterController } from '../services/updater'
import type { UpdaterApi } from '../services/updater'
import type { UpdaterStatus } from '../types/updater'
function status(phase: UpdaterStatus['phase'], revision = 0): UpdaterStatus {
return { revision, installedVersion: '0.2.0', testBuild: false, packageFormat: 'development', phase, availableVersion: null, releaseNotes: null,
downloadedBytes: 0, totalBytes: null, canRetry: false, message: null }
}
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (error: unknown) => void
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no })
return { promise, resolve, reject }
}
const flush = () => new Promise<void>((resolve) => setImmediate(resolve))
function fixture(overrides: Partial<UpdaterApi> = {}) {
const calls = { status: 0, check: 0, install: 0, restart: 0, open: 0, disposed: 0 }
const receivers: Array<(value: UpdaterStatus) => void> = []
const api: UpdaterApi = {
status: async () => { calls.status++; return status('idle') },
check: async () => { calls.check++; return status('available', calls.check) },
install: async () => { calls.install++; return status('ready', 50) },
restart: async () => { calls.restart++ },
open: async () => { calls.open++ },
watch: (receive) => { receivers.push(receive); return { ready: Promise.resolve(), dispose: () => { calls.disposed++ } } },
...overrides,
}
return { calls, receivers, api, controller: createUpdaterController(api) }
}
test('newer native progress wins over an older invoke result; stale failures cannot finish another request', () => {
const started = updaterReducer(initialUpdaterState, { type: 'begin', command: 'install', request: 1 })
const progress = updaterReducer(started, { type: 'status', status: status('verifying', 4) })
equal(updaterReducer(progress, { type: 'status', status: status('downloading', 3) }), progress)
equal(updaterReducer(progress, { type: 'status', status: status('available', 4) }), progress)
const failed = updaterReducer(progress, { type: 'failed', request: 1, error: 'Network interrupted' })
const retry = updaterReducer(failed, { type: 'begin', command: 'check', request: 2 })
equal(updaterReducer(retry, { type: 'failed', request: 1, error: 'Old error' }), retry)
equal(updaterReducer(retry, { type: 'settled', request: 1 }), retry)
equal(retry.error, null)
})
test('one startup check survives a StrictMode reconnect and never downloads or restarts', async () => {
const { controller, calls } = fixture()
const disconnect = controller.connect()
disconnect() // StrictMode cleans up before listener registration has resolved.
const disconnectRemount = controller.connect()
await flush()
equal(calls.check, 1)
equal(calls.status, 1)
equal(controller.snapshot().status?.phase, 'available')
disconnectRemount()
const disconnectAgain = controller.connect()
await flush()
equal(calls.check, 1)
equal(calls.install, 0)
equal(calls.restart, 0)
disconnectAgain()
})
test('new pending account work or UI disposal before native handoff cancels an install request', async () => {
const { controller, calls } = fixture()
const disconnect = controller.connect()
await flush()
const blockedBeforeHandoff = controller.install()
controller.setBlockedReason('Аккаунт сохраняется')
equal(await blockedBeforeHandoff, false)
equal(calls.install, 0)
equal(controller.snapshot().pending, null)
controller.setBlockedReason(null)
const cancelledBeforeHandoff = controller.install()
disconnect()
equal(await cancelledBeforeHandoff, false)
equal(calls.install, 0)
})
test('disconnect before event registration resolves cancels initialization and rejects old listener events', async () => {
const ready = deferred<void>()
let receive!: (value: UpdaterStatus) => void
let disposed = 0
const { controller, calls } = fixture({ watch: (callback) => {
receive = callback
return { ready: ready.promise, dispose: () => { disposed++ } }
} })
const disconnect = controller.connect()
disconnect()
ready.resolve()
receive(status('available', 99))
await flush()
equal(disposed, 1)
equal(calls.status, 0)
equal(calls.check, 0)
equal(controller.snapshot().status, null)
equal(controller.snapshot().pending, null)
})
test('an earlier status read cannot hide an in-flight native installation', async () => {
const read = deferred<UpdaterStatus>()
const { controller, receivers, calls } = fixture({ status: () => read.promise })
const disconnect = controller.connect()
await flush()
receivers[0]?.(status('installing', 6))
read.resolve(status('idle', 0))
await flush()
equal(controller.snapshot().status?.phase, 'installing')
equal(updaterMutating(controller.snapshot()), true)
equal(calls.check, 0)
equal(await controller.install(), false)
disconnect()
})
test('startup check waits for pending work and explicit installation cannot overlap it or a double click', async () => {
const installation = deferred<UpdaterStatus>()
let installs = 0
const { controller, calls } = fixture({ install: () => { installs++; return installation.promise } })
controller.setBlockedReason('Сохраняем настройки')
const disconnect = controller.connect()
await flush()
equal(calls.check, 0)
controller.setBlockedReason(null)
await flush()
equal(calls.check, 1)
controller.setBlockedReason('Игра запущена')
equal(await controller.install(), false)
equal(installs, 0)
controller.setBlockedReason(null)
const first = controller.install()
equal(await controller.install(), false)
await flush()
equal(installs, 1)
equal(updaterMutating(controller.snapshot()), true)
installation.resolve(status('ready', 7))
equal(await first, true)
equal(calls.restart, 0) // The native install owns its restart; the UI never sends a second one.
disconnect()
})
test('a failed automatic check releases its request; retry is explicit and never installs', async () => {
let attempts = 0
const { controller, calls } = fixture({ check: async () => {
if (++attempts === 1) throw new Error('Network unavailable')
return status('available', 3)
} })
const disconnect = controller.connect()
await flush()
match(controller.snapshot().error ?? '', /Network unavailable/)
equal(controller.snapshot().pending, null)
await flush()
equal(attempts, 1)
equal(await controller.check(), true)
equal(controller.snapshot().error, null)
equal(controller.snapshot().status?.phase, 'available')
equal(calls.install, 0)
disconnect()
})
test('failed event subscription can be retried without leaving a dead pending operation', async () => {
let subscriptions = 0
let disposed = 0
const { controller, calls } = fixture({ watch: () => ({
ready: ++subscriptions === 1 ? Promise.reject(new Error('Events unavailable')) : Promise.resolve(),
dispose: () => { disposed++ },
}) })
const disconnect = controller.connect()
await flush()
match(controller.snapshot().error ?? '', /Events unavailable/)
equal(calls.check, 0)
equal(await controller.check(), true)
equal(subscriptions, 2)
equal(disposed, 1)
equal(calls.check, 1)
disconnect()
})
test('manual packages and a ready restart cannot accidentally invoke installation', () => {
const manual = { ...initialUpdaterState, status: status('manual') }
equal(canRunUpdater(manual, 'install'), false)
equal(canRunUpdater(manual, 'open'), true)
const ready = { ...initialUpdaterState, status: status('ready') }
equal(canRunUpdater(ready, 'check'), false)
equal(canRunUpdater(ready, 'install'), false)
equal(canRunUpdater(ready, 'restart'), true)
equal(canRunUpdater({ ...ready, blockedReason: 'Аккаунт сохраняется' }, 'restart'), false)
})
test('an indeterminate installer failure only permits the fixed manual recovery page', () => {
const recovery = { ...initialUpdaterState, status: status('error'), blockedReason: 'Настройки не сохранены' }
equal(canRunUpdater(recovery, 'check'), false)
equal(canRunUpdater(recovery, 'install'), false)
equal(canRunUpdater(recovery, 'restart'), false)
equal(canRunUpdater(recovery, 'open'), true)
})
test('manual recovery can open the fixed page even when a failed settings save blocks mutations', async () => {
const { controller, calls } = fixture({ status: async () => status('error', 2) })
controller.setBlockedReason('Настройки не сохранены')
const disconnect = controller.connect()
await flush()
equal(await controller.open(), true)
equal(calls.open, 1)
equal(calls.install, 0)
equal(calls.check, 0)
disconnect()
})
test('unknown download totals stay indeterminate and finite totals are bounded', () => {
equal(updaterPercent(null), null)
const downloading = status('downloading')
equal(updaterPercent(downloading), null)
equal(updaterPercent({ ...downloading, downloadedBytes: 10, totalBytes: 0 }), null)
equal(updaterPercent({ ...downloading, downloadedBytes: NaN, totalBytes: 10 }), null)
equal(updaterPercent({ ...downloading, downloadedBytes: 10, totalBytes: Infinity }), null)
equal(updaterPercent({ ...downloading, downloadedBytes: -1, totalBytes: 10 }), null)
deepStrictEqual([updaterPercent({ ...downloading, downloadedBytes: 7, totalBytes: 10 }),
updaterPercent({ ...downloading, downloadedBytes: 11, totalBytes: 10 })], [70, 100])
})
+56
View File
@@ -0,0 +1,56 @@
import type { UpdaterStatus } from '../types/updater'
export type UpdaterCommand = 'status' | 'check' | 'install' | 'restart' | 'open'
export interface UpdaterState {
status: UpdaterStatus | null
pending: { command: UpdaterCommand; request: number } | null
error: string | null
blockedReason: string | null
}
export const initialUpdaterState: UpdaterState = { status: null, pending: null, error: null, blockedReason: null }
export type UpdaterAction =
| { type: 'status'; status: UpdaterStatus }
| { type: 'begin'; command: UpdaterCommand; request: number }
| { type: 'settled'; request: number }
| { type: 'failed'; request: number; error: string }
| { type: 'blocked'; reason: string | null }
export function updaterReducer(state: UpdaterState, action: UpdaterAction): UpdaterState {
switch (action.type) {
case 'status':
// Event delivery and invoke completion may arrive in either order.
if (state.status && action.status.revision <= state.status.revision) return state
return { ...state, status: action.status, error: null }
case 'begin':
return state.pending ? state : { ...state, pending: { command: action.command, request: action.request }, error: null }
case 'settled':
return state.pending?.request === action.request ? { ...state, pending: null } : state
case 'failed':
return state.pending?.request === action.request ? { ...state, pending: null, error: action.error } : state
case 'blocked':
return state.blockedReason === action.reason ? state : { ...state, blockedReason: action.reason }
}
}
export function updaterMutating(state: UpdaterState): boolean {
return state.pending?.command === 'install' || state.pending?.command === 'restart' ||
state.status?.phase === 'downloading' || state.status?.phase === 'verifying' || state.status?.phase === 'installing'
}
export function canRunUpdater(state: UpdaterState, command: Exclude<UpdaterCommand, 'status'>): boolean {
if (state.pending) return false
const phase = state.status?.phase
if (command === 'open') return phase === 'manual' || (phase === 'error' && state.status?.canRetry === false)
if (state.blockedReason || updaterMutating(state)) return false
if (command === 'restart') return phase === 'ready'
if (command === 'install') return phase === 'available'
if (phase === 'error' && state.status?.canRetry === false) return false
return !phase || ['idle', 'available', 'no_update', 'manual', 'unconfigured', 'error'].includes(phase)
}
export function updaterPercent(status: UpdaterStatus | null): number | null {
if (!status || status.totalBytes === null || !Number.isFinite(status.totalBytes) || status.totalBytes <= 0 ||
!Number.isFinite(status.downloadedBytes) || status.downloadedBytes < 0) return null
return Math.min(100, Math.floor(100 * status.downloadedBytes / status.totalBytes))
}
+23 -1
View File
@@ -126,7 +126,7 @@ button:disabled { cursor: default; }
.drawer-backdrop { position: fixed; inset: 48px 0 0; background: rgba(0,0,0,.44); opacity: 0; pointer-events: none; transition: opacity .2s; z-index: 20; } .drawer-backdrop { position: fixed; inset: 48px 0 0; background: rgba(0,0,0,.44); opacity: 0; pointer-events: none; transition: opacity .2s; z-index: 20; }
.drawer-backdrop.visible { opacity: 1; pointer-events: auto; } .drawer-backdrop.visible { opacity: 1; pointer-events: auto; }
.settings-drawer { position: fixed; top: 48px; right: 0; bottom: 0; width: 390px; overflow-y: auto; background: #121713; border-left: 1px solid var(--line); z-index: 21; padding: 28px; transform: translateX(100%); transition: transform .24s cubic-bezier(.2,.8,.2,1); box-shadow: -30px 0 70px rgba(0,0,0,.35); } .settings-drawer { position: fixed; top: 48px; right: 0; bottom: 0; width: min(390px, 100vw); overflow-y: auto; background: #121713; border-left: 1px solid var(--line); z-index: 21; padding: clamp(18px, 3vw, 28px); transform: translateX(100%); transition: transform .24s cubic-bezier(.2,.8,.2,1); box-shadow: -30px 0 70px rgba(0,0,0,.35); }
.settings-drawer.open { transform: translateX(0); } .settings-drawer.open { transform: translateX(0); }
.drawer-title { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 34px; } .drawer-title { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 34px; }
.drawer-title p { color: #737c74; font-size: 11px; margin: 0 0 5px; } .drawer-title p { color: #737c74; font-size: 11px; margin: 0 0 5px; }
@@ -188,3 +188,25 @@ button:disabled { cursor: default; }
.legacy-actions { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 20px; } .legacy-actions { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 20px; }
.legacy-actions button { font: inherit; padding: 10px 14px; cursor: pointer; } .legacy-actions button { font: inherit; padding: 10px 14px; cursor: pointer; }
.account-hint { white-space: pre-wrap; overflow-wrap: anywhere; user-select: text; } .account-hint { white-space: pre-wrap; overflow-wrap: anywhere; user-select: text; }
.launcher-update { padding: 0 0 24px; margin-bottom: 24px; border-bottom: 1px solid var(--line); }
.launcher-update-heading { display: flex; flex-wrap: wrap; gap: 8px; justify-content: space-between; align-items: baseline; }
.launcher-update-heading h3 { margin: 0; font-size: 14px; }
.launcher-update-heading > span { color: var(--muted); font-size: 11px; }
.launcher-update p { font-size: 11px; line-height: 1.6; color: #aeb7af; overflow-wrap: anywhere; }
.launcher-update .launcher-update-status { color: var(--green); font-weight: 700; }
.launcher-update .status-error { color: #eea18f; }
.launcher-update .launcher-update-blocked { color: var(--copper); }
.launcher-update-actions { display: grid; gap: 8px; margin-top: 14px; }
.launcher-update-actions button { display: flex; gap: 8px; align-items: center; justify-content: center; border: 1px solid var(--line); border-radius: 5px; background: #222b24; padding: 10px 12px; font-size: 11px; cursor: pointer; }
.launcher-update-actions button.launcher-update-primary { background: var(--green); color: var(--ink); font-weight: 800; }
.launcher-update-actions button:disabled { opacity: .45; cursor: default; }
.launcher-update-notes { margin-top: 12px; font-size: 11px; }
.launcher-update-notes summary { cursor: pointer; color: var(--ice); }
.launcher-update-notes p { white-space: pre-wrap; max-height: 220px; overflow-y: auto; user-select: text; }
.launcher-update-notes p:focus-visible, .launcher-update-notes summary:focus-visible { outline: 2px solid var(--green); outline-offset: 2px; }
.launcher-update-progress { height: 5px; background: #283029; overflow: hidden; border-radius: 3px; }
.launcher-update-progress i { display: block; height: 100%; width: 100%; background: var(--ice); }
.launcher-update-progress:not([aria-valuenow]) i { opacity: .5; }
.launcher-update-notice { position: absolute; top: 70px; right: 26px; display: grid; gap: 3px; text-align: left; padding: 10px 14px; border: 1px solid #34553b; border-radius: 5px; background: #14261b; color: #c6e8c8; font-size: 11px; cursor: pointer; }
.launcher-update-notice span { color: #8daf95; font-size: 10px; }
+15
View File
@@ -0,0 +1,15 @@
/** Native updater owns endpoints, verification keys, package choice and versions. */
export interface UpdaterStatus {
revision: number
installedVersion: string
testBuild: boolean
packageFormat: 'development' | 'AppImage' | 'deb' | 'rpm' | 'MSI' | 'NSIS' | 'app' | 'unpackaged'
phase: 'idle' | 'checking' | 'available' | 'downloading' | 'verifying' | 'installing' |
'ready' | 'no_update' | 'unconfigured' | 'manual' | 'error'
availableVersion: string | null
releaseNotes: string | null
downloadedBytes: number
totalBytes: number | null
canRetry: boolean
message: string | null
}