feat: add signed launcher updates with native lifecycle recovery
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||||
|
|||||||
Generated
+317
-7
@@ -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",
|
||||||
|
|||||||
@@ -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"] }
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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())?;
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())?;
|
||||||
|
|||||||
@@ -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())
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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
@@ -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"
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheUVnVElINVRoVDVpb2VGV21XT0wzYXN1WmxxTEhGZHVvYzg3S1FVcUFPVnVqa3FpRXFWbFl2T2FHN2ZmOU1FRVpQR1krREhCcU00RVpXMXYzd2s5UndnPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzIxCWZpbGU6bGF0ZXN0Lmpzb24KVkxhbGNUZE9NSUpTWlR3L1hDczFTTkFzbjkrK1ZyWE9EeG5xTUVBUnRYcW1kZHphNXVTTEs4OTU0WitORWdQdk9PaXNhVWVvbTFJRHFBeWRtQmdzQXc9PQo=
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ShaCraft synthetic fixture; never execute or install.
|
||||||
|
shacraft-launcher_0.3.0_linux-x86_64.AppImage
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEM4NUEyNTNEQjlERDZDMkIKUldRcmJOMjVQU1ZheUQ2UzVTN0NHS21ydFp1c1REajVucjlXYnFPK3ZSYWYrQVFDaUgvL3lpQ2UK
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDIyODAzRjlGOUFFNDM4QzYKUldUR09PU2FueitBSWhoU1Y4S2VFK21OeWRmamVkMlBreWRjbWdtRmNtbEZrTzMwNE92MDU2d3YK
|
||||||
+23
-5
@@ -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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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, /<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, /Повторить проверку|Установить и перезапустить|Перезапустить лаунчер/)
|
||||||
|
})
|
||||||
@@ -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() },
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
})
|
||||||
@@ -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
@@ -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; }
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user