fix: bind AppImage updates to the running bundle
This commit is contained in:
@@ -61,6 +61,9 @@ payload are in `/root/shacraft` on the ShaCraft host; see
|
||||
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.
|
||||
- Linux self-update requires the original ordinary AppImage file and the frozen
|
||||
Tauri `APPDIR` bound to the running `usr/bin/shacraft-launcher`. Extracted or
|
||||
inherited AppImage context is manual-only; never replace a bare binary.
|
||||
- `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
|
||||
|
||||
@@ -220,7 +220,11 @@ 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.
|
||||
AppImage. The original non-symlink AppImage must have the expected native header,
|
||||
and the frozen Tauri `APPDIR` must contain the actual running executable at
|
||||
`usr/bin/shacraft-launcher`. Extracted binaries and inherited context from another
|
||||
AppImage require manual installation. 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
|
||||
|
||||
@@ -184,7 +184,10 @@ updates preserve it:
|
||||
into an appropriate Applications directory before use. A protected destination
|
||||
may require OS permission or a manual replacement; an installation error never
|
||||
counts as a completed update.
|
||||
- Linux x64 AppImage → x64 AppImage at its current writable location. Debian
|
||||
- Linux x64 AppImage → x64 AppImage at its current writable location. The original
|
||||
ordinary, non-symlink AppImage must exist, and the frozen Tauri `APPDIR` must
|
||||
match the running `usr/bin/shacraft-launcher`. Extracted AppDirs and an inherited
|
||||
environment from another AppImage use manual installation. Debian
|
||||
packages, RPM, bare development binaries and unsupported architectures never
|
||||
enter the self-replacement path. Install a new deb through the system package
|
||||
manager; the launcher does not run privileged package-manager commands.
|
||||
|
||||
+175
-4
@@ -14,6 +14,8 @@ use std::{
|
||||
sync::{Arc, Mutex},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
#[cfg(target_os = "linux")]
|
||||
use tauri::Manager;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
|
||||
@@ -181,11 +183,82 @@ fn package_mode(
|
||||
}
|
||||
}
|
||||
|
||||
fn current_mode() -> PackageMode {
|
||||
// The stamped bundle type survives extracting an AppImage. It does not identify
|
||||
// the runtime file which the plugin will replace. Only the frozen Tauri Env is
|
||||
// shared with the plugin's executable_path selection; do not reread process env.
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
fn appimage_context_valid(path: Option<&Path>, ordinary_file: bool, header: &[u8]) -> bool {
|
||||
path.is_some_and(Path::is_absolute)
|
||||
&& ordinary_file
|
||||
&& format::verify(
|
||||
&PackageMode::Automatic {
|
||||
platform: "linux-x86_64",
|
||||
msi: false,
|
||||
},
|
||||
header,
|
||||
)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
fn appimage_file_ready(path: Option<&Path>) -> bool {
|
||||
use std::io::Read;
|
||||
let Some(path) = path else { return false };
|
||||
let ordinary_file = std::fs::symlink_metadata(path)
|
||||
.map(|metadata| metadata.file_type().is_file())
|
||||
.unwrap_or(false);
|
||||
if !path.is_absolute() || !ordinary_file {
|
||||
return false;
|
||||
}
|
||||
let mut header = [0; 20];
|
||||
if std::fs::File::open(path)
|
||||
.and_then(|mut file| file.read_exact(&mut header))
|
||||
.is_err()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
appimage_context_valid(Some(path), ordinary_file, &header)
|
||||
}
|
||||
|
||||
// Binding APPDIR to the actual executable rejects APPIMAGE/APPDIR inherited
|
||||
// from an unrelated parent application. The fixed relative path is the verified
|
||||
// Tauri AppDir layout for this application's configured binary name.
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
fn appdir_matches_executable(appdir: Option<&Path>, executable: Option<&Path>) -> bool {
|
||||
let (Some(appdir), Some(executable)) = (appdir, executable) else {
|
||||
return false;
|
||||
};
|
||||
if !appdir.is_absolute() || !executable.is_absolute() {
|
||||
return false;
|
||||
}
|
||||
let (Ok(appdir), Ok(executable)) = (appdir.canonicalize(), executable.canonicalize()) else {
|
||||
return false;
|
||||
};
|
||||
appdir.is_dir()
|
||||
&& executable.is_file()
|
||||
&& executable.strip_prefix(&appdir).ok() == Some(Path::new("usr/bin/shacraft-launcher"))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn appimage_runtime_ready(app: &AppHandle) -> bool {
|
||||
let environment = app.env();
|
||||
let executable = std::env::current_exe().ok();
|
||||
appimage_file_ready(environment.appimage.as_deref().map(Path::new))
|
||||
&& appdir_matches_executable(
|
||||
environment.appdir.as_deref().map(Path::new),
|
||||
executable.as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
fn current_mode(_app: &AppHandle) -> 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 = "linux")]
|
||||
if !appimage_runtime_ready(_app) {
|
||||
return PackageMode::Manual;
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let app_bundle = std::env::current_exe()
|
||||
@@ -275,12 +348,12 @@ pub(crate) fn check(app: &AppHandle, state: &UpdaterState) -> Result<UpdateStatu
|
||||
else {
|
||||
return Ok(state.phase(app, Phase::NoUpdate, None));
|
||||
};
|
||||
let manual = current_mode() == PackageMode::Manual;
|
||||
let manual = current_mode(app) == 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.status.message = manual.then(|| "Эта сборка обновляется вручную. Для .deb используйте менеджер пакетов. Автообновление AppImage доступно при запуске исходного файла .AppImage; распакованная копия обновляется вручную.".into());
|
||||
data.candidate = Some(verified);
|
||||
}))
|
||||
}
|
||||
@@ -320,7 +393,7 @@ pub(crate) fn download_install(
|
||||
if !release.is_newer_than(env!("CARGO_PKG_VERSION"))? {
|
||||
return Err("Эта версия уже установлена".into());
|
||||
}
|
||||
let mode = current_mode();
|
||||
let mode = current_mode(app);
|
||||
let artifact = select_artifact(&release, &mode)?;
|
||||
// Includes cross-process game lease and lifecycle exclusion; held through restart/handoff.
|
||||
let guard = UpdateGuard::acquire(directory, operations)?;
|
||||
@@ -482,6 +555,104 @@ mod tests {
|
||||
}
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn appimage_runtime_requires_absolute_ordinary_image_file() {
|
||||
let absolute = std::env::temp_dir().join("launcher.AppImage");
|
||||
let mut header = [0; 20];
|
||||
header[..6].copy_from_slice(b"\x7fELF\x02\x01");
|
||||
header[8..11].copy_from_slice(b"AI\x02");
|
||||
header[18..20].copy_from_slice(b"\x3e\x00");
|
||||
assert!(appimage_context_valid(Some(&absolute), true, &header));
|
||||
// A stamped extracted binary has no APPIMAGE runtime path. A relative
|
||||
// path, missing file/directory/symlink or ordinary ELF is also manual.
|
||||
assert!(!appimage_context_valid(None, true, &header));
|
||||
assert!(!appimage_context_valid(
|
||||
Some(Path::new("launcher.AppImage")),
|
||||
true,
|
||||
&header
|
||||
));
|
||||
assert!(!appimage_context_valid(Some(&absolute), false, &header));
|
||||
assert!(!appimage_context_valid(
|
||||
Some(&absolute),
|
||||
true,
|
||||
&header[..10]
|
||||
));
|
||||
header[8..11].fill(0);
|
||||
assert!(!appimage_context_valid(Some(&absolute), true, &header));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appimage_file_context_rejects_missing_directory_symlink_and_raw_binary() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"shacraft-appimage-context-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos(),
|
||||
));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let path = root.join("launcher.AppImage");
|
||||
assert!(!appimage_file_ready(None));
|
||||
assert!(!appimage_file_ready(Some(&path)));
|
||||
assert!(!appimage_file_ready(Some(&root)));
|
||||
let mut header = [0; 20];
|
||||
header[..6].copy_from_slice(b"\x7fELF\x02\x01");
|
||||
header[18..20].copy_from_slice(b"\x3e\x00");
|
||||
std::fs::write(&path, header).unwrap();
|
||||
assert!(!appimage_file_ready(Some(&path))); // ordinary extracted ELF
|
||||
header[8..11].copy_from_slice(b"AI\x02");
|
||||
std::fs::write(&path, header).unwrap();
|
||||
assert!(appimage_file_ready(Some(&path))); // runtime image header fixture
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let link = root.join("linked.AppImage");
|
||||
std::os::unix::fs::symlink(&path, &link).unwrap();
|
||||
assert!(!appimage_file_ready(Some(&link)));
|
||||
}
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appdir_binding_rejects_inherited_parent_context_and_escaped_executable() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"shacraft-appdir-context-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos(),
|
||||
));
|
||||
let appdir = root.join("ShaCraft.AppDir");
|
||||
let executable = appdir.join("usr/bin/shacraft-launcher");
|
||||
std::fs::create_dir_all(executable.parent().unwrap()).unwrap();
|
||||
std::fs::write(&executable, b"fixture").unwrap();
|
||||
let parent_appdir = root.join("Other.AppDir");
|
||||
std::fs::create_dir(&parent_appdir).unwrap();
|
||||
let bare = root.join("shacraft-launcher");
|
||||
std::fs::write(&bare, b"fixture").unwrap();
|
||||
assert!(appdir_matches_executable(Some(&appdir), Some(&executable)));
|
||||
assert!(!appdir_matches_executable(None, Some(&executable)));
|
||||
assert!(!appdir_matches_executable(Some(&appdir), None));
|
||||
assert!(!appdir_matches_executable(
|
||||
Some(Path::new("relative.AppDir")),
|
||||
Some(&executable)
|
||||
));
|
||||
assert!(!appdir_matches_executable(
|
||||
Some(&parent_appdir),
|
||||
Some(&executable)
|
||||
));
|
||||
assert!(!appdir_matches_executable(Some(&appdir), Some(&bare)));
|
||||
assert!(!appdir_matches_executable(Some(&root), Some(&executable))); // arbitrary ancestor is not sufficient
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::fs::remove_file(&executable).unwrap();
|
||||
std::os::unix::fs::symlink(&bare, &executable).unwrap();
|
||||
assert!(!appdir_matches_executable(Some(&appdir), Some(&executable)));
|
||||
}
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn updater_single_flight_releases_after_failure() {
|
||||
let state = UpdaterState::default();
|
||||
|
||||
Reference in New Issue
Block a user