feat: add signed launcher updates with native lifecycle recovery

This commit is contained in:
Emil
2026-09-09 19:48:10 +03:00
parent 7c0eb20894
commit 1d08396b80
39 changed files with 2952 additions and 46 deletions
+317 -7
View File
@@ -1770,6 +1770,36 @@ dependencies = [
"windows-sys 0.45.0",
]
[[package]]
name = "jni"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
dependencies = [
"cfg-if",
"combine",
"jni-macros",
"jni-sys 0.4.1",
"log",
"simd_cesu8",
"thiserror 2.0.20",
"walkdir",
"windows-link 0.2.1",
]
[[package]]
name = "jni-macros"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"simd_cesu8",
"syn 2.0.119",
]
[[package]]
name = "jni-sys"
version = "0.3.1"
@@ -1975,6 +2005,12 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2226,6 +2262,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.13.1",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
@@ -2251,6 +2288,18 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-osa-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
dependencies = [
"bitflags 2.13.1",
"objc2",
"objc2-app-kit",
"objc2-foundation",
]
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
@@ -2314,12 +2363,32 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "osakit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
dependencies = [
"objc2",
"objc2-foundation",
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 2.0.20",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -2836,15 +2905,20 @@ dependencies = [
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
"serde_json",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
@@ -2912,6 +2986,18 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
@@ -2922,6 +3008,33 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
dependencies = [
"core-foundation",
"core-foundation-sys",
"jni 0.22.4",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.15"
@@ -2954,6 +3067,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "schemars"
version = "0.8.22"
@@ -3011,6 +3133,29 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.1",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "selectors"
version = "0.36.1"
@@ -3235,13 +3380,16 @@ dependencies = [
[[package]]
name = "shacraft-launcher"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"base64 0.22.1",
"ed25519-dalek",
"flate2",
"md-5",
"minisign-verify",
"reqwest 0.12.28",
"reqwest 0.13.4",
"semver",
"serde",
"serde_json",
"sha1",
@@ -3250,6 +3398,8 @@ dependencies = [
"tar",
"tauri",
"tauri-build",
"tauri-plugin-updater",
"time",
"url",
"zip",
]
@@ -3275,6 +3425,22 @@ version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "simd_cesu8"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
dependencies = [
"rustc_version",
"simdutf8",
]
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "siphasher"
version = "1.0.3"
@@ -3511,7 +3677,7 @@ dependencies = [
"gdkwayland-sys",
"gdkx11-sys",
"gtk",
"jni",
"jni 0.21.1",
"libc",
"log",
"ndk",
@@ -3578,7 +3744,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"jni",
"jni 0.21.1",
"libc",
"log",
"mime",
@@ -3674,6 +3840,54 @@ dependencies = [
"tauri-utils",
]
[[package]]
name = "tauri-plugin"
version = "2.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020"
dependencies = [
"anyhow",
"glob",
"plist",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri-utils",
"walkdir",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b28d8cabdeb0564f03ae261963de4bc3d98321cd3d213e76a81b7d344e5df606"
dependencies = [
"base64 0.22.1",
"dirs",
"flate2",
"futures-util",
"http",
"infer",
"log",
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest 0.13.4",
"rustls",
"semver",
"serde",
"serde_json",
"tar",
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.20",
"time",
"tokio",
"url",
"windows-sys 0.60.2",
]
[[package]]
name = "tauri-runtime"
version = "2.11.3"
@@ -3684,7 +3898,7 @@ dependencies = [
"dpi",
"gtk",
"http",
"jni",
"jni 0.21.1",
"objc2",
"objc2-ui-kit",
"objc2-web-kit",
@@ -3707,7 +3921,7 @@ checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f"
dependencies = [
"gtk",
"http",
"jni",
"jni 0.21.1",
"log",
"objc2",
"objc2-app-kit",
@@ -3774,6 +3988,19 @@ dependencies = [
"toml 1.1.5+spec-1.1.0",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "tendril"
version = "0.5.1"
@@ -4451,6 +4678,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
@@ -4750,6 +4986,15 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -4783,13 +5028,30 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm",
"windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link 0.2.1",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
]
[[package]]
name = "windows-threading"
version = "0.1.0"
@@ -4829,6 +5091,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
@@ -4841,6 +5109,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
@@ -4853,12 +5127,24 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
@@ -4871,6 +5157,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
@@ -4883,6 +5175,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
@@ -4895,6 +5193,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
@@ -4907,6 +5211,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.5.40"
@@ -4971,7 +5281,7 @@ dependencies = [
"gtk",
"http",
"javascriptcore-rs",
"jni",
"jni 0.21.1",
"libc",
"ndk",
"objc2",
+7 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "shacraft-launcher"
version = "0.1.1"
version = "0.2.0"
description = "ShaCraft Minecraft launcher"
authors = ["ShaCraft"]
license = "MIT"
@@ -28,3 +28,9 @@ flate2 = "1"
tar = "0.4"
zip = { version = "2", default-features = false, features = ["deflate"] }
sysinfo = { version = "0.39.6", default-features = false, features = ["system"] }
tauri-plugin-updater = { version = "=2.11.0", default-features = false, features = ["rustls-tls"] }
minisign-verify = "=0.2.5"
semver = "1"
time = { version = "0.3", features = ["parsing", "formatting"] }
reqwest-updater = { package = "reqwest", version = "0.13", default-features = false, features = ["rustls-no-provider"] }
+3
View File
@@ -1,3 +1,6 @@
fn main() {
println!("cargo:rerun-if-env-changed=SHACRAFT_UPDATER_PUBLIC_KEY");
println!("cargo:rerun-if-env-changed=SHACRAFT_UPDATER_TEST_BUILD");
println!("cargo:rerun-if-changed=updater-public-key.txt");
tauri_build::build()
}
+6
View File
@@ -30,8 +30,10 @@ pub(crate) fn start_microsoft_login(
state: State<'_, LauncherOperations>,
) -> Result<(), String> {
let directory = data_dir(&app)?;
let lifecycle = crate::update_guard::begin_operation(&directory, &state)?;
let permit = state.account.acquire("Account operation")?;
tauri::async_runtime::spawn_blocking(move || {
let _lifecycle = lifecycle;
let _permit = permit;
let login = || -> Result<msa::MinecraftProfile, 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>,
) -> Result<Option<msa::MinecraftProfile>, String> {
let data_dir = data_dir(&app)?;
let lifecycle = crate::update_guard::begin_operation(&data_dir, &state)?;
let permit = state.account.acquire("Account operation")?;
tauri::async_runtime::spawn_blocking(move || {
let _lifecycle = lifecycle;
let _permit = permit;
let Some(refresh_token) = msa::load_refresh_token(&data_dir) else {
return Ok(None);
@@ -102,8 +106,10 @@ pub(crate) async fn logout(
state: State<'_, LauncherOperations>,
) -> Result<(), String> {
let data_dir = data_dir(&app)?;
let lifecycle = crate::update_guard::begin_operation(&data_dir, &state)?;
let permit = state.account.acquire("Account operation")?;
tauri::async_runtime::spawn_blocking(move || {
let _lifecycle = lifecycle;
let _permit = permit;
msa::clear_account(&data_dir)
})
+4
View File
@@ -151,8 +151,10 @@ pub(crate) async fn ensure_game_installed(
profile_id: String,
) -> Result<PreparationResult, String> {
let directory = data_dir(&app)?;
let lifecycle = crate::update_guard::begin_operation(&directory, &state)?;
let permit = state.installation.acquire("Installation")?;
tauri::async_runtime::spawn_blocking(move || {
let _lifecycle = lifecycle;
let _permit = permit;
let _lock = InstallationLock::acquire(&directory)?;
let snapshot = remote::fetch_snapshot(&profile_id).map_err(|e| e.to_string())?;
@@ -181,9 +183,11 @@ async fn play(
onboarding_name: Option<String>,
) -> Result<PreparationResult, String> {
let directory = data_dir(&app)?;
let lifecycle = crate::update_guard::begin_operation(&directory, &state)?;
let permit = state.installation.acquire("Installation")?;
let account_operation = state.shacraft_account.clone();
tauri::async_runtime::spawn_blocking(move || {
let _lifecycle = lifecycle;
let _permit = permit;
let lock = InstallationLock::acquire(&directory)?;
let snapshot = remote::fetch_snapshot(&profile_id).map_err(|e| e.to_string())?;
+1
View File
@@ -5,6 +5,7 @@ pub(crate) mod host;
pub(crate) mod preferences;
pub(crate) mod profiles;
pub(crate) mod shacraft;
pub(crate) mod updater;
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
+11 -6
View File
@@ -1,6 +1,6 @@
use super::data_dir;
use crate::settings;
use tauri::AppHandle;
use crate::{operations::LauncherOperations, settings};
use tauri::{AppHandle, State};
#[tauri::command]
pub(crate) async fn load_settings(app: AppHandle) -> Result<settings::LauncherSettings, String> {
@@ -14,11 +14,16 @@ pub(crate) async fn load_settings(app: AppHandle) -> Result<settings::LauncherSe
#[tauri::command]
pub(crate) async fn save_settings(
app: AppHandle,
state: State<'_, LauncherOperations>,
settings: settings::LauncherSettings,
) -> Result<settings::LauncherSettings, String> {
let data_dir = data_dir(&app)?;
tauri::async_runtime::spawn_blocking(move || settings::save(&data_dir, settings))
.await
.map_err(|error| format!("Settings task failed: {error}"))?
.map_err(|error| error.to_string())
let lifecycle = crate::update_guard::begin_operation(&data_dir, &state)?;
tauri::async_runtime::spawn_blocking(move || {
let _lifecycle = lifecycle;
settings::save(&data_dir, settings)
})
.await
.map_err(|error| format!("Settings task failed: {error}"))?
.map_err(|error| error.to_string())
}
+10
View File
@@ -47,10 +47,13 @@ pub(crate) async fn get_server_status(profile_id: String) -> Result<remote::Serv
#[tauri::command]
pub(crate) async fn inspect_remote_profile(
app: AppHandle,
state: State<'_, LauncherOperations>,
profile_id: String,
) -> Result<profile::ProfileInspection, String> {
let directory = data_dir(&app)?;
let lifecycle = crate::update_guard::begin_operation(&directory, &state)?;
tauri::async_runtime::spawn_blocking(move || {
let _lifecycle = lifecycle;
// Inspection must not report a partially applied journal as ready.
let _lock = InstallationLock::acquire(&directory)?;
let manifest = remote::fetch_manifest(&profile_id).map_err(|e| e.to_string())?;
@@ -68,8 +71,10 @@ pub(crate) async fn sync_remote_profile(
profile_id: String,
) -> Result<profile::SyncResult, String> {
let directory = data_dir(&app)?;
let lifecycle = crate::update_guard::begin_operation(&directory, &state)?;
let permit = state.installation.acquire("Installation")?;
tauri::async_runtime::spawn_blocking(move || {
let _lifecycle = lifecycle;
let _permit = permit;
let _lock = InstallationLock::acquire(&directory)?;
let snapshot = remote::fetch_snapshot(&profile_id).map_err(|e| e.to_string())?;
@@ -86,10 +91,13 @@ pub(crate) async fn sync_remote_profile(
#[tauri::command]
pub(crate) async fn legacy_mods(
app: AppHandle,
state: State<'_, LauncherOperations>,
profile_id: String,
) -> Result<Vec<profile::LegacyMod>, String> {
let directory = data_dir(&app)?;
let lifecycle = crate::update_guard::begin_operation(&directory, &state)?;
tauri::async_runtime::spawn_blocking(move || {
let _lifecycle = lifecycle;
let _lock = InstallationLock::acquire(&directory)?;
let manifest = remote::fetch_manifest(&profile_id).map_err(|e| e.to_string())?;
profile::list_legacy_mods(&directory.join("profiles").join(&manifest.id), &manifest)
@@ -107,8 +115,10 @@ pub(crate) async fn backup_legacy_mods(
selections: Vec<profile::LegacySelection>,
) -> Result<profile::LegacyBackup, String> {
let directory = data_dir(&app)?;
let lifecycle = crate::update_guard::begin_operation(&directory, &state)?;
let permit = state.installation.acquire("Legacy migration")?;
tauri::async_runtime::spawn_blocking(move || {
let _lifecycle = lifecycle;
let _permit = permit;
let _lock = InstallationLock::acquire(&directory)?;
let manifest = remote::fetch_manifest(&profile_id).map_err(|e| e.to_string())?;
+2
View File
@@ -13,10 +13,12 @@ async fn account_task<T: Send + 'static>(
work: impl FnOnce(&Path) -> Result<T, shacraft_account::AccountError> + Send + 'static,
) -> Result<T, String> {
let directory = data_dir(app)?;
let lifecycle = crate::update_guard::begin_operation(&directory, operations)?;
let permit = operations
.shacraft_account
.acquire("ShaCraft account operation")?;
tauri::async_runtime::spawn_blocking(move || {
let _lifecycle = lifecycle;
let _permit = permit;
work(&directory).map_err(|error| error.to_string())
})
+158
View File
@@ -0,0 +1,158 @@
//! No updater command accepts a URL, key, target, version, executable or arguments.
use super::data_dir;
use crate::{
operations::LauncherOperations,
update_guard,
updater::{self, UpdateStatus, UpdaterState},
};
use tauri::{AppHandle, Manager, State};
fn pending(app: &AppHandle, state: &UpdaterState) -> Result<Option<UpdateStatus>, String> {
let operations = app.state::<LauncherOperations>();
if let Err(reason) = operations.ensure_writable() {
return Ok(Some(state.recovery(app, reason)));
}
let directory = data_dir(app)?;
match update_guard::pending_reason(&directory, env!("CARGO_PKG_VERSION")) {
Ok(Some(reason)) | Err(reason) => {
// Recovery discovered after startup is just as permanent for this
// process. External marker deletion cannot resume native writes.
operations.latch_recovery(reason.clone());
Ok(Some(state.recovery(app, reason)))
}
Ok(None) => Ok(None),
}
}
fn contain_worker_panic<T>(
work: impl FnOnce() -> Result<T, String>,
panic_message: &str,
) -> Result<T, String> {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(work))
.unwrap_or_else(|_| Err(panic_message.to_string()))
}
fn report_failure(app: &AppHandle, state: &UpdaterState, error: String) -> UpdateStatus {
match pending(app, state) {
Ok(Some(recovery)) => recovery,
Ok(None) => state.fail(app, error),
Err(reason) => {
app.state::<LauncherOperations>()
.latch_recovery(reason.clone());
state.recovery(app, reason)
}
}
}
#[tauri::command]
pub(crate) fn updater_status(
app: AppHandle,
state: State<'_, UpdaterState>,
) -> Result<UpdateStatus, String> {
if state.critical() {
return Ok(state.status());
}
Ok(pending(&app, &state)?.unwrap_or_else(|| state.status()))
}
#[tauri::command]
pub(crate) async fn updater_check(
app: AppHandle,
state: State<'_, UpdaterState>,
) -> Result<UpdateStatus, String> {
let permit = state.acquire()?;
if let Some(status) = pending(&app, &state)? {
return Ok(status);
}
let state = state.inner().clone();
let worker_app = app.clone();
let worker_state = state.clone();
let worker = tauri::async_runtime::spawn_blocking(move || {
let _permit = permit;
// Publish failure from the worker even if its IPC caller disappeared.
contain_worker_panic(
|| updater::check(&worker_app, &worker_state),
"Проверка обновления прервалась. Повторите проверку.",
)
.unwrap_or_else(|error| report_failure(&worker_app, &worker_state, error))
});
Ok(worker.await.unwrap_or_else(|_| {
report_failure(
&app,
&state,
"Проверка обновления прервалась. Повторите проверку.".into(),
)
}))
}
#[tauri::command]
pub(crate) async fn updater_download_install(
app: AppHandle,
state: State<'_, UpdaterState>,
) -> Result<UpdateStatus, String> {
let permit = state.acquire()?;
if let Some(status) = pending(&app, &state)? {
return Ok(status);
}
let directory = data_dir(&app)?;
let state = state.inner().clone();
let worker_app = app.clone();
let worker_state = state.clone();
let worker = tauri::async_runtime::spawn_blocking(move || {
let _permit = permit;
contain_worker_panic(
|| {
let operations = worker_app.state::<LauncherOperations>();
updater::download_install(&worker_app, &worker_state, &directory, &operations)
},
"Установка обновления прервалась; проверьте состояние перед повторной попыткой.",
)
.unwrap_or_else(|error| report_failure(&worker_app, &worker_state, error))
});
Ok(worker.await.unwrap_or_else(|_| {
report_failure(
&app,
&state,
"Установка обновления прервалась; проверьте состояние перед повторной попыткой.".into(),
)
}))
}
#[tauri::command]
pub(crate) fn updater_restart(
app: AppHandle,
state: State<'_, UpdaterState>,
) -> Result<(), String> {
let _permit = state.acquire()?;
if !state.ready() {
return Err("Нет завершённого обновления для перезапуска".into());
}
app.restart()
}
#[tauri::command]
pub(crate) fn updater_open_release_page() -> Result<(), String> {
updater::open_release_page()
}
#[cfg(test)]
mod tests {
use super::contain_worker_panic;
use crate::operations::Operation;
#[test]
fn panicking_worker_releases_owned_permit_and_returns_a_retryable_boundary_error() {
let operation = Operation::default();
let permit = operation.acquire("test updater").unwrap();
let result: Result<(), String> = contain_worker_panic(
move || {
let _permit = permit;
panic!("synthetic worker failure");
},
"worker interrupted",
);
assert_eq!(result, Err("worker interrupted".into()));
assert!(operation.acquire("retry").is_ok());
assert_eq!(contain_worker_panic(|| Ok(42), "unused"), Ok(42));
}
}
+60 -5
View File
@@ -71,6 +71,16 @@ pub(crate) struct InstallationLock {
lease: PathBuf,
}
impl Drop for InstallationLock {
fn drop(&mut self) {
// A concurrent Unix spawn can retain an inherited copy until exec.
// Release this owner's lock explicitly instead of waiting for every
// duplicate descriptor to close. Never remove the durable game lease.
// If unlock fails, closing the file still leaves the OS fail-closed.
let _ = self._file.unlock();
}
}
impl InstallationLock {
pub fn acquire(data_dir: &Path) -> Result<Self, String> {
ordinary_path(data_dir)?;
@@ -166,7 +176,10 @@ mod tests {
let a = InstallationLock::acquire(&p).unwrap();
assert!(InstallationLock::acquire(&p).is_err());
drop(a);
assert!(InstallationLock::acquire(&p).is_ok());
drop(
InstallationLock::acquire(&p)
.unwrap_or_else(|error| panic!("expected released lock: {error}")),
);
fs::remove_dir_all(p).unwrap();
}
#[test]
@@ -176,9 +189,8 @@ mod tests {
a.starting().unwrap();
a.running(std::process::id()).unwrap();
drop(a);
assert!(InstallationLock::acquire(&p)
.unwrap_err_string()
.contains("Minecraft"));
let error = InstallationLock::acquire(&p).unwrap_err_string();
assert!(error.contains("Minecraft"), "unexpected refusal: {error}");
fs::remove_dir_all(p).unwrap();
}
#[test]
@@ -193,9 +205,52 @@ mod tests {
})
.unwrap();
drop(a);
assert!(InstallationLock::acquire(&p).is_ok());
drop(
InstallationLock::acquire(&p)
.unwrap_or_else(|error| panic!("expected released lock: {error}")),
);
fs::remove_dir_all(p).unwrap();
}
#[test]
fn inherited_file_description_does_not_extend_owner_guard_lifetime() {
let p = dir();
let guard = InstallationLock::acquire(&p).unwrap();
guard
.store(&Lease::Running {
game: ProcessIdentity {
pid: std::process::id(),
started: 1,
},
})
.unwrap();
// A concurrent Unix spawn inherits this same open file description
// until exec closes its CLOEXEC copy. try_clone deterministically keeps
// that description alive without relying on fork timing or sleeps.
let inherited = guard._file.try_clone().unwrap();
assert!(InstallationLock::acquire(&p).is_err());
drop(guard);
let reacquired = InstallationLock::acquire(&p)
.unwrap_or_else(|error| panic!("owner dropped but lock remained: {error}"));
assert!(!p.join("installation-state/game-lease.json").exists());
drop(inherited);
drop(reacquired);
fs::remove_dir_all(p).unwrap();
}
#[test]
fn releasing_owner_lock_keeps_live_game_lease_with_inherited_description() {
let p = dir();
let guard = InstallationLock::acquire(&p).unwrap();
guard.running(std::process::id()).unwrap();
let inherited = guard._file.try_clone().unwrap();
drop(guard);
let error = InstallationLock::acquire(&p).unwrap_err_string();
assert!(error.contains("Minecraft"), "unexpected refusal: {error}");
assert!(p.join("installation-state/game-lease.json").exists());
drop(inherited);
fs::remove_dir_all(p).unwrap();
}
#[test]
fn interrupted_spawn_fails_closed() {
let p = dir();
+38
View File
@@ -1,4 +1,7 @@
mod download;
mod update_guard;
mod updater;
use tauri::Manager;
mod installation_lock;
mod inventory;
mod java;
@@ -22,7 +25,42 @@ mod operations;
pub fn run() {
tauri::Builder::default()
.manage(operations::LauncherOperations::default())
.manage(updater::UpdaterState::default())
.plugin(
tauri_plugin_updater::Builder::new()
.pubkey(updater::configured_key().unwrap_or(""))
.build(),
)
.setup(|app| {
let directory = app.path().app_data_dir()?;
let instance =
update_guard::InstanceGuard::acquire(&directory, env!("CARGO_PKG_VERSION"))
.map_err(std::io::Error::other)?;
if let Some(reason) = instance.recovery_reason() {
app.state::<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![
commands::updater::updater_status,
commands::updater::updater_check,
commands::updater::updater_download_install,
commands::updater::updater_restart,
commands::updater::updater_open_release_page,
commands::host::native_host,
commands::host::detect_java,
commands::host::microsoft_login_available,
+63 -2
View File
@@ -3,12 +3,14 @@
//! Acquire before scheduling the worker and move the permit into it. Dropping
//! the caller's future cannot unlock an operation that is still running.
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
atomic::{AtomicBool, AtomicUsize, Ordering},
Arc, Mutex,
};
#[derive(Default)]
pub(crate) struct LauncherOperations {
pub lifecycle: Lifecycle,
recovery: Mutex<Option<String>>,
pub installation: Operation,
pub account: Operation,
pub shacraft_account: Operation,
@@ -50,3 +52,62 @@ mod tests {
assert!(operation.acquire("Installation").is_ok());
}
}
// Owned, Send permits are held by the worker, not the awaiting IPC future.
// Readers represent all native operations which can persist or launch; the
// sole writer represents launcher replacement, including its download stage.
#[derive(Default)]
pub(crate) struct Lifecycle(Arc<AtomicUsize>);
const EXCLUSIVE: usize = usize::MAX;
impl Lifecycle {
pub fn shared(&self) -> Result<SharedPermit, String> {
self.0
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |n| {
if n < EXCLUSIVE - 1 {
Some(n + 1)
} else {
None
}
})
.map_err(|_| "Лаунчер обновляется. Дождитесь завершения и перезапуска.".to_string())?;
Ok(SharedPermit(self.0.clone()))
}
pub fn exclusive(&self) -> Result<ExclusivePermit, String> {
self.0
.compare_exchange(0, EXCLUSIVE, Ordering::AcqRel, Ordering::Acquire)
.map_err(|_| {
"Завершите операцию с игрой, настройками или аккаунтом и повторите обновление."
.to_string()
})?;
Ok(ExclusivePermit(self.0.clone()))
}
pub fn is_updating(&self) -> bool {
self.0.load(Ordering::Acquire) == EXCLUSIVE
}
}
pub(crate) struct SharedPermit(Arc<AtomicUsize>);
impl Drop for SharedPermit {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::Release);
}
}
pub(crate) struct ExclusivePermit(Arc<AtomicUsize>);
impl Drop for ExclusivePermit {
fn drop(&mut self) {
self.0.store(0, Ordering::Release);
}
}
impl LauncherOperations {
/// Startup recovery is latched for this process. Removing a marker in a
/// running application is never authority to resume writes or launch.
pub fn latch_recovery(&self, reason: String) {
*self.recovery.lock().unwrap() = Some(reason);
}
pub fn ensure_writable(&self) -> Result<(), String> {
match self.recovery.lock().unwrap().as_ref() {
Some(reason) => Err(reason.clone()),
None => Ok(()),
}
}
}
+355
View File
@@ -0,0 +1,355 @@
//! Launcher replacement is a distinct trust and lifecycle boundary from game
//! installation. A process-local gate drains all native writes; the game lock
//! also checks the durable detached-game lease. A handoff record survives the
//! Windows updater's immediate process exit and is never cleared by a timeout.
use crate::{
installation_lock::InstallationLock,
operations::{ExclusivePermit, LauncherOperations, Permit},
};
use serde::{Deserialize, Serialize};
use std::{
fs::{self, File, OpenOptions},
io,
path::{Path, PathBuf},
};
// A renamed durable marker must survive a power loss before installer handoff.
// Unix directory fsync persists the name itself, in addition to write_atomic's
// fsync of the file contents. Windows uses its native file replacement semantics.
fn sync_directory(path: &Path) -> Result<(), String> {
#[cfg(unix)]
{
File::open(path)
.and_then(|file| file.sync_all())
.map_err(|e| e.to_string())?;
}
#[cfg(not(unix))]
let _ = path;
Ok(())
}
const RECOVERY: &str = "Предыдущая установка обновления не завершена или её запись повреждена. Автоматическое продолжение заблокировано. Закройте игру, установщик и лаунчер, затем восстановите приложение официальным пакетом того же типа. Настройки и игровые файлы удалять не нужно.";
fn ordinary(path: &Path) -> Result<(), String> {
match fs::symlink_metadata(path) {
Ok(meta) if meta.file_type().is_symlink() => {
Err("Служебный путь обновления является ссылкой".into())
}
Ok(_) => Ok(()),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error.to_string()),
}
}
fn directory(data: &Path) -> Result<PathBuf, String> {
ordinary(data)?;
fs::create_dir_all(data).map_err(|e| e.to_string())?;
let path = data.join("launcher-state");
ordinary(&path)?;
fs::create_dir_all(&path).map_err(|e| e.to_string())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).map_err(|e| e.to_string())?;
}
sync_directory(data)?;
if let Some(parent) = data.parent() {
sync_directory(parent)?;
}
Ok(path)
}
/// Retained in Tauri state for the entire process lifetime. In particular an
/// idle second launcher cannot keep an old executable loaded during replacement.
pub(crate) struct InstanceGuard {
_file: File,
recovery_reason: Option<String>,
}
impl Drop for InstanceGuard {
fn drop(&mut self) {
// Release the owner lock even if a concurrent spawn retains a temporary
// inherited file description before exec. Never clear the handoff marker.
let _ = self._file.unlock();
}
}
impl InstanceGuard {
pub fn recovery_reason(&self) -> Option<String> {
self.recovery_reason.clone()
}
pub fn acquire(data: &Path, current_version: &str) -> Result<Self, String> {
let dir = directory(data)?;
let path = dir.join("instance.lock");
ordinary(&path)?;
let mut options = OpenOptions::new();
options.read(true).write(true).create(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let file = options.open(&path).map_err(|e| e.to_string())?;
let marker = dir.join("pending-update.json");
let mut acquired = file.try_lock().is_ok();
if !acquired && read_pending(&marker)?.is_some_and(|pending| pending.to == current_version)
{
// Tauri starts the replacement child before its old process exits.
// Only the exact recorded target may wait for that legitimate handoff.
// Never remove a lock or treat elapsed time as successful acquisition.
let until = std::time::Instant::now() + std::time::Duration::from_secs(5);
while !acquired && std::time::Instant::now() < until {
std::thread::sleep(std::time::Duration::from_millis(25));
acquired = file.try_lock().is_ok();
}
}
if !acquired {
return Err(
"ShaCraft Launcher уже запущен. Откройте его окно или завершите другой экземпляр."
.into(),
);
}
let recovery_reason = match read_pending(&marker) {
// A corrupt/unreadable marker becomes explicit recovery state,
// NEVER Ready/None. Setup latches this reason before any IPC runs.
Err(reason) => Some(reason),
Ok(Some(pending)) if pending.to == current_version => fs::remove_file(marker)
.map_err(|error| error.to_string())
.and_then(|()| sync_directory(&dir))
.err()
.map(|error| format!("{RECOVERY} {error}")),
Ok(Some(pending)) => Some(format!("{RECOVERY} Ожидаемая версия: {}.", pending.to)),
Ok(None) => None,
};
Ok(Self {
_file: file,
recovery_reason,
})
}
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct PendingUpdate {
from: String,
to: String,
}
fn read_pending(path: &Path) -> Result<Option<PendingUpdate>, String> {
ordinary(path)?;
match fs::read(path) {
Ok(bytes) if bytes.len() <= 1024 => serde_json::from_slice(&bytes)
.map(Some)
.map_err(|_| RECOVERY.to_string()),
Ok(_) => Err(RECOVERY.into()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e.to_string()),
}
}
pub(crate) fn ensure_no_pending(data: &Path) -> Result<(), String> {
if let Some(pending) = read_pending(&directory(data)?.join("pending-update.json"))? {
return Err(format!("{RECOVERY} Ожидаемая версия: {}.", pending.to));
}
Ok(())
}
pub(crate) fn pending_reason(
data: &Path,
_current_version: &str,
) -> Result<Option<String>, String> {
// Startup is the only place allowed to acknowledge a completed handoff.
// Read-only status must never remove a marker while an installer is active.
match ensure_no_pending(data) {
Ok(()) => Ok(None),
Err(reason) => Ok(Some(reason)),
}
}
pub(crate) fn begin_operation(
data: &Path,
operations: &LauncherOperations,
) -> Result<crate::operations::SharedPermit, String> {
operations.ensure_writable()?;
let permit = operations.lifecycle.shared()?;
ensure_no_pending(data)?;
Ok(permit)
}
pub(crate) struct UpdateGuard<'a> {
operations: &'a LauncherOperations,
directory: PathBuf,
_exclusive: ExclusivePermit,
_operation: Permit,
_installation: InstallationLock,
}
impl<'a> UpdateGuard<'a> {
pub fn acquire(data: &Path, operations: &'a LauncherOperations) -> Result<Self, String> {
operations.ensure_writable()?;
let exclusive = operations.lifecycle.exclusive()?;
let operation = operations.installation.acquire("Обновление лаунчера")?;
ensure_no_pending(data)?;
let installation = InstallationLock::acquire(data)?;
Ok(Self {
operations,
directory: directory(data)?,
_exclusive: exclusive,
_operation: operation,
_installation: installation,
})
}
/// Call only after all package checks and immediately before the platform
/// installer. Drop intentionally preserves this record on uncertain errors.
pub fn begin_install(&self, current_version: &str, target_version: &str) -> Result<(), String> {
let from = semver::Version::parse(current_version).map_err(|e| e.to_string())?;
let to = semver::Version::parse(target_version).map_err(|e| e.to_string())?;
if to <= from || !to.pre.is_empty() || !to.build.is_empty() {
return Err("Установка этой версии обновления запрещена".into());
}
let path = self.directory.join("pending-update.json");
ordinary(&path)?;
crate::storage::write_atomic(
&path,
&serde_json::to_vec(&PendingUpdate {
from: current_version.into(),
to: target_version.into(),
})
.map_err(|e| e.to_string())?,
)
.map_err(|e| e.to_string())?;
self.operations
.latch_recovery(format!("{RECOVERY} Ожидаемая версия: {target_version}."));
sync_directory(&self.directory)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT: AtomicU64 = AtomicU64::new(0);
fn dir() -> PathBuf {
std::env::temp_dir().join(format!(
"shacraft-update-lock-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
))
}
#[test]
fn instance_owner_drop_releases_lock_despite_inherited_description() {
let data = dir();
let owner = InstanceGuard::acquire(&data, "0.2.0").unwrap();
let inherited = owner._file.try_clone().unwrap();
assert!(InstanceGuard::acquire(&data, "0.2.0").is_err());
drop(owner);
let next = InstanceGuard::acquire(&data, "0.2.0")
.unwrap_or_else(|error| panic!("owner instance lock remained: {error}"));
drop(inherited);
drop(next);
fs::remove_dir_all(data).unwrap();
}
#[test]
fn excludes_account_settings_and_game_writes_in_both_directions() {
let dir = dir();
let state = LauncherOperations::default();
let write = begin_operation(&dir, &state).unwrap();
assert!(UpdateGuard::acquire(&dir, &state).is_err());
drop(write);
let update = UpdateGuard::acquire(&dir, &state).unwrap();
assert!(begin_operation(&dir, &state).is_err());
assert!(UpdateGuard::acquire(&dir, &state).is_err());
drop(update);
assert!(begin_operation(&dir, &state).is_ok());
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn checks_game_lease_after_launcher_has_exited() {
let dir = dir();
let state = LauncherOperations::default();
let game = InstallationLock::acquire(&dir).unwrap();
game.running(std::process::id()).unwrap();
drop(game);
assert!(UpdateGuard::acquire(&dir, &state).is_err());
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn failed_download_can_retry_but_installer_handoff_survives_exit() {
let dir = dir();
let state = LauncherOperations::default();
drop(UpdateGuard::acquire(&dir, &state).unwrap());
let update = UpdateGuard::acquire(&dir, &state).unwrap();
assert!(update.begin_install("0.2.0", "0.1.1").is_err());
assert!(update.begin_install("0.2.0", "0.2.0").is_err());
update.begin_install("0.2.0", "0.2.1").unwrap();
drop(update);
assert!(begin_operation(&dir, &state).is_err());
assert!(UpdateGuard::acquire(&dir, &state).is_err());
let old = InstanceGuard::acquire(&dir, "0.2.0").unwrap();
assert!(begin_operation(&dir, &state).is_err());
drop(old);
let different = InstanceGuard::acquire(&dir, "0.3.0").unwrap();
assert!(begin_operation(&dir, &state).is_err());
drop(different);
let updated = InstanceGuard::acquire(&dir, "0.2.1").unwrap();
assert!(begin_operation(&dir, &state).is_err()); // old process stays latched
assert!(begin_operation(&dir, &LauncherOperations::default()).is_ok());
drop(updated);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn target_process_waits_for_real_lock_release_during_restart() {
let dir = dir();
let instance = InstanceGuard::acquire(&dir, "0.2.0").unwrap();
let state = LauncherOperations::default();
let update = UpdateGuard::acquire(&dir, &state).unwrap();
update.begin_install("0.2.0", "0.2.1").unwrap();
drop(update);
// Model Tauri's spawn-before-exit with an OS lock held by the old owner.
let next_dir = dir.clone();
let next = std::thread::spawn(move || InstanceGuard::acquire(&next_dir, "0.2.1"));
std::thread::sleep(std::time::Duration::from_millis(75));
assert!(!next.is_finished());
drop(instance);
let target = next.join().unwrap().unwrap();
assert!(target.recovery_reason().is_none());
assert!(begin_operation(&dir, &state).is_err());
assert!(begin_operation(&dir, &LauncherOperations::default()).is_ok());
drop(target);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn rejects_second_idle_instance_and_malformed_marker() {
let dir = dir();
let instance = InstanceGuard::acquire(&dir, "0.2.0").unwrap();
assert!(InstanceGuard::acquire(&dir, "0.2.0").is_err());
drop(instance);
fs::write(
directory(&dir).unwrap().join("pending-update.json"),
b"invalid",
)
.unwrap();
let recovery = InstanceGuard::acquire(&dir, "0.2.1").unwrap();
let state = LauncherOperations::default();
state.latch_recovery(
recovery
.recovery_reason()
.expect("corruption must be explicit recovery"),
);
// Read-only diagnostics are available, while every shared write and
// updater install remains denied, including after external deletion.
assert!(pending_reason(&dir, "0.2.1").unwrap().is_some());
assert!(begin_operation(&dir, &state).is_err());
assert!(UpdateGuard::acquire(&dir, &state).is_err());
fs::remove_file(directory(&dir).unwrap().join("pending-update.json")).unwrap();
assert!(begin_operation(&dir, &state).is_err());
assert!(UpdateGuard::acquire(&dir, &state).is_err());
drop(recovery);
fs::remove_dir_all(dir).unwrap();
}
}
+493
View File
@@ -0,0 +1,493 @@
//! Native-only launcher updater. The webview controls timing, never trust inputs.
mod format;
mod protocol;
use crate::{
operations::{LauncherOperations, Operation, Permit},
update_guard::UpdateGuard,
};
use protocol::{Artifact, VerifiedRelease};
use reqwest::blocking::Client;
use serde::Serialize;
use std::{
path::Path,
process::{Command, Stdio},
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use tauri::{AppHandle, Emitter};
use tauri_plugin_updater::UpdaterExt;
#[derive(Clone, Serialize, Debug, PartialEq)]
#[serde(rename_all = "snake_case")]
pub(crate) enum Phase {
Idle,
Checking,
Available,
Downloading,
Verifying,
Installing,
Ready,
NoUpdate,
Unconfigured,
Manual,
Error,
}
#[derive(Clone, Serialize, Debug)]
#[serde(rename_all = "camelCase")]
pub(crate) struct UpdateStatus {
revision: u64,
installed_version: String,
package_format: &'static str,
phase: Phase,
available_version: Option<String>,
release_notes: Option<String>,
downloaded_bytes: u64,
total_bytes: Option<u64>,
can_retry: bool,
message: Option<String>,
test_build: bool,
}
struct StateData {
status: UpdateStatus,
candidate: Option<VerifiedRelease>,
}
#[derive(Clone)]
pub(crate) struct UpdaterState {
inner: Arc<Mutex<StateData>>,
operation: Operation,
}
impl Default for UpdaterState {
fn default() -> Self {
let configured = configured_key().is_some();
Self {
inner: Arc::new(Mutex::new(StateData {
status: UpdateStatus {
revision: 0,
installed_version: env!("CARGO_PKG_VERSION").into(),
package_format: format::installed_label(),
phase: if configured { Phase::Idle } else { Phase::Unconfigured },
available_version: None, release_notes: None, downloaded_bytes: 0, total_bytes: None,
can_retry: false,
message: (!configured).then(|| "Подписанные обновления ещё не настроены для этой сборки. Официальные выпуски доступны на GitHub.".into()),
test_build: protocol::test_build(),
},
candidate: None,
})),
operation: Operation::default(),
}
}
}
pub(crate) fn configured_key() -> Option<&'static str> {
protocol::configured_key()
}
impl UpdaterState {
pub fn status(&self) -> UpdateStatus {
self.inner.lock().unwrap().status.clone()
}
pub fn acquire(&self) -> Result<Permit, String> {
self.operation.acquire("Обновление лаунчера")
}
fn change(&self, app: &AppHandle, update: impl FnOnce(&mut StateData)) -> UpdateStatus {
let status = {
let mut data = self.inner.lock().unwrap();
update(&mut data);
data.status.revision += 1;
data.status.clone()
};
let _ = app.emit("launcher-update-status", &status);
status
}
fn phase(&self, app: &AppHandle, phase: Phase, message: Option<String>) -> UpdateStatus {
self.change(app, |data| {
data.status.can_retry = phase == Phase::Error;
data.status.phase = phase;
data.status.message = message;
})
}
pub fn fail(&self, app: &AppHandle, error: String) -> UpdateStatus {
self.change(app, |data| {
data.status.can_retry = data.status.phase != Phase::Installing;
data.status.phase = Phase::Error;
data.status.message = Some(error);
if !data.status.can_retry {
data.candidate = None;
}
})
}
pub fn recovery(&self, app: &AppHandle, reason: String) -> UpdateStatus {
self.change(app, |data| {
data.candidate = None;
data.status.phase = Phase::Error;
data.status.can_retry = false;
data.status.message = Some(reason);
})
}
pub fn may_check(&self) -> bool {
!matches!(
self.status().phase,
Phase::Downloading | Phase::Verifying | Phase::Installing | Phase::Ready
)
}
pub fn ready(&self) -> bool {
self.status().phase == Phase::Ready
}
pub fn critical(&self) -> bool {
matches!(
self.status().phase,
Phase::Downloading | Phase::Verifying | Phase::Installing | Phase::Ready
)
}
}
#[derive(Clone, Debug, PartialEq)]
enum PackageMode {
Automatic { platform: &'static str, msi: bool },
Manual,
}
fn package_mode(
os: &str,
arch: &str,
bundle: Option<tauri::utils::config::BundleType>,
) -> PackageMode {
use tauri::utils::config::BundleType;
match (os, arch, bundle) {
("linux", "x86_64", Some(BundleType::AppImage)) => PackageMode::Automatic {
platform: "linux-x86_64",
msi: false,
},
("windows", "x86_64", Some(BundleType::Nsis)) => PackageMode::Automatic {
platform: "windows-x86_64",
msi: false,
},
("windows", "x86_64", Some(BundleType::Msi)) => PackageMode::Automatic {
platform: "windows-x86_64",
msi: true,
},
("macos", "aarch64", Some(BundleType::App)) => PackageMode::Automatic {
platform: "darwin-aarch64",
msi: false,
},
("macos", "x86_64", Some(BundleType::App)) => PackageMode::Automatic {
platform: "darwin-x86_64",
msi: false,
},
_ => PackageMode::Manual,
}
}
fn current_mode() -> PackageMode {
// Bare binaries, distro packages and dev runs must never be overwritten as an AppImage/.app.
if cfg!(debug_assertions) {
return PackageMode::Manual;
}
#[cfg(target_os = "macos")]
{
let app_bundle = std::env::current_exe()
.ok()
.and_then(|path| path.parent().map(|p| p.ends_with("Contents/MacOS")))
.unwrap_or(false);
if !app_bundle {
return PackageMode::Manual;
}
}
package_mode(
std::env::consts::OS,
std::env::consts::ARCH,
tauri::utils::platform::bundle_type(),
)
}
fn client() -> Result<Client, String> {
Client::builder()
.https_only(true)
.connect_timeout(Duration::from_secs(15))
.timeout(Duration::from_secs(300))
.user_agent("ShaCraft-Launcher-Updater/1")
.redirect(reqwest::redirect::Policy::custom(|attempt| {
if attempt.previous().len() <= 5 && protocol::redirect_allowed(attempt.url()) {
attempt.follow()
} else {
attempt.error("Untrusted update redirect")
}
}))
.build()
.map_err(|_| "Не удалось подготовить соединение для обновлений".into())
}
fn response_bytes(
client: &Client,
url: &str,
limit: u64,
progress: impl FnMut(u64),
) -> Result<Option<Vec<u8>>, String> {
let response = client
.get(url)
.send()
.map_err(|_| "Не удалось связаться с GitHub. Проверьте сеть и повторите проверку.")?;
if response.status() == reqwest::StatusCode::NO_CONTENT {
return Ok(None);
}
if !response.status().is_success() {
return Err(format!(
"GitHub не отдал обновление (HTTP {}). Повторите позже.",
response.status().as_u16()
));
}
if response.content_length().is_some_and(|size| size > limit) {
return Err("Размер ответа превышает подписанный предел".into());
}
protocol::read_bounded(response, limit, progress).map(Some)
}
pub(crate) fn check(app: &AppHandle, state: &UpdaterState) -> Result<UpdateStatus, String> {
if !state.may_check() {
return Err("Сначала завершите текущее обновление лаунчера".into());
}
let Some(key) = configured_key() else {
return Ok(state.phase(
app,
Phase::Unconfigured,
Some("Подписанные обновления ещё не настроены для этой сборки.".into()),
));
};
state.change(app, |data| {
data.candidate = None;
data.status.phase = Phase::Checking;
data.status.can_retry = false;
data.status.message = None;
data.status.downloaded_bytes = 0;
data.status.total_bytes = None;
data.status.available_version = None;
data.status.release_notes = None;
});
let client = client()?;
let Some(verified) = protocol::fetch_release(
|url, limit| response_bytes(&client, url, limit, |_| {}),
key,
env!("CARGO_PKG_VERSION"),
)?
else {
return Ok(state.phase(app, Phase::NoUpdate, None));
};
let manual = current_mode() == PackageMode::Manual;
Ok(state.change(app, |data| {
data.status.available_version = Some(verified.release.version.clone());
data.status.release_notes = Some(verified.release.notes.clone());
data.status.phase = if manual { Phase::Manual } else { Phase::Available };
data.status.message = manual.then(|| "Эта сборка обновляется вручную. Для .deb используйте менеджер пакетов; AppImage и установленные Windows/macOS пакеты поддерживают обновление внутри лаунчера.".into());
data.candidate = Some(verified);
}))
}
fn select_artifact(release: &VerifiedRelease, mode: &PackageMode) -> Result<Artifact, String> {
match mode {
PackageMode::Automatic { platform, msi } => {
let artifact = if *msi {
release.release.manual_packages.get("windows-x86_64-msi")
} else {
release.release.platforms.get(*platform)
};
artifact
.cloned()
.ok_or_else(|| "Нет подписанного пакета для текущей платформы".into())
}
PackageMode::Manual => {
Err("Эту сборку необходимо обновить вручную через официальный выпуск".into())
}
}
}
pub(crate) fn download_install(
app: &AppHandle,
state: &UpdaterState,
directory: &Path,
operations: &LauncherOperations,
) -> Result<UpdateStatus, String> {
let key = configured_key().ok_or("Подписанные обновления не настроены")?;
let release = state
.inner
.lock()
.unwrap()
.candidate
.clone()
.ok_or("Сначала проверьте доступность обновления")?;
if !release.is_newer_than(env!("CARGO_PKG_VERSION"))? {
return Err("Эта версия уже установлена".into());
}
let mode = current_mode();
let artifact = select_artifact(&release, &mode)?;
// Includes cross-process game lease and lifecycle exclusion; held through restart/handoff.
let guard = UpdateGuard::acquire(directory, operations)?;
state.change(app, |data| {
data.status.phase = Phase::Downloading;
data.status.can_retry = false;
data.status.message = None;
data.status.downloaded_bytes = 0;
data.status.total_bytes = Some(artifact.size);
});
let client = client()?;
let mut last = Instant::now();
let bytes = response_bytes(&client, &artifact.url, artifact.size, |count| {
if last.elapsed() >= Duration::from_millis(100) || count == artifact.size {
state.change(app, |data| {
data.status.downloaded_bytes = count;
});
last = Instant::now();
}
})?
.ok_or("Сервер не вернул пакет обновления")?;
state.phase(app, Phase::Verifying, None);
protocol::verify_artifact(&bytes, &artifact, key)?;
format::verify(&mode, &bytes)?;
let platform = match mode {
PackageMode::Automatic { platform, .. } => platform,
PackageMode::Manual => unreachable!(),
};
// The plugin's constructor is private. Its check creates the native installer
// context from a fixed version URL; accept only the signed metadata already read.
let builder = app
.updater_builder()
.pubkey(key)
.target(platform)
.endpoints(vec![release
.pinned_endpoint()
.parse()
.map_err(|_| "Некорректный адрес выпуска")?])
.map_err(|_| "Не удалось настроить установщик обновления")?
.configure_client(|builder| {
builder
.https_only(true)
.connect_timeout(Duration::from_secs(15))
.timeout(Duration::from_secs(30))
.redirect(reqwest_updater::redirect::Policy::custom(|attempt| {
if attempt.previous().len() <= 5 && protocol::redirect_allowed(attempt.url()) {
attempt.follow()
} else {
attempt.error("Untrusted update redirect")
}
}))
});
let mut update = tauri::async_runtime::block_on(
builder
.build()
.map_err(|_| "Не удалось подготовить установщик")?
.check(),
)
.map_err(|_| "Не удалось сверить подписанный выпуск с установщиком. Повторите попытку.")?
.ok_or("Подписанный выпуск больше не доступен установщику")?;
if update.raw_json != release.json
|| update.version != release.release.version
|| update.target != platform
{
return Err(
"Метаданные выпуска изменились. Установка остановлена; проверьте обновления заново."
.into(),
);
}
// MSI installations stay MSI; the signed descriptor is never supplied by JS.
update.download_url = artifact
.url
.parse()
.map_err(|_| "Некорректный адрес пакета")?;
update.signature = artifact.signature.clone();
// Update::install does NOT verify bytes itself. Keep this immediately before it.
protocol::install_verified(&bytes, &artifact, key, |verified_bytes| {
guard.begin_install(env!("CARGO_PKG_VERSION"), &release.release.version)?;
state.phase(
app,
Phase::Installing,
Some("Лаунчер перезапустится после установки. Не выключайте компьютер.".into()),
);
update.install(verified_bytes).map_err(|_| "Установка прервалась. Запись о незавершённом обновлении сохранена; следуйте инструкции восстановления.".to_string())
})?;
// Windows exits inside plugin install after handing off to NSIS/MSI. There is
// no installer PID API; the persistent lifecycle marker guards the new process.
state.phase(
app,
Phase::Ready,
Some("Обновление установлено. Перезапускаем лаунчер.".into()),
);
app.restart()
}
pub(crate) fn open_release_page() -> Result<(), String> {
// Fixed executable/argument structure and URL; no shell or webview-supplied input.
#[cfg(target_os = "linux")]
let mut command = {
let mut command = Command::new("xdg-open");
command.arg(protocol::RELEASES);
command
};
#[cfg(target_os = "macos")]
let mut command = {
let mut command = Command::new("open");
command.arg(protocol::RELEASES);
command
};
#[cfg(target_os = "windows")]
let mut command = {
let mut command = Command::new("rundll32.exe");
command.args(["url.dll,FileProtocolHandler", protocol::RELEASES]);
command
};
command
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map(|_| ())
.map_err(|_| "Не удалось открыть страницу официальных выпусков".into())
}
#[cfg(test)]
mod tests {
use super::*;
use tauri::utils::config::BundleType;
#[test]
fn package_formats_never_cross_installers_or_architectures() {
assert_eq!(
package_mode("linux", "x86_64", Some(BundleType::Deb)),
PackageMode::Manual
);
assert_eq!(package_mode("linux", "x86_64", None), PackageMode::Manual);
assert_eq!(
package_mode("windows", "aarch64", Some(BundleType::Nsis)),
PackageMode::Manual
);
assert_eq!(
package_mode("windows", "x86_64", Some(BundleType::Msi)),
PackageMode::Automatic {
platform: "windows-x86_64",
msi: true
}
);
assert_eq!(
package_mode("macos", "aarch64", Some(BundleType::App)),
PackageMode::Automatic {
platform: "darwin-aarch64",
msi: false
}
);
assert_eq!(
package_mode("macos", "x86_64", Some(BundleType::App)),
PackageMode::Automatic {
platform: "darwin-x86_64",
msi: false
}
);
}
#[test]
fn updater_single_flight_releases_after_failure() {
let state = UpdaterState::default();
let permit = state.acquire().unwrap();
assert!(state.acquire().is_err());
drop(permit);
assert!(state.acquire().is_ok());
}
}
+94
View File
@@ -0,0 +1,94 @@
//! Do not let the plugin's byte sniffing select a different Windows installer
//! than the installed package. Metadata, signature and these checks all agree.
use super::PackageMode;
pub(super) fn verify(mode: &PackageMode, bytes: &[u8]) -> Result<(), String> {
let valid = match mode {
PackageMode::Automatic {
platform: "windows-x86_64",
msi: true,
} => bytes.starts_with(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"),
PackageMode::Automatic {
platform: "windows-x86_64",
msi: false,
} => {
let pe = bytes
.get(0x3c..0x40)
.map(|raw| u32::from_le_bytes(raw.try_into().unwrap()) as usize);
bytes.starts_with(b"MZ")
&& pe.and_then(|at| at.checked_add(4).and_then(|end| bytes.get(at..end)))
== Some(b"PE\0\0".as_slice())
}
PackageMode::Automatic {
platform: "linux-x86_64",
msi: false,
} => {
bytes.starts_with(b"\x7fELF\x02\x01") // ELF64, little-endian
&& bytes.get(8..11) == Some(b"AI\x02".as_slice()) // AppImage Type2 magic
&& bytes.get(18..20) == Some(b"\x3e\x00".as_slice())
} // EM_X86_64
PackageMode::Automatic {
platform: "darwin-aarch64" | "darwin-x86_64",
msi: false,
} => bytes.starts_with(b"\x1f\x8b\x08"), // gzip archive; actual .app architecture is signed in metadata
_ => false,
};
if valid {
Ok(())
} else {
Err("Формат подписанного пакета не соответствует установленному лаунчеру. Автоматическая смена типа установки запрещена.".into())
}
}
pub(super) fn installed_label() -> &'static str {
use tauri::utils::{config::BundleType, platform::bundle_type};
if cfg!(debug_assertions) {
return "development";
}
match bundle_type() {
Some(BundleType::AppImage) => "AppImage",
Some(BundleType::Deb) => "deb",
Some(BundleType::Rpm) => "rpm",
Some(BundleType::Msi) => "MSI",
Some(BundleType::Nsis) => "NSIS",
Some(BundleType::App | BundleType::Dmg) => "app",
None => "unpackaged",
}
}
#[cfg(test)]
mod tests {
use super::*;
fn mode(platform: &'static str, msi: bool) -> PackageMode {
PackageMode::Automatic { platform, msi }
}
#[test]
fn windows_installers_cannot_silently_switch_formats() {
let msi = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1";
let mut exe = vec![0; 96];
exe[..2].copy_from_slice(b"MZ");
exe[0x3c] = 64;
exe[64..68].copy_from_slice(b"PE\0\0");
assert!(verify(&mode("windows-x86_64", true), msi).is_ok());
assert!(verify(&mode("windows-x86_64", false), &exe).is_ok());
assert!(verify(&mode("windows-x86_64", true), &exe).is_err());
assert!(verify(&mode("windows-x86_64", false), msi).is_err());
exe[0x3c..0x40].fill(255); // malformed offset cannot panic/wrap
assert!(verify(&mode("windows-x86_64", false), &exe).is_err());
}
#[test]
fn linux_requires_the_expected_appimage_arch_and_mac_requires_archive() {
let mut image = vec![0; 32];
image[..6].copy_from_slice(b"\x7fELF\x02\x01");
image[8..11].copy_from_slice(b"AI\x02");
image[18..20].copy_from_slice(b"\x3e\x00");
assert!(verify(&mode("linux-x86_64", false), &image).is_ok());
image[18] = 183; // ARM64 ELF is never an x86_64 update.
assert!(verify(&mode("linux-x86_64", false), &image).is_err());
for platform in ["darwin-aarch64", "darwin-x86_64"] {
assert!(verify(&mode(platform, false), b"\x1f\x8b\x08").is_ok());
assert!(verify(&mode(platform, false), b"MSI").is_err());
}
assert!(verify(&PackageMode::Manual, b"\x1f\x8b\x08").is_err());
}
}
+294
View File
@@ -0,0 +1,294 @@
//! Signed launcher releases are a separate trust domain from the ShaCraft pack.
//! Authenticate the exact metadata bytes before parsing any URLs or versions.
use base64::{engine::general_purpose::STANDARD, Engine};
use minisign_verify::{PublicKey, Signature};
use semver::Version;
use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::{collections::BTreeMap, io::Read};
use url::Url;
pub(crate) const RELEASES: &str = "https://github.com/emil28092005/shacraft-launcher/releases";
pub(crate) const LATEST: &str =
"https://github.com/emil28092005/shacraft-launcher/releases/latest/download/latest.json";
pub(crate) const MAX_METADATA: u64 = 32768;
pub(crate) const MAX_SIGNATURE: u64 = 2048;
pub(crate) const MAX_PACKAGE: u64 = 1024 * 1024 * 1024;
const PINNED_KEY: &str = include_str!("../../updater-public-key.txt");
pub(crate) fn test_build() -> bool {
option_env!("SHACRAFT_UPDATER_TEST_BUILD") == Some("1")
}
pub(crate) fn configured_key() -> Option<&'static str> {
let injected = option_env!("SHACRAFT_UPDATER_PUBLIC_KEY").map(str::trim);
let pinned = PINNED_KEY.trim();
// Explicit CI-only builds may use a disposable real signing key. Release CI
// forbids this switch; every such binary exposes its test provenance in status.
if test_build() {
return injected.filter(|key| key_is_valid(key));
}
if key_is_valid(pinned) && injected.is_none_or(|key| key == pinned) {
Some(pinned)
} else {
None
}
}
const PLATFORMS: [(&str, &str); 4] = [
("windows-x86_64", "windows-x86_64-setup.exe"),
("linux-x86_64", "linux-x86_64.AppImage"),
("darwin-aarch64", "darwin-aarch64.app.tar.gz"),
("darwin-x86_64", "darwin-x86_64.app.tar.gz"),
];
const MANUAL: [(&str, &str); 4] = [
("windows-x86_64-msi", "windows-x86_64.msi"),
("linux-x86_64-deb", "linux-x86_64.deb"),
("darwin-aarch64-dmg", "darwin-aarch64.dmg"),
("darwin-x86_64-dmg", "darwin-x86_64.dmg"),
];
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Artifact {
pub url: String,
pub signature: String,
pub sha256: String,
pub size: u64,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct Release {
pub schema_version: u32,
pub version: String,
pub tag: String,
pub notes: String,
#[serde(rename = "pub_date")]
pub pub_date: String,
pub platforms: BTreeMap<String, Artifact>,
pub manual_packages: BTreeMap<String, Artifact>,
}
#[derive(Clone)]
pub(crate) struct VerifiedRelease {
pub release: Release,
pub json: serde_json::Value,
}
fn decode_text(encoded: &str) -> Result<String, String> {
let bytes = STANDARD
.decode(encoded.trim())
.map_err(|_| "Некорректный формат подписи обновления")?;
String::from_utf8(bytes).map_err(|_| "Некорректный формат подписи обновления".into())
}
pub(crate) fn key_is_valid(key: &str) -> bool {
key.len() <= MAX_SIGNATURE as usize
&& decode_text(key)
.ok()
.and_then(|text| PublicKey::decode(&text).ok())
.is_some()
}
pub(crate) fn verify_signature(bytes: &[u8], signature: &str, key: &str) -> Result<(), String> {
if signature.len() > MAX_SIGNATURE as usize || key.len() > MAX_SIGNATURE as usize {
return Err("Некорректный размер подписи обновления".into());
}
let public = PublicKey::decode(&decode_text(key)?)
.map_err(|_| "Не настроен доверенный ключ обновлений")?;
let signature = Signature::decode(&decode_text(signature)?)
.map_err(|_| "Некорректный формат подписи обновления")?;
// Same minisign primitive/legacy compatibility as tauri-plugin-updater 2.11.0.
public
.verify(bytes, &signature, true)
.map_err(|_| "Подпись обновления не прошла проверку".into())
}
fn stable_version(value: &str) -> Result<Version, String> {
let v = Version::parse(value).map_err(|_| "Некорректная версия обновления")?;
if !v.pre.is_empty()
|| !v.build.is_empty()
|| v.to_string() != value
|| v.major > 255
|| v.minor > 255
|| v.patch > 65535
{
return Err("Разрешены только стабильные версии обновлений".into());
}
Ok(v)
}
impl VerifiedRelease {
pub fn parse(bytes: &[u8], signature: &str, key: &str) -> Result<Self, String> {
if bytes.len() as u64 > MAX_METADATA {
return Err("Слишком большой список обновлений".into());
}
verify_signature(bytes, signature, key)?;
let release: Release = serde_json::from_slice(bytes)
.map_err(|_| "Некорректные подписанные метаданные обновления")?;
stable_version(&release.version)?;
if release.schema_version != 1
|| release.tag != format!("v{}", release.version)
|| release.notes.len() > 4096
|| release.pub_date.len() != 20
|| !release.pub_date.ends_with('Z')
{
return Err("Неподдерживаемые метаданные обновления".into());
}
let date = time::OffsetDateTime::parse(
&release.pub_date,
&time::format_description::well_known::Rfc3339,
)
.map_err(|_| "Некорректная дата подписанного выпуска")?;
if date
.format(&time::format_description::well_known::Rfc3339)
.map_err(|_| "Некорректная дата выпуска")?
!= release.pub_date
{
return Err("Некорректная дата подписанного выпуска".into());
}
validate_artifacts(&release.platforms, &PLATFORMS, &release)?;
validate_artifacts(&release.manual_packages, &MANUAL, &release)?;
Ok(Self {
release,
json: serde_json::from_slice(bytes).map_err(|_| "Некорректные метаданные")?,
})
}
pub fn is_newer_than(&self, current: &str) -> Result<bool, String> {
let available = stable_version(&self.release.version)?;
let installed = stable_version(current)?;
if available < installed {
return Err(
"Сервер предложил более старую версию. Понижение версии заблокировано.".into(),
);
}
Ok(available > installed)
}
pub fn pinned_endpoint(&self) -> String {
format!("{RELEASES}/download/{}/latest.json", self.release.tag)
}
}
fn validate_artifacts(
values: &BTreeMap<String, Artifact>,
expected: &[(&str, &str)],
release: &Release,
) -> Result<(), String> {
if values.len() != expected.len() {
return Err("Неполный список платформ обновления".into());
}
for (platform, suffix) in expected {
let artifact = values
.get(*platform)
.ok_or("Отсутствует ожидаемая платформа обновления")?;
let expected_url = format!(
"{RELEASES}/download/{}/shacraft-launcher_{}_{}",
release.tag, release.version, suffix
);
if artifact.url != expected_url
|| artifact.size == 0
|| artifact.size > MAX_PACKAGE
|| artifact.sha256.len() != 64
|| !artifact
.sha256
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|| artifact.signature.is_empty()
|| artifact.signature.len() > MAX_SIGNATURE as usize
{
return Err("Неверная привязка пакета к версии, платформе или репозиторию".into());
}
let text = decode_text(&artifact.signature)?;
Signature::decode(&text).map_err(|_| "Некорректная подпись пакета обновления")?;
}
Ok(())
}
/// Only these GitHub release hosts may participate in HTTPS redirects. CDN query
/// parameters are GitHub's signed delivery URLs; initial URLs are exact literals.
pub(crate) fn redirect_allowed(url: &Url) -> bool {
if url.scheme() != "https"
|| !url.username().is_empty()
|| url.password().is_some()
|| url.port_or_known_default() != Some(443)
|| url.fragment().is_some()
{
return false;
}
match url.host_str() {
Some("github.com") => {
url.query().is_none()
&& url
.path()
.starts_with("/emil28092005/shacraft-launcher/releases/")
}
Some("release-assets.githubusercontent.com") => true,
_ => false,
}
}
pub(crate) fn read_bounded(
mut reader: impl Read,
limit: u64,
mut progress: impl FnMut(u64),
) -> Result<Vec<u8>, String> {
let mut result = Vec::new();
let mut buffer = [0_u8; 64 * 1024];
loop {
let count = reader
.read(&mut buffer)
.map_err(|_| "Соединение прервалось. Повторите загрузку обновления.")?;
if count == 0 {
break;
}
if result.len() as u64 + count as u64 > limit {
return Err("Размер ответа превышает подписанный предел".into());
}
result.extend_from_slice(&buffer[..count]);
progress(result.len() as u64);
}
Ok(result)
}
pub(crate) fn verify_artifact(bytes: &[u8], artifact: &Artifact, key: &str) -> Result<(), String> {
if bytes.len() as u64 != artifact.size
|| format!("{:x}", Sha256::digest(bytes)) != artifact.sha256
{
return Err(
"Размер или SHA-256 пакета обновления не совпал. Установка остановлена.".into(),
);
}
verify_signature(bytes, &artifact.signature, key)
}
pub(crate) fn install_verified<T>(
bytes: &[u8],
artifact: &Artifact,
key: &str,
installer: impl FnOnce(&[u8]) -> Result<T, String>,
) -> Result<T, String> {
verify_artifact(bytes, artifact, key)?;
installer(bytes)
}
/// A narrow injectable read boundary; production uses only the fixed HTTPS client.
pub(crate) fn fetch_release(
mut fetch: impl FnMut(&str, u64) -> Result<Option<Vec<u8>>, String>,
key: &str,
current: &str,
) -> Result<Option<VerifiedRelease>, String> {
let Some(bytes) = fetch(LATEST, MAX_METADATA)? else {
return Ok(None);
};
let signature = fetch(&format!("{LATEST}.sig"), MAX_SIGNATURE)?
.ok_or("Отсутствует подпись списка обновлений")?;
let signature =
std::str::from_utf8(&signature).map_err(|_| "Некорректная подпись списка обновлений")?;
let verified = VerifiedRelease::parse(&bytes, signature, key)?;
Ok(verified.is_newer_than(current)?.then_some(verified))
}
#[cfg(test)]
mod tests;
+204
View File
@@ -0,0 +1,204 @@
use super::*;
use std::{
cell::Cell,
collections::VecDeque,
io::{self, Cursor},
};
const KEY: &str = include_str!("../../../tests/fixtures/updater/public-key.txt");
const METADATA: &[u8] = include_bytes!("../../../tests/fixtures/updater/latest.json");
const SIG: &str = include_str!("../../../tests/fixtures/updater/latest.json.sig");
const PACKAGE: &[u8] = include_bytes!("../../../tests/fixtures/updater/package.txt");
fn release() -> VerifiedRelease {
VerifiedRelease::parse(METADATA, SIG, KEY).unwrap()
}
#[test]
fn real_tauri_signatures_authenticate_metadata_and_fake_installer_input() {
assert!(key_is_valid(KEY));
assert!(!key_is_valid("unconfigured"));
let release = release();
let artifact = &release.release.platforms["linux-x86_64"];
let calls = Cell::new(0);
install_verified(PACKAGE, artifact, KEY, |bytes| {
calls.set(calls.get() + 1);
assert_eq!(bytes, PACKAGE);
Ok(())
})
.unwrap();
assert_eq!(calls.get(), 1);
// Real signed test data only. This fake installer never executes the fixture.
}
#[test]
fn tampered_metadata_version_platform_or_package_cannot_reach_installer() {
for (from, to) in [
("0.3.0", "0.9.0"),
("darwin-aarch64", "darwin-mips64"),
("shacraft-launcher/releases", "other-repository/releases"),
] {
let changed = String::from_utf8(METADATA.to_vec())
.unwrap()
.replace(from, to);
assert!(VerifiedRelease::parse(changed.as_bytes(), SIG, KEY).is_err());
}
let release = release();
let artifact = &release.release.platforms["linux-x86_64"];
let calls = Cell::new(0);
let mut corrupt = PACKAGE.to_vec();
corrupt[0] ^= 1;
assert!(install_verified(&corrupt, artifact, KEY, |_| {
calls.set(1);
Ok(())
})
.is_err());
let mut wrong_signature = artifact.clone();
wrong_signature.signature = release.release.platforms["windows-x86_64"]
.signature
.clone();
assert!(install_verified(PACKAGE, &wrong_signature, KEY, |_| {
calls.set(1);
Ok(())
})
.is_err());
assert_eq!(calls.get(), 0);
}
#[test]
fn same_version_is_no_update_and_downgrades_or_prereleases_are_rejected() {
let release = release();
assert!(release.is_newer_than("0.2.0").unwrap());
assert!(!release.is_newer_than("0.3.0").unwrap());
assert!(release.is_newer_than("0.4.0").is_err());
for bad in [
"v0.3.0",
"0.3.0-rc.1",
"0.3.0+other",
"01.2.3",
"256.0.0",
"0.256.0",
"0.0.65536",
] {
assert!(stable_version(bad).is_err(), "{bad}");
}
}
#[test]
fn all_platform_descriptors_bind_repository_tag_filename_size_and_hash() {
let release = release();
let mut missing = release.release.platforms.clone();
missing.remove("darwin-aarch64");
assert!(validate_artifacts(&missing, &PLATFORMS, &release.release).is_err());
let mut wrong_arch = release.release.platforms.clone();
wrong_arch.insert(
"darwin-aarch64".into(),
release.release.platforms["darwin-x86_64"].clone(),
);
assert!(validate_artifacts(&wrong_arch, &PLATFORMS, &release.release).is_err());
for url in [
"https://github.com/other/repo/releases/download/v0.3.0/file",
"https://github.com/emil28092005/shacraft-launcher/releases/download/v0.2.0/file",
"https://evil.invalid/update",
] {
let mut wrong = release.release.platforms.clone();
wrong.get_mut("linux-x86_64").unwrap().url = url.into();
assert!(validate_artifacts(&wrong, &PLATFORMS, &release.release).is_err());
}
for size in [0, MAX_PACKAGE + 1] {
let mut wrong = release.release.platforms.clone();
wrong.get_mut("linux-x86_64").unwrap().size = size;
assert!(validate_artifacts(&wrong, &PLATFORMS, &release.release).is_err());
}
let mut wrong = release.release.platforms.clone();
wrong.get_mut("linux-x86_64").unwrap().sha256 = "A".repeat(64);
assert!(validate_artifacts(&wrong, &PLATFORMS, &release.release).is_err());
}
#[test]
fn github_redirects_cannot_leave_release_hosts_or_downgrade_https() {
for url in [
LATEST,
"https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/latest.json",
"https://release-assets.githubusercontent.com/github-production-release-asset/123?sig=test",
] {
assert!(redirect_allowed(&Url::parse(url).unwrap()), "{url}");
}
for url in [
"http://github.com/emil28092005/shacraft-launcher/releases",
"https://github.com/other/repository/releases/latest",
"https://release-assets.githubusercontent.com.evil.invalid/file",
"https://evil.invalid/file",
"https://user@github.com/emil28092005/shacraft-launcher/releases/a",
"https://github.com:8443/emil28092005/shacraft-launcher/releases/a",
"https://github.com/emil28092005/shacraft-launcher/releases/a#extra",
] {
assert!(!redirect_allowed(&Url::parse(url).unwrap()), "{url}");
}
}
#[test]
fn bounded_reads_reject_oversized_or_interrupted_downloads() {
assert!(read_bounded(Cursor::new(b"12345"), 4, |_| {}).is_err());
assert_eq!(
read_bounded(Cursor::new(b"1234"), 4, |_| {}).unwrap(),
b"1234"
);
struct Broken;
impl Read for Broken {
fn read(&mut self, _: &mut [u8]) -> io::Result<usize> {
Err(io::Error::other("test disconnect"))
}
}
assert!(read_bounded(Broken, 10, |_| {}).is_err());
}
#[test]
fn network_failure_and_bad_signature_retry_fetch_whole_signed_release() {
let mut replies = VecDeque::from([
Err("offline".into()),
Ok(Some(METADATA.to_vec())),
Ok(Some(b"bad-signature".to_vec())),
Ok(Some(METADATA.to_vec())),
Ok(Some(SIG.as_bytes().to_vec())),
]);
let mut urls = Vec::new();
let mut fetch = |url: &str, _| {
urls.push(url.to_string());
replies.pop_front().unwrap()
};
assert!(fetch_release(&mut fetch, KEY, "0.2.0").is_err());
assert!(fetch_release(&mut fetch, KEY, "0.2.0").is_err());
assert_eq!(
fetch_release(&mut fetch, KEY, "0.2.0")
.unwrap()
.unwrap()
.release
.version,
"0.3.0"
);
assert_eq!(
urls,
[
LATEST,
LATEST,
&format!("{LATEST}.sig"),
LATEST,
&format!("{LATEST}.sig")
]
);
assert!(fetch_release(|_, _| Ok(None), KEY, "0.2.0")
.unwrap()
.is_none());
}
#[test]
fn installer_failure_is_reported_and_a_fresh_attempt_reverifies_input() {
let release = release();
let artifact = &release.release.platforms["linux-x86_64"];
assert!(install_verified(PACKAGE, artifact, KEY, |_| Err::<(), _>(
"simulated installer failure".into()
))
.is_err());
assert!(install_verified(PACKAGE, artifact, KEY, |_| Ok(())).is_ok());
}
+18 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ShaCraft Launcher",
"version": "0.1.1",
"version": "0.2.0",
"identifier": "ru.shacraft.launcher",
"build": {
"beforeDevCommand": "npm run dev",
@@ -35,6 +35,22 @@
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
],
"windows": {
"wix": {
"upgradeCode": "2058b1df-56a1-51ef-bd48-d296479cd59a"
},
"nsis": {
"installMode": "currentUser"
}
}
},
"plugins": {
"updater": {
"pubkey": "",
"windows": {
"installMode": "passive"
}
}
}
}
+59
View File
@@ -0,0 +1,59 @@
{
"manualPackages": {
"darwin-aarch64-dmg": {
"sha256": "409ed0537cccf2724f405fd0031f6e84718442b263b75513532e03ecc991a678",
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheUlYSjNaNG55a084WTlCTFlhQTdOdlNNZ3JYYjNaeXZLOThXWERjN3kwWkRtcnNCN0NMVDJBOEpUdVVlS2JJYkpZc3dkdWtyOXQvQ2k3dGJ1bldyRXc4PQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzIwCWZpbGU6c2hhY3JhZnQtbGF1bmNoZXJfMC4zLjBfZGFyd2luLWFhcmNoNjQuZG1nCnRnRlh2dnFUdnJ1MG4vTTF6SExvNzk3ZVpvUkZ5Nk0vdjRiSFpiaHFZUkw5a3N4TkkyODV3RDY0MmJyTXpNdFUwL1NBSlluTG5WeVlMdE1xa3pkWUNnPT0K",
"size": 97,
"url": "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/shacraft-launcher_0.3.0_darwin-aarch64.dmg"
},
"darwin-x86_64-dmg": {
"sha256": "70496d49bf410231a2575c384a306023ab5eeaf312d4dd93b6e8aed3e2ee9492",
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheUJ2biswTzY5QmNNU2hmRHVRV2xpUEtBay94SW1NdHArVnJDQmhLZ3N4UEVQSkxJbG92dmF1bSt4RmJkSm0wOExzNlRDUHJJaVpEUWZvVGoxd0NkNFFrPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzIwCWZpbGU6c2hhY3JhZnQtbGF1bmNoZXJfMC4zLjBfZGFyd2luLXg4Nl82NC5kbWcKVEFaRkxCUFlSRUQ1T0NOa0doclVucnVLNWw0STcxV05ybkxvcWNrazlPdGVEdU5FTlVlMTFSNFlTUEV1RUNvREZqTFZ0UlN3MTJRcGttQWpxblZUQUE9PQo=",
"size": 96,
"url": "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/shacraft-launcher_0.3.0_darwin-x86_64.dmg"
},
"linux-x86_64-deb": {
"sha256": "cefbadc09d008ff4b0f26ee788f524644b7dadf9de4549bd8d841e44ad92e292",
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheU9BMWNrS1hCWHdJWlNFczFJNklxMDJsdHMwZ1NFakpxTDExS0xFUXVwWVFRTVh3Q2Vpc0MvUEI4NXZmTjJjMnZaRFoydVk1SHdMSkkwNFRqMkZIUUFFPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzE5CWZpbGU6c2hhY3JhZnQtbGF1bmNoZXJfMC4zLjBfbGludXgteDg2XzY0LmRlYgpjMVZYeVJuUEZWWithQ2lFVHdkWXBqMDA4NGlKT3FYL3djclhyK1hTSE1RVzVrN0p6TVV1V3plOFlKZHQ5K1pzWDMzcmtPYWFPaCtZeEE4WWt6cW5EZz09Cg==",
"size": 95,
"url": "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/shacraft-launcher_0.3.0_linux-x86_64.deb"
},
"windows-x86_64-msi": {
"sha256": "fac4bdeb3c95d4c0a91b5f03215a0dd8f3e676b7e7f9140eef1616d2fefa5c4b",
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheUdYODluWEtUd2RGNUVyRzNTQWpNV1VoOXpFaXVCZ05SbUpwQTQzYnU2WmI0bzIxVTBTdXFNN2p0Z3NYRndhNmlqTGFzRkZiYllhb0pzbFN4UkMrUUFrPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzE5CWZpbGU6c2hhY3JhZnQtbGF1bmNoZXJfMC4zLjBfd2luZG93cy14ODZfNjQubXNpClZxRlZTL2NobmcreDBLVnc5QzdyZHRkS01NVVZOY2ZpcDNBTWcvUGt6dm15bUNTUzEra09RRDk1Z1VWbmwyU1NjblFXQ0NCcXhTUlpnUEpocWpqQkRRPT0K",
"size": 97,
"url": "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/shacraft-launcher_0.3.0_windows-x86_64.msi"
}
},
"notes": "Synthetic test metadata.",
"platforms": {
"darwin-aarch64": {
"sha256": "73f3eea52be58fb872d2f6423f5cc604b1599403a556a2208173f8b3ea1e66cb",
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheVBPN2xlbzEyQ3h2SkRCdzNlUnFxVEhzQjNYRGNNT1B2enc5TUxKQ1U5TDdwaGhkOUxqNTRnZ3FheDN6UE82SWpMR0YvQUZQR255bWRZcTdYYy9qMWdjPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzE5CWZpbGU6c2hhY3JhZnQtbGF1bmNoZXJfMC4zLjBfZGFyd2luLWFhcmNoNjQuYXBwLnRhci5negp5ckVPVnhvSE1yNitYZm9SRi9KRVNjTWhMVUY3dnNDYmViSHNSWmE2TlFpOVJqd2RxZVJuY0NqVHd6UU5PYUt2NXVQN1JKSHlhZVdiY3JvTjdLcUZCQT09Cg==",
"size": 104,
"url": "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/shacraft-launcher_0.3.0_darwin-aarch64.app.tar.gz"
},
"darwin-x86_64": {
"sha256": "548d2042689d797dbcb29f09d206e1c36814880a831a26945bf0c550f2617a64",
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheVB5MVRuUU52OEdLZDJHYWhmMUtWaDNQMnM0U0xCcC9sdkE0TWhSK0svNzRuTVBtVW5NUTh6MEQ5amVKd0Vpa1l4cWZ2YzNtWW41Y0RQV0VrTitUbVF3PQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzE5CWZpbGU6c2hhY3JhZnQtbGF1bmNoZXJfMC4zLjBfZGFyd2luLXg4Nl82NC5hcHAudGFyLmd6ClFuY3IwOXZKaWsrUTIwMGZmTFdFZmh2K3ZEcDAzYXBpSi8wVnJNS05Zb25YdVgyd1IzLzZud1JqaFNHdXZwa0FpclEyQnplZTFjR1RqNUpzQm9qdUJnPT0K",
"size": 103,
"url": "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/shacraft-launcher_0.3.0_darwin-x86_64.app.tar.gz"
},
"linux-x86_64": {
"sha256": "150e75098117b452b56bfd3925e2c6af03129b47e17be4529f4498b17a9ce9ae",
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheUh1Znd0d2k0c3VGd085c3N2RWVmZnliem04Tzc4MjBaOE1Sd1NWdU9pNU9zZjlMZWNoRnBxaDN6eGlER0hxTDVyRUtVdXdjOUhWK29oM1hSSC84Qmc4PQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzIwCWZpbGU6c2hhY3JhZnQtbGF1bmNoZXJfMC4zLjBfbGludXgteDg2XzY0LkFwcEltYWdlCmdFZTNDdmt0ckRySnB3NWtBeUdYV3drSVZidmNKZEpsOGptcERGK0VjQmpUUDU0c1ZWV1diTWVTZTlhRzVnUVJFRlVITVNhVGxrbS8rMkdTREtUQUNBPT0K",
"size": 100,
"url": "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/shacraft-launcher_0.3.0_linux-x86_64.AppImage"
},
"windows-x86_64": {
"sha256": "a9773409a6545e98af6a9aad2de2b7d89be257c33af6566bd7ffefc1f443d1be",
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheUduVjJYSS83QzVIMXRBK0ZabHk1UGxZMUxkNVkzYXE4VXgranlqRnluSlhOdlBBWU5qaTB5MmQySTZ6M3pZMmZHNHI4dkZpRzk2Q2NXWS9GbXJ2N3dVPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzIwCWZpbGU6c2hhY3JhZnQtbGF1bmNoZXJfMC4zLjBfd2luZG93cy14ODZfNjQtc2V0dXAuZXhlCmlneXpmSGc0WlM3STgxTHUzbldxdVpqbjhraUp6K1c3djhQSWdmOHh0WTFwMnB2cmIwMHdjMVVOajJKVWR0MTVwTTBYd0NvYUk1OVVuemRUakh3SkJBPT0K",
"size": 103,
"url": "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/shacraft-launcher_0.3.0_windows-x86_64-setup.exe"
}
},
"pub_date": "2026-09-09T00:00:00Z",
"schemaVersion": 1,
"tag": "v0.3.0",
"version": "0.3.0"
}
+1
View File
@@ -0,0 +1 @@
dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheUVnVElINVRoVDVpb2VGV21XT0wzYXN1WmxxTEhGZHVvYzg3S1FVcUFPVnVqa3FpRXFWbFl2T2FHN2ZmOU1FRVpQR1krREhCcU00RVpXMXYzd2s5UndnPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzIxCWZpbGU6bGF0ZXN0Lmpzb24KVkxhbGNUZE9NSUpTWlR3L1hDczFTTkFzbjkrK1ZyWE9EeG5xTUVBUnRYcW1kZHphNXVTTEs4OTU0WitORWdQdk9PaXNhVWVvbTFJRHFBeWRtQmdzQXc9PQo=
+2
View File
@@ -0,0 +1,2 @@
ShaCraft synthetic fixture; never execute or install.
shacraft-launcher_0.3.0_linux-x86_64.AppImage
+1
View File
@@ -0,0 +1 @@
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEM4NUEyNTNEQjlERDZDMkIKUldRcmJOMjVQU1ZheUQ2UzVTN0NHS21ydFp1c1REajVucjlXYnFPK3ZSYWYrQVFDaUgvL3lpQ2UK
+1
View File
@@ -0,0 +1 @@
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDIyODAzRjlGOUFFNDM4QzYKUldUR09PU2FueitBSWhoU1Y4S2VFK21OeWRmamVkMlBreWRjbWdtRmNtbEZrTzMwNE92MDU2d3YK