diff --git a/.github/workflows/desktop-package.yml b/.github/workflows/desktop-package.yml index bc2744c71f..bf73ff0a39 100644 --- a/.github/workflows/desktop-package.yml +++ b/.github/workflows/desktop-package.yml @@ -895,6 +895,7 @@ jobs: --assets-dir release-assets \ --version "${{ needs.prepare.outputs.version }}" \ --out-dir release-updater-assets \ + --windows-installer-dir release-manual-assets \ --required-platforms "${REQUIRED_UPDATER_PLATFORMS}" - name: Generate Linux binaries manifest diff --git a/.github/workflows/publish-ready-desktop.yml b/.github/workflows/publish-ready-desktop.yml index 9cb66d12c9..d1fc299817 100644 --- a/.github/workflows/publish-ready-desktop.yml +++ b/.github/workflows/publish-ready-desktop.yml @@ -107,6 +107,7 @@ jobs: node scripts/write-minisign-public-key.mjs --out release-assets/minisign.pub node scripts/collect-tauri-updater-assets.mjs --assets-dir release-assets \ --version "$RELEASE_VERSION" --out-dir release-updater-assets \ + --windows-installer-dir release-manual-assets \ --required-platforms windows-x86_64,linux-x86_64,linux-aarch64,linux-x86_64-deb,linux-aarch64-deb,linux-x86_64-rpm,linux-aarch64-rpm node scripts/generate-linux-binaries-manifest.mjs --assets-dir linux-release-assets \ --version "$RELEASE_VERSION" --tag "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \ diff --git a/OpenBitFun-Installer/AGENTS.md b/OpenBitFun-Installer/AGENTS.md index 10f1e5bff5..031afd3e36 100644 --- a/OpenBitFun-Installer/AGENTS.md +++ b/OpenBitFun-Installer/AGENTS.md @@ -74,6 +74,12 @@ For ZIP dependency or extraction changes, verify compatibility with old installe cargo test --manifest-path OpenBitFun-Installer/src-tauri/Cargo.toml --lib installer::extract::tests ``` +For transaction recovery, lock contention, locked Windows files and downgrade +admission, run `cargo test --manifest-path OpenBitFun-Installer/src-tauri/Cargo.toml --lib`. +The filesystem fixtures use temporary roots; they do not exercise real UAC or +an existing user installation. Frontend race and retry checks are included in +`pnpm --dir OpenBitFun-Installer run test`. + Run the full installer build only for packaging, payload, native bundling, install/uninstall flow, registry, shortcut, or extraction changes: diff --git a/OpenBitFun-Installer/README.md b/OpenBitFun-Installer/README.md index e33607a651..b8f7d785b9 100644 --- a/OpenBitFun-Installer/README.md +++ b/OpenBitFun-Installer/README.md @@ -234,6 +234,48 @@ src-tauri/target/release-fast/openbitfun-installer.exe ## Customization guide +### Installation, updates and recovery + +The default is a current-user installation. Select “Install for all users” for +a new machine-wide installation; Windows then asks for administrator approval. +An existing installation keeps its directory and scope. Updates to protected +directories request elevation automatically. Canceling authorization leaves the +existing application in place. Model and theme preferences remain with the +original user, including when a different administrator approves maintenance. + +Installations record their managed files in `.openbitfun-installed.json`. +Updates and uninstallations preserve other files and user data. A running or +locked file produces an error and restores earlier changes. Close OpenBitFun +before retrying. Known downgrades are refused using stored and executable version +information; reinstalling the same version is allowed. + +If maintenance is interrupted, rerun the installer against the same directory. +Its `.openbitfun-maintenance` journal restores incomplete changes before a new +operation. An unreadable journal or failed recovery is retained with an error; +keep that directory for repair. A cleanup warning means installation completed, +but maintenance files remain. Retry maintenance before deleting recovery data. + +Windows in-app updates use the same signed standalone installer. The application +exits after the maintenance worker is ready. Installation failures after exit +are reported by a native dialog and retained for the next application launch. +The restarted application runs under the original user's account. + +Uninstall can be entered through Windows Settings, the registered uninstall +command, or `uninstall.exe` in the installation directory. `--quiet` selects +non-interactive removal. Uninstall progress reflects native operations and the +window remains open on failure. User configuration, sessions and workspaces are +preserved. Maintenance runs without WebView2 when invoked in native mode; +the interactive installer offers the signed Microsoft WebView2 bootstrap when +the runtime is absent. + +Quiet maintenance returns exit code `0` on success, `1` on failure, and `2` when +removal committed but backup cleanup remains. Windows can rename an executing +uninstaller but cannot delete its image until it exits. For exit `2`, run the +downloaded installer from outside the installation directory with +`--uninstall "C:\path\to\OpenBitFun" --quiet` to finish cleanup. Do not delete an +uncommitted recovery journal manually. Interactive uninstall also has a native +fallback when WebView2 is absent and does not require downloading a runtime. + ### Changing the UI Theme Shared light/dark values come from `@openbitfun/theme-openbitfun`. Installer-only named presets live in diff --git a/OpenBitFun-Installer/src-tauri/Cargo.toml b/OpenBitFun-Installer/src-tauri/Cargo.toml index 01a7e0b512..4c0dd5cb45 100644 --- a/OpenBitFun-Installer/src-tauri/Cargo.toml +++ b/OpenBitFun-Installer/src-tauri/Cargo.toml @@ -29,8 +29,10 @@ tauri-plugin-dialog = "=2.7.3" serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" +semver = "1" anyhow = "1.0" log = "0.4" +tempfile = "3" dirs = "5.0" zip = { version = "9.0.3", default-features = false, features = ["deflate"] } openbitfun-core-types = { path = "../../src/crates/contracts/core-types" } @@ -51,9 +53,6 @@ tauri-macros = { git = "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/tauri-apps/tauri.git", rev = "7cc68e7 tauri-codegen = { git = "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/tauri-apps/tauri.git", rev = "7cc68e74ff6981f5c50a52a67d56c5eb2d227188" } tauri-plugin = { git = "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/tauri-apps/tauri.git", rev = "7cc68e74ff6981f5c50a52a67d56c5eb2d227188" } -[dev-dependencies] -tempfile = "3" - [lints.rust] unsafe_op_in_unsafe_fn = "warn" unexpected_cfgs = "warn" diff --git a/OpenBitFun-Installer/src-tauri/src/installer/commands.rs b/OpenBitFun-Installer/src-tauri/src/installer/commands.rs index c7f5ccaba2..6f6050d420 100644 --- a/OpenBitFun-Installer/src-tauri/src/installer/commands.rs +++ b/OpenBitFun-Installer/src-tauri/src/installer/commands.rs @@ -2,6 +2,7 @@ use super::extract::{self, ESTIMATED_INSTALL_SIZE}; use super::generated_locale_contract::INSTALLER_GENERATED_LOCALES; +use super::transaction::{self, Transaction}; use super::types::{ ConnectionTestResult, DiskSpaceInfo, InstallOptions, InstallProgress, ModelConfig, RemoteModelInfo, @@ -11,7 +12,7 @@ use openbitfun_core_types::{ installer_config_handoff::{ InstallerConfigHandoff, InstallerModelHandoff, INSTALLER_CONFIG_HANDOFF_FILE_NAME, }, - product_identity::{data_namespace, hidden_data_directory}, + product_identity::data_namespace, }; use openbitfun_services_core::json_store::JsonFileStore; use serde::{Deserialize, Serialize}; @@ -21,16 +22,342 @@ use std::collections::{HashMap, HashSet}; use std::fs::File; use std::io::{Cursor, Read}; use std::path::{Path, PathBuf}; -use std::sync::LazyLock; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + LazyLock, +}; use tauri::{Emitter, Manager, Window}; -#[cfg(target_os = "windows")] -#[derive(Default)] -struct WindowsInstallState { - manufacturer_registered: bool, - uninstall_registered: bool, - desktop_shortcut_created: bool, - start_menu_shortcut_created: bool, +static MAINTENANCE_BUSY: AtomicBool = AtomicBool::new(false); +struct OperationGuard { + _lock: Option, +} +impl OperationGuard { + fn acquire(machine: bool) -> Result { + MAINTENANCE_BUSY + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .map_err(|_| "Another maintenance operation is already in progress".to_string())?; + let result = (|| { + let directory = if machine { + let directory = PathBuf::from( + std::env::var_os("PROGRAMDATA") + .ok_or("Machine data directory is unavailable")?, + ) + .join(data_namespace()) + .join("maintenance"); + std::fs::create_dir_all(&directory).map_err(|e| e.to_string())?; + directory + } else { + ensure_config_directory()? + }; + let path = directory.join("maintenance.lock"); + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(path) + .map_err(|e| e.to_string())?; + file.try_lock() + .map_err(|_| "Another maintenance process is already running".to_string())?; + Ok(Self { _lock: Some(file) }) + })(); + if result.is_err() { + MAINTENANCE_BUSY.store(false, Ordering::Release); + } + result + } +} +impl Drop for OperationGuard { + fn drop(&mut self) { + self._lock.take(); + MAINTENANCE_BUSY.store(false, Ordering::Release); + } +} +pub(crate) fn maintenance_busy() -> bool { + MAINTENANCE_BUSY.load(Ordering::Acquire) +} + +pub(crate) fn persist_native_outcome(code: u32, error: Option<&str>) { + #[cfg(windows)] + super::elevation::persist_native_outcome(code, error); + #[cfg(not(windows))] + let _ = (code, error); +} + +struct UiOperation; +impl UiOperation { + fn acquire() -> Result { + MAINTENANCE_BUSY + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .map_err(|_| "Another maintenance operation is already running".to_string())?; + Ok(Self) + } +} +impl Drop for UiOperation { + fn drop(&mut self) { + MAINTENANCE_BUSY.store(false, Ordering::Release); + } +} + +/// Kept in the executable so release collection can reject pre-protocol installers. +pub(crate) const MAINTENANCE_PROTOCOL: &str = "OPENBITFUN_MAINTENANCE_V1"; + +pub(crate) fn native_mode() -> Option> { + let args = std::env::args().skip(1).collect::>(); + if args.iter().any(|arg| arg == "--maintenance-protocol") { + println!("{MAINTENANCE_PROTOCOL}"); + return Some(Ok(())); + } + let flags = args + .iter() + .take_while(|arg| !arg.eq_ignore_ascii_case("/ARGS")) + .collect::>(); + let update = flags.iter().any(|arg| arg.eq_ignore_ascii_case("/UPDATE")); + let install = flags + .iter() + .any(|arg| arg.eq_ignore_ascii_case("/NATIVE_INSTALL")); + let quiet = flags + .iter() + .any(|arg| *arg == "--quiet" || arg.eq_ignore_ascii_case("/NATIVE_UNINSTALL")); + if !update && !quiet && !install { + return None; + } + if crate::preview::is_enabled() { + return Some(Err("Preview cannot execute maintenance".into())); + } + Some(tauri::async_runtime::block_on(async { + #[cfg(windows)] + if let Some(owner) = args.iter().find_map(|arg| arg.strip_prefix("/OWNER_SID=")) { + let target = args + .iter() + .find_map(|arg| arg.strip_prefix("/OPENBITFUN_TARGET=")) + .or_else(|| { + args.iter() + .position(|arg| arg == "--uninstall") + .and_then(|index| args.get(index + 1)) + .map(String::as_str) + }) + .map(PathBuf::from); + let machine = flags + .iter() + .any(|arg| arg.eq_ignore_ascii_case("/ALLUSERS")) + || target + .as_ref() + .is_some_and(|target| super::registry::is_machine_install(target)); + if !machine && super::elevation::owner_sid()? != owner { + return Err("The administrator account differs from the installation owner. Run maintenance as the original owner or ask your administrator to repair the installation; no files were changed".into()); + } + } + if update || install { + let target = args + .iter() + .find_map(|arg| arg.strip_prefix("/OPENBITFUN_TARGET=").map(str::to_string)) + .unwrap_or_else(get_initial_install_path); + let validated = prepare_install_target(Path::new(&target))?; + let space = get_disk_space(validated.to_string_lossy().into_owned())?; + if !space.sufficient { + return Err("Insufficient disk space for update staging".into()); + } + #[cfg(windows)] + wait_for_update_parent(&args, &validated)?; + #[cfg(windows)] + if update && !args.iter().any(|arg| arg.starts_with("/READY_EVENT=")) { + wait_for_legacy_update_parent(&validated)?; + } + let options = InstallOptions { + install_path: target.clone(), + all_users: flags + .iter() + .any(|arg| arg.eq_ignore_ascii_case("/ALLUSERS")), + desktop_shortcut: flags + .iter() + .any(|arg| arg.eq_ignore_ascii_case("/DESKTOP_SHORTCUT")), + start_menu: flags + .iter() + .any(|arg| arg.eq_ignore_ascii_case("/START_MENU")), + launch_after_install: false, + app_language: "en-US".into(), + theme_preference: "system".into(), + model_config: None, + }; + let result = install_impl(None, options).await?; + if flags.iter().any(|arg| arg.eq_ignore_ascii_case("/R")) { + launch_application(target)?; + } + if result.cleanup_pending { + return Err("Update installed, but maintenance cleanup remains pending; reopen the application and retry cleanup".into()); + } + } else { + let path = args + .iter() + .position(|arg| arg == "--uninstall") + .and_then(|index| args.get(index + 1)) + .cloned() + .ok_or("Silent uninstall requires an explicit installation path")?; + let result = uninstall_impl(None, path).await?; + if result.cleanup_pending { + return Err( + "Uninstall completed, but some maintenance files remain; retry cleanup".into(), + ); + } + } + Ok(()) + })) +} + +#[cfg(windows)] +fn wait_for_legacy_update_parent(root: &Path) -> Result<(), String> { + let powershell = system_powershell()?; + let script = r#"$ErrorActionPreference='Stop' +$target=[IO.Path]::GetFullPath($env:OPENBITFUN_LEGACY_UPDATE_TARGET) +$name=[IO.Path]::GetFileNameWithoutExtension($target) +$deadline=[DateTime]::UtcNow.AddMinutes(3) +foreach($process in @(Get-Process -Name $name -ErrorAction SilentlyContinue)) { + try { $path=$process.Path } catch { if($process.HasExited) { continue }; throw } + if(!$path) { if($process.HasExited) { continue }; throw 'Cannot inspect an OpenBitFun process; close all application instances and retry' } + if([String]::Equals([IO.Path]::GetFullPath($path),$target,[StringComparison]::OrdinalIgnoreCase)) { + $left=[int][Math]::Max(0,($deadline-[DateTime]::UtcNow).TotalMilliseconds) + if(!$process.WaitForExit($left)) { throw 'OpenBitFun did not exit; close the application and retry. No installation files were changed.' } + } +} +"#; + let result = create_windows_silent_command(powershell) + .args(["-NoProfile", "-NonInteractive", "-Command", script]) + .env("OPENBITFUN_LEGACY_UPDATE_TARGET", root.join(MAIN_APP_EXE)) + .output() + .map_err(|e| e.to_string())?; + if !result.status.success() { + return Err(format!( + "Cannot wait for the previous OpenBitFun version to exit: {}", + String::from_utf8_lossy(&result.stderr) + )); + } + Ok(()) +} + +#[cfg(windows)] +fn wait_for_update_parent(args: &[String], root: &Path) -> Result<(), String> { + use std::ffi::c_void; + use std::os::windows::ffi::OsStrExt; + #[link(name = "kernel32")] + extern "system" { + fn OpenProcess(access: u32, inherit: i32, pid: u32) -> *mut c_void; + fn QueryFullProcessImageNameW( + process: *mut c_void, + flags: u32, + image: *mut u16, + size: *mut u32, + ) -> i32; + fn OpenEventW(access: u32, inherit: i32, name: *const u16) -> *mut c_void; + fn SetEvent(event: *mut c_void) -> i32; + fn WaitForMultipleObjects( + count: u32, + objects: *const *mut c_void, + all: i32, + milliseconds: u32, + ) -> u32; + fn CloseHandle(object: *mut c_void) -> i32; + } + let Some(event_name) = args + .iter() + .find_map(|arg| arg.strip_prefix("/READY_EVENT=")) + else { + return Ok(()); + }; + if !event_name.starts_with("Local\\OpenBitFunUpdate-") || event_name.len() > 128 { + return Err("Invalid update handoff event".into()); + } + let pid = args + .iter() + .find_map(|arg| arg.strip_prefix("/WAITPID=")) + .ok_or("Update parent process is missing")? + .parse::() + .map_err(|_| "Update parent process is invalid")?; + if pid == std::process::id() { + return Err("Cannot wait for the maintenance process itself".into()); + } + struct Handle(*mut c_void); + impl Drop for Handle { + fn drop(&mut self) { + unsafe { + CloseHandle(self.0); + } + } + } + let parent = Handle(unsafe { OpenProcess(0x00100000 | 0x1000, 0, pid) }); + if parent.0.is_null() { + return Err("Cannot establish update parent identity; no files were changed".into()); + } + let mut image = vec![0_u16; 32768]; + let mut length = image.len() as u32; + if unsafe { QueryFullProcessImageNameW(parent.0, 0, image.as_mut_ptr(), &mut length) } == 0 + || !windows_path_eq_case_insensitive( + Path::new(&String::from_utf16_lossy(&image[..length as usize])), + &root.join(MAIN_APP_EXE), + ) + { + return Err("Update parent does not belong to the target installation".into()); + } + let supervisor_pid = args + .iter() + .find_map(|arg| arg.strip_prefix("/SUPERVISOR_PID=")) + .ok_or("Update supervisor process is missing")? + .parse::() + .map_err(|_| "Invalid update supervisor process")?; + let supervisor = Handle(unsafe { OpenProcess(0x00100000, 0, supervisor_pid) }); + if supervisor.0.is_null() { + return Err("Update supervisor is unavailable".into()); + } + let name = std::ffi::OsStr::new(event_name) + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let ready = Handle(unsafe { OpenEventW(2, 0, name.as_ptr()) }); + if ready.0.is_null() || unsafe { SetEvent(ready.0) } == 0 { + return Err("Update supervisor is unavailable".into()); + } + // A canceled/timed-out supervisor cannot leave an orphan installer that + // applies a formerly canceled update when the user later closes the app. + let handles = [supervisor.0, parent.0]; + if unsafe { WaitForMultipleObjects(2, handles.as_ptr(), 0, 180_000) } != 1 { + return Err("Update handoff was canceled or the application did not exit; no installation files were changed".into()); + } + Ok(()) +} + +#[cfg(windows)] +#[derive(Serialize, Deserialize)] +struct IntegrationSnapshot { + registry: super::registry::Snapshot, + shortcuts: super::shortcut::Snapshot, +} + +fn restore_integrations(path: &Path, snapshot: &Value) -> Result<(), String> { + #[cfg(windows)] + { + let snapshot: IntegrationSnapshot = + serde_json::from_value(snapshot.clone()).map_err(|e| e.to_string())?; + super::registry::restore(path, &snapshot.registry).map_err(|e| e.to_string())?; + super::shortcut::restore(path, &snapshot.shortcuts).map_err(|e| e.to_string())?; + } + #[cfg(not(windows))] + let _ = (path, snapshot); + Ok(()) +} + +fn snapshot_integrations(path: &Path, tx: &mut Transaction, machine: bool) -> Result<(), String> { + #[cfg(windows)] + { + let snapshot = IntegrationSnapshot { + registry: super::registry::snapshot(path, machine).map_err(|e| e.to_string())?, + shortcuts: super::shortcut::snapshot(path, machine).map_err(|e| e.to_string())?, + }; + tx.snapshot_integrations(serde_json::to_value(snapshot).map_err(|e| e.to_string())?)?; + } + #[cfg(not(windows))] + let _ = (path, tx, machine); + Ok(()) } const MIN_WINDOWS_APP_EXE_BYTES: u64 = 5 * 1024 * 1024; @@ -47,8 +374,32 @@ const INSTALLER_STATE_FILE: &str = "installer-state.json"; const EMBEDDED_PAYLOAD_ZIP: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/embedded_payload.zip")); +#[cfg(windows)] +pub(crate) fn windows_system_directory() -> Result { + use std::os::windows::ffi::OsStringExt; + #[link(name = "kernel32")] + extern "system" { + fn GetSystemDirectoryW(buffer: *mut u16, size: u32) -> u32; + } + let mut buffer = vec![0_u16; 32768]; + let length = unsafe { GetSystemDirectoryW(buffer.as_mut_ptr(), buffer.len() as u32) } as usize; + if length == 0 || length >= buffer.len() { + return Err("Windows system directory is unavailable".into()); + } + Ok(PathBuf::from(std::ffi::OsString::from_wide( + &buffer[..length], + ))) +} + +#[cfg(windows)] +pub(crate) fn system_powershell() -> Result { + Ok(windows_system_directory()?.join("WindowsPowerShell/v1.0/powershell.exe")) +} + #[cfg(target_os = "windows")] -fn create_windows_silent_command>(program: S) -> std::process::Command { +pub(crate) fn create_windows_silent_command>( + program: S, +) -> std::process::Command { use std::os::windows::process::CommandExt; const CREATE_NO_WINDOW: u32 = 0x08000000; @@ -74,12 +425,12 @@ static INSTALLER_APP_LANGUAGE_ALIASES_BY_PRIORITY: LazyLock, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] struct PayloadManifestFile { path: String, size: u64, @@ -121,14 +472,19 @@ struct InstallerState { /// Get the default installation path. #[tauri::command] -pub(crate) fn get_default_install_path() -> String { +pub(crate) fn get_default_install_path(all_users: Option) -> String { let base = if cfg!(target_os = "windows") { - std::env::var("LOCALAPPDATA") - .map(PathBuf::from) - .unwrap_or_else(|_| { - dirs::data_local_dir() - .unwrap_or_else(|| PathBuf::from(["C:", "Program Files"].join("\\"))) - }) + if all_users == Some(true) { + std::env::var_os("ProgramW6432") + .or_else(|| std::env::var_os("ProgramFiles")) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(r"C:\Program Files")) + } else { + std::env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .or_else(dirs::data_local_dir) + .unwrap_or_else(|| PathBuf::from(r"C:\Program Files")) + } } else if cfg!(target_os = "macos") { dirs::home_dir() .map(|h| h.join("Applications")) @@ -149,22 +505,22 @@ pub(crate) fn get_initial_install_path() -> String { { use super::registry; if let Some(data) = registry::read_existing_install_from_uninstall_registry() { - if let Ok(resolved) = prepare_install_target(Path::new(&data.install_location)) { + if let Ok(resolved) = resolve_install_target(Path::new(&data.install_location)) { return resolved.to_string_lossy().to_string(); } } if let Some(from_reg) = registry::read_tauri_install_location() { - if let Ok(resolved) = prepare_install_target(Path::new(&from_reg)) { + if let Ok(resolved) = resolve_install_target(Path::new(&from_reg)) { return resolved.to_string_lossy().to_string(); } } } if let Some(saved) = read_last_install_path() { - if let Ok(resolved) = prepare_install_target(Path::new(&saved)) { + if let Ok(resolved) = resolve_install_target(Path::new(&saved)) { return resolved.to_string_lossy().to_string(); } } - get_default_install_path() + get_default_install_path(None) } /// Detect an existing OpenBitFun install (Tauri NSIS or this installer) via Add/Remove Programs registry. @@ -303,7 +659,7 @@ fn launch_windows_registered_uninstaller( } #[cfg(target_os = "windows")] -fn parse_windows_command_line(command_line: &str) -> Result, String> { +pub(super) fn parse_windows_command_line(command_line: &str) -> Result, String> { use std::ffi::{c_void, OsStr, OsString}; use std::os::windows::ffi::{OsStrExt, OsStringExt}; @@ -388,20 +744,14 @@ pub(crate) fn get_disk_space(path: String) -> Result { return Ok(DiskSpaceInfo { total: total_bytes, available: free_bytes_available, - required: ESTIMATED_INSTALL_SIZE, - sufficient: free_bytes_available >= ESTIMATED_INSTALL_SIZE, + required: required_install_space()?, + sufficient: free_bytes_available >= required_install_space()?, }); } } } - // Fallback: assume sufficient space - Ok(DiskSpaceInfo { - total: 0, - available: u64::MAX, - required: ESTIMATED_INSTALL_SIZE, - sufficient: true, - }) + Err("Cannot determine available disk space; check the target drive and retry".into()) } #[cfg(target_os = "windows")] @@ -473,31 +823,147 @@ pub(crate) fn get_launch_context() -> LaunchContext { #[tauri::command] pub(crate) fn validate_install_path(path: String) -> Result { let requested_path = PathBuf::from(&path); - let install_path = prepare_install_target(&requested_path)?; + // Validation must be read-only. Permission probing belongs to execution, + // where the native worker can request elevation for a protected directory. + let install_path = resolve_install_target(&requested_path)?; Ok(InstallPathValidation { install_path: install_path.to_string_lossy().to_string(), }) } /// Main installation command. Emits progress events to the frontend. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MaintenanceResult { + is_update: bool, + cleanup_pending: bool, + #[serde(skip_serializing_if = "Option::is_none")] + warning: Option, +} + #[tauri::command] pub(crate) async fn start_installation( window: Window, options: InstallOptions, -) -> Result<(), String> { +) -> Result { + #[cfg(windows)] + { + let path = resolve_install_target(Path::new(&options.install_path))?; + super::registry::validate_scope(&path, options.all_users).map_err(|e| e.to_string())?; + let needs_elevation = !super::elevation::is_elevated() + && (options.all_users + || super::registry::is_machine_install(&path) + || prepare_install_target(&path) + .is_err_and(|error| error.ends_with("not_writable"))); + if needs_elevation { + let _ui = UiOperation::acquire()?; + let is_update = + path.join(MAIN_APP_EXE).exists() || path.join(transaction::INVENTORY).exists(); + emit_progress( + Some(&window), + "prepare", + 0, + "Waiting for administrator authorization...", + ); + let mut args = vec![ + "/NATIVE_INSTALL".into(), + format!("/OPENBITFUN_TARGET={}", path.display()), + format!("/OWNER_SID={}", super::elevation::owner_sid()?), + ]; + if options.all_users || super::registry::is_machine_install(&path) { + args.push("/ALLUSERS".into()); + } + if options.desktop_shortcut { + args.push("/DESKTOP_SHORTCUT".into()); + } + if options.start_menu { + args.push("/START_MENU".into()); + } + let current = std::env::current_exe().map_err(|e| e.to_string())?; + let code = tauri::async_runtime::spawn_blocking(move || { + super::elevation::run(¤t, &args) + }) + .await + .map_err(|e| e.to_string())??; + if code != 0 && code != 2 { + return Err(format!("Administrator maintenance failed (exit code {code}); inspect the reported error before retrying")); + } + let warning = if !is_update { + apply_first_launch_language(&options.app_language) + .await + .err() + } else { + None + }; + persist_last_install_path(&path); + emit_progress(Some(&window), "complete", 100, "Installation complete"); + return Ok(MaintenanceResult { + is_update, + cleanup_pending: code == 2, + warning, + }); + } + } + install_impl(Some(window), options).await +} + +async fn install_impl( + window: Option, + options: InstallOptions, +) -> Result { + #[cfg(windows)] + let machine_lock = + options.all_users || super::registry::is_machine_install(Path::new(&options.install_path)); + #[cfg(not(windows))] + let machine_lock = options.all_users; + let _operation = OperationGuard::acquire(machine_lock)?; let install_path = prepare_install_target(Path::new(&options.install_path))?; - let install_dir_was_absent = !install_path.exists(); - #[cfg(target_os = "windows")] - let mut windows_state = WindowsInstallState::default(); + #[cfg(windows)] + super::registry::validate_scope(&install_path, options.all_users).map_err(|e| e.to_string())?; + let space = get_disk_space(install_path.to_string_lossy().into_owned())?; + if !space.sufficient { + return Err( + "Insufficient disk space for installation staging and maintenance files".into(), + ); + } + let mut transaction = Transaction::open_with_recovery(&install_path, restore_integrations)?; + let is_update = install_path.join(MAIN_APP_EXE).exists() + || install_path.join(transaction::INVENTORY).exists(); + let previous = read_installed_inventory(&install_path)?; + let stored_version = std::fs::read(install_path.join(transaction::INVENTORY)) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .and_then(|value| { + value + .get("version") + .and_then(Value::as_str) + .map(str::to_string) + }); + #[cfg(windows)] + let stored_version = + stored_version.or_else(|| super::registry::installed_version(&install_path)); + if let Some(version) = stored_version { + validate_upgrade_version(&version, env!("CARGO_PKG_VERSION"))?; + } + #[cfg(windows)] + if let Some(version) = installed_binary_version(&install_path.join(MAIN_APP_EXE)) { + validate_upgrade_version(&version, env!("CARGO_PKG_VERSION"))?; + } + + #[cfg(windows)] + let machine = options.all_users || super::registry::is_machine_install(&install_path); + #[cfg(not(windows))] + let machine = options.all_users; + let staging_path = transaction.stage(); let result: Result<(), String> = async { // Step 1: Create target directory - emit_progress(&window, "prepare", 5, "Creating installation directory..."); + emit_progress(window.as_ref(), "prepare", 5, "Creating installation directory..."); std::fs::create_dir_all(&install_path) .map_err(|e| format!("Failed to create directory: {}", e))?; // Step 2: Extract / copy application files - emit_progress(&window, "extract", 15, "Extracting application files..."); + emit_progress(window.as_ref(), "extract", 15, "Extracting application files..."); let mut extracted = false; let mut used_debug_placeholder = false; @@ -513,7 +979,7 @@ pub(crate) async fn start_installation( )?); extract::extract_zip_bytes_with_filter( EMBEDDED_PAYLOAD_ZIP, - &install_path, + &staging_path, should_install_payload_path, ) .map_err(|e| format!("Embedded payload extraction failed: {}", e))?; @@ -529,7 +995,7 @@ pub(crate) async fn start_installation( .to_path_buf(); if !extracted { - for candidate in build_payload_candidates(&window, &exe_dir) { + for candidate in build_payload_candidates(window.as_ref(), &exe_dir) { if candidate.is_zip { checked_locations.push(format!("zip: {}", candidate.path.display())); if !candidate.path.exists() { @@ -542,7 +1008,7 @@ pub(crate) async fn start_installation( )?); extract::extract_zip_with_filter( &candidate.path, - &install_path, + &staging_path, should_install_payload_path, ) .map_err(|e| format!("Extraction failed from {}: {}", candidate.label, e))?; @@ -562,7 +1028,7 @@ pub(crate) async fn start_installation( )?); extract::copy_directory_with_filter( &candidate.path, - &install_path, + &staging_path, should_install_payload_path, ) .map_err(|e| format!("File copy failed from {}: {}", candidate.label, e))?; @@ -576,7 +1042,7 @@ pub(crate) async fn start_installation( if cfg!(debug_assertions) { // Development mode: create a placeholder to simplify local UI iteration. log::warn!("No payload found - running in development mode"); - let placeholder = install_path.join(MAIN_APP_EXE); + let placeholder = staging_path.join(MAIN_APP_EXE); if !placeholder.exists() { std::fs::write(&placeholder, "placeholder") .map_err(|e| format!("Failed to write placeholder: {}", e))?; @@ -594,10 +1060,26 @@ pub(crate) async fn start_installation( let manifest = installed_manifest.as_ref().ok_or_else(|| { "Installer payload manifest was not retained after extraction".to_string() })?; - verify_installed_payload(&install_path, manifest)?; + verify_installed_payload(&staging_path, manifest)?; } - emit_progress(&window, "extract", 50, "Files extracted successfully"); + emit_progress(window.as_ref(), "extract", 50, "Files extracted successfully"); + + let mut manifest = installed_manifest.unwrap_or(PayloadManifest { files: vec![] }); + #[cfg(windows)] { + let current = std::env::current_exe().map_err(|e| e.to_string())?; + let uninstaller = staging_path.join("uninstall.exe"); + std::fs::copy(current, &uninstaller).map_err(|e| format!("Cannot stage uninstaller: {e}"))?; + manifest.files.push(PayloadManifestFile { path: "uninstall.exe".into(), size: std::fs::metadata(&uninstaller).map_err(|e| e.to_string())?.len(), sha256: sha256_file(&uninstaller)? }); + } + let inventory = serde_json::json!({ "schema": 1, "productId": "openbitfun", "version": env!("CARGO_PKG_VERSION"), "allUsers": machine, "files": manifest.files }); + std::fs::write(staging_path.join(transaction::INVENTORY), serde_json::to_vec(&inventory).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?; + let mut replacements = manifest.files.iter().map(|file| file.path.clone()).collect::>(); + if used_debug_placeholder { replacements.push(MAIN_APP_EXE.into()); } + replacements.push(transaction::INVENTORY.into()); + let obsolete = previous.map(|old| old.files.into_iter().map(|file| file.path).filter(|path| !replacements.contains(path)).collect::>()).unwrap_or_default(); + snapshot_integrations(&install_path, &mut transaction, machine)?; + transaction.publish(&replacements, &obsolete)?; // Step 3: Windows-specific operations #[cfg(target_os = "windows")] @@ -605,223 +1087,369 @@ pub(crate) async fn start_installation( use super::registry; use super::shortcut; - let current_exe = std::env::current_exe().map_err(|e| e.to_string())?; let uninstaller_path = install_path.join("uninstall.exe"); - std::fs::copy(¤t_exe, &uninstaller_path) - .map_err(|e| format!("Failed to create uninstaller executable: {}", e))?; let uninstall_command = format!("\"{}\"", uninstaller_path.display()); - emit_progress(&window, "registry", 60, "Registering application..."); + emit_progress(window.as_ref(), "registry", 60, "Registering application..."); registry::register_tauri_install_location(&install_path) .map_err(|e| format!("Registry error: {}", e))?; - windows_state.manufacturer_registered = true; + registry::register_uninstall_entry( &install_path, env!("CARGO_PKG_VERSION"), &uninstall_command, ) .map_err(|e| format!("Registry error: {}", e))?; - windows_state.uninstall_registered = true; + // Desktop shortcut if options.desktop_shortcut { - emit_progress(&window, "shortcuts", 70, "Creating desktop shortcut..."); + emit_progress(window.as_ref(), "shortcuts", 70, "Creating desktop shortcut..."); shortcut::create_desktop_shortcut(&install_path) .map_err(|e| format!("Shortcut error: {}", e))?; - windows_state.desktop_shortcut_created = true; + } // Start Menu if options.start_menu { - emit_progress(&window, "shortcuts", 75, "Creating Start Menu entry..."); + emit_progress(window.as_ref(), "shortcuts", 75, "Creating Start Menu entry..."); shortcut::create_start_menu_shortcut(&install_path) .map_err(|e| format!("Start Menu error: {}", e))?; - windows_state.start_menu_shortcut_created = true; + } } // Step 4: Pass startup preferences to the desktop configuration manager, // which remains the only writer of the canonical app.json. - emit_progress(&window, "config", 92, "Applying startup preferences..."); - apply_first_launch_language(&options.app_language) - .await - .map_err(|e| format!("Failed to apply startup preferences: {}", e)) + emit_progress(window.as_ref(), "config", 92, "Applying startup preferences..."); + if !is_update && window.is_some() { + apply_first_launch_language(&options.app_language).await + .map_err(|e| format!("Failed to apply startup preferences: {e}"))?; + } + Ok(()) } .await; - if let Err(err) = result { - #[cfg(target_os = "windows")] - rollback_installation(&install_path, install_dir_was_absent, &windows_state); - #[cfg(not(target_os = "windows"))] - rollback_installation(&install_path, install_dir_was_absent); - return Err(err); + if let Err(error) = result { + return match transaction.rollback() { + Ok(()) => Err(error), + Err(recovery) => Err(format!( + "{error}. Recovery failed: {recovery}; installation backup was preserved" + )), + }; } + let cleaned = transaction.commit()?; - persist_last_install_path(&install_path); - emit_progress(&window, "complete", 100, "Installation complete!"); + if window.is_some() { + persist_last_install_path(&install_path); + } + emit_progress(window.as_ref(), "complete", 100, "Installation complete!"); - Ok(()) + Ok(MaintenanceResult { + is_update, + cleanup_pending: !cleaned, + warning: None, + }) } /// Uninstall OpenBitFun (for the uninstaller companion). #[tauri::command] -pub(crate) async fn uninstall(install_path: String) -> Result<(), String> { - let install_path = PathBuf::from(&install_path); - let uninstall_targets = collect_uninstall_targets(&install_path)?; - - #[cfg(target_os = "windows")] +pub(crate) async fn uninstall( + window: Window, + install_path: String, +) -> Result { + #[cfg(windows)] { - use super::registry; - use super::shortcut; - - let _ = shortcut::remove_desktop_shortcut(); - let _ = shortcut::remove_start_menu_shortcut(); - let _ = registry::remove_context_menu(); - let _ = registry::remove_from_path(&install_path); - let _ = registry::remove_autostart_run_entry(); - let _ = registry::remove_tauri_install_location(); - let _ = registry::remove_uninstall_entry(); + let path = PathBuf::from(&install_path); + if !super::elevation::is_elevated() + && (super::registry::is_machine_install(&path) + || prepare_install_target(&path) + .is_err_and(|error| error.ends_with("not_writable"))) + { + let _ui = UiOperation::acquire()?; + let current = std::env::current_exe().map_err(|e| e.to_string())?; + let mut args = vec![ + "/NATIVE_UNINSTALL".into(), + "--uninstall".into(), + install_path, + format!("/OPENBITFUN_TARGET={}", path.display()), + format!("/OWNER_SID={}", super::elevation::owner_sid()?), + ]; + if super::registry::is_machine_install(&path) { + args.push("/ALLUSERS".into()); + } + let code = tauri::async_runtime::spawn_blocking(move || { + super::elevation::run(¤t, &args) + }) + .await + .map_err(|e| e.to_string())??; + if code != 0 && code != 2 { + return Err(format!("Administrator uninstall failed (exit code {code}); installation recovery data was retained")); + } + return Ok(MaintenanceResult { + is_update: false, + cleanup_pending: code == 2, + warning: None, + }); + } } + uninstall_impl(Some(window), install_path).await +} - #[cfg(target_os = "windows")] +async fn uninstall_impl( + window: Option, + install_path: String, +) -> Result { + #[cfg(windows)] + let machine_lock = super::registry::is_machine_install(Path::new(&install_path)); + #[cfg(not(windows))] + let machine_lock = false; + let _operation = OperationGuard::acquire(machine_lock)?; + let install_path = PathBuf::from(&install_path); + if !install_path.is_absolute() || !install_path.is_dir() { + return Err("Installed directory is unavailable".into()); + } + if !install_path.join(MAIN_APP_EXE).is_file() + && !install_path.join(transaction::INVENTORY).is_file() + && !install_path + .join(transaction::WORK_DIR) + .join("journal.json") + .is_file() { - let current_exe = std::env::current_exe().ok(); - let running_uninstall_binary = current_exe - .as_ref() - .and_then(|exe| exe.file_stem().map(|s| s.to_string_lossy().to_string())) - .map(|stem| stem.eq_ignore_ascii_case("uninstall")) - .unwrap_or(false); - - let current_exe_parent = current_exe - .as_ref() - .and_then(|exe| exe.parent().map(|p| p.to_path_buf())); - let running_from_install_dir = current_exe_parent - .as_ref() - .map(|parent| windows_path_eq_case_insensitive(parent, &install_path)) - .unwrap_or(false); - - append_uninstall_runtime_log(&format!( - "uninstall called: install_path='{}', current_exe='{}', running_uninstall_binary={}, running_from_install_dir={}", - install_path.display(), - current_exe - .as_ref() - .map(|p| p.display().to_string()) - .unwrap_or_else(|| "".to_string()), - running_uninstall_binary, - running_from_install_dir - )); - - let current_exe_path = current_exe.as_deref(); - remove_installed_targets(&install_path, &uninstall_targets, current_exe_path)?; - - if (running_uninstall_binary || running_from_install_dir) - && current_exe_path - .map(|exe| { - windows_path_eq_case_insensitive(exe, &install_path.join("uninstall.exe")) - }) - .unwrap_or(false) + return Err( + "Cannot establish ownership of this installation; no files were removed".into(), + ); + } + // Running from the installation prevents a safe transactional rename. + if std::env::current_exe() + .ok() + .is_some_and(|exe| paths_equal_for_platform(&exe, &install_path.join("uninstall.exe"))) + { + return Err( + "Restart the uninstaller from its registered entry before removing files".into(), + ); + } + let mut transaction = Transaction::open_with_recovery(&install_path, restore_integrations)?; + let targets = collect_uninstall_targets(&install_path)?; + let relative = targets + .iter() + .map(|path| { + path.strip_prefix(&install_path) + .map(|p| p.to_string_lossy().replace('\\', "/")) + .map_err(|e| e.to_string()) + }) + .collect::, _>>()?; + #[cfg(windows)] + let machine = super::registry::is_machine_install(&install_path); + #[cfg(not(windows))] + let machine = false; + snapshot_integrations(&install_path, &mut transaction, machine)?; + emit_progress( + window.as_ref(), + "prepare", + 10, + "Preparing uninstallation...", + ); + transaction.publish(&[], &relative)?; + let cleanup = (|| -> Result<(), String> { + #[cfg(windows)] { - schedule_windows_self_uninstall_cleanup(current_exe_path.unwrap())?; + use super::{registry, shortcut}; + emit_progress( + window.as_ref(), + "shortcuts", + 60, + "Removing owned shortcuts...", + ); + shortcut::remove_desktop_shortcut(&install_path).map_err(|e| e.to_string())?; + shortcut::remove_start_menu_shortcut(&install_path).map_err(|e| e.to_string())?; + registry::remove_context_menu(&install_path).map_err(|e| e.to_string())?; + registry::remove_from_path(&install_path).map_err(|e| e.to_string())?; + registry::remove_autostart_run_entry(&install_path).map_err(|e| e.to_string())?; + emit_progress( + window.as_ref(), + "registry", + 85, + "Removing owned registration...", + ); + registry::remove_tauri_install_location(&install_path).map_err(|e| e.to_string())?; + registry::remove_uninstall_entry(&install_path).map_err(|e| e.to_string())?; } + Ok(()) + })(); + if let Err(error) = cleanup { + return match transaction.rollback() { + Ok(()) => Err(format!( + "Uninstall failed; previous files restored: {error}" + )), + Err(recovery) => Err(format!( + "Uninstall failed: {error}; recovery needs repair: {recovery}" + )), + }; } - - #[cfg(not(target_os = "windows"))] - remove_installed_targets(&install_path, &uninstall_targets, None)?; - Ok(()) -} - -#[cfg(target_os = "windows")] -fn schedule_windows_self_uninstall_cleanup(uninstall_exe_path: &Path) -> Result<(), String> { - let temp_dir = std::env::temp_dir(); - let pid = std::process::id(); - let script_path = temp_dir.join(format!("{}-uninstall-{}.cmd", data_namespace(), pid)); - let log_path = temp_dir.join(format!( - "{}-uninstall-cleanup-{}.log", - data_namespace(), - pid - )); - let fallback_log_name = format!("{}-uninstall-cleanup.log", data_namespace()); - - let script = format!( - r#"@echo off -setlocal enableextensions -set "TARGET=%~1" -set "LOG=%~2" -set "TARGET_DIR=%~dp1" -if "%TARGET%"=="" exit /b 2 -if "%LOG%"=="" set "LOG=%TEMP%\{fallback_log_name}" -echo [%DATE% %TIME%] cleanup start > "%LOG%" -cd /d "%TEMP%" -for /L %%i in (1,1,30) do ( - if not exist "%TARGET%" ( - echo [%DATE% %TIME%] cleanup success on try %%i >> "%LOG%" - exit /b 0 - ) - del /f /q "%TARGET%" >> "%LOG%" 2>&1 - if not exist "%TARGET%" ( - if not "%TARGET_DIR%"=="" rmdir "%TARGET_DIR%" >> "%LOG%" 2>&1 - echo [%DATE% %TIME%] cleanup success on try %%i >> "%LOG%" - exit /b 0 - ) - timeout /t 1 /nobreak >nul -) -echo [%DATE% %TIME%] cleanup failed after retries >> "%LOG%" -exit /b 1 -"# + let cleaned = transaction.commit()?; + drop(transaction); + if cleaned { + for dir in collect_parent_directories(&install_path, &targets) { + let _ = std::fs::remove_dir(dir); + } + let _ = std::fs::remove_dir(&install_path); + } + emit_progress( + window.as_ref(), + "complete", + 100, + "Uninstallation complete; user data preserved", ); + Ok(MaintenanceResult { + is_update: false, + cleanup_pending: !cleaned, + warning: None, + }) +} - std::fs::write(&script_path, script) - .map_err(|e| format!("Failed to write cleanup script: {}", e))?; - - append_uninstall_runtime_log(&format!( - "scheduled cleanup script='{}', target='{}', cleanup_log='{}'", - script_path.display(), - uninstall_exe_path.display(), - log_path.display() - )); - - let child = create_windows_silent_command("cmd") - .arg("/C") - .arg("call") - .arg(&script_path) - .arg(uninstall_exe_path) - .arg(&log_path) - .current_dir(&temp_dir) - .spawn() - .map_err(|e| format!("Failed to schedule uninstall cleanup: {}", e))?; - - append_uninstall_runtime_log(&format!("cleanup process spawned: pid={}", child.id())); +#[cfg(windows)] +pub(super) fn windows_path_eq_case_insensitive(a: &Path, b: &Path) -> bool { + fn normalize(path: &Path) -> String { + std::fs::canonicalize(path) + .unwrap_or_else(|_| path.to_path_buf()) + .to_string_lossy() + .replace('/', "\\") + .trim_start_matches(r"\\?\") + .trim_end_matches('\\') + .to_lowercase() + } + normalize(a) == normalize(b) +} - Ok(()) +/// Relocate before creating a WebView so the installed executable is never in use +/// while the removal transaction backs it up. No command-shell cleanup is needed. +pub(crate) fn relocate_uninstaller() -> Result { + #[cfg(windows)] + { + if crate::preview::is_enabled() || !is_running_as_uninstall_binary() { + return Ok(false); + } + let current = std::env::current_exe().map_err(|e| e.to_string())?; + let arguments = std::env::args_os().skip(1).collect::>(); + let root = arguments + .iter() + .position(|arg| arg == "--uninstall") + .and_then(|index| arguments.get(index + 1)) + .map(PathBuf::from) + .or_else(|| current.parent().map(Path::to_path_buf)) + .ok_or("Uninstaller has no installation directory")?; + let temp = tempfile::Builder::new() + .prefix("openbitfun-uninstall-") + .tempdir() + .map_err(|e| e.to_string())?; + let copy = temp.path().join("openbitfun-maintenance.exe"); + std::fs::copy(¤t, ©).map_err(|e| e.to_string())?; + let mut child = create_windows_silent_command(©) + .arg("--uninstall") + .arg(&root) + .args(&arguments) + .spawn() + .map_err(|e| format!("Cannot start uninstall maintenance: {e}"))?; + let _retained = temp.keep(); + if arguments.iter().any(|arg| arg == "--quiet") { + // Management tools must receive the worker's real result. Keeping + // this image mapped may defer backup cleanup (exit 2), but a failed + // removal must never be reported as a successful spawn (exit 0). + let status = child + .wait() + .map_err(|e| format!("Cannot read uninstall result: {e}"))?; + std::process::exit(status.code().unwrap_or(1)); + } + return Ok(true); + } + #[cfg(not(windows))] + Ok(false) } -#[cfg(target_os = "windows")] -fn windows_path_eq_case_insensitive(a: &Path, b: &Path) -> bool { - fn normalize(path: &Path) -> String { - let mut s = path.to_string_lossy().replace('/', "\\").to_lowercase(); - while s.ends_with('\\') { - s.pop(); +pub(crate) fn schedule_uninstaller_cleanup() { + #[cfg(windows)] + { + let schedule = || -> Result<(), String> { + let executable = std::env::current_exe().map_err(|e| e.to_string())?; + if executable.file_name() != Some(std::ffi::OsStr::new("openbitfun-maintenance.exe")) { + return Ok(()); + } + let directory = executable + .parent() + .ok_or("Temporary uninstaller directory is unavailable")?; + let parent = directory + .parent() + .ok_or("Temporary uninstaller parent is unavailable")?; + if !directory + .file_name() + .is_some_and(|name| name.to_string_lossy().starts_with("openbitfun-uninstall-")) + || !windows_path_eq_case_insensitive(parent, &std::env::temp_dir()) + { + return Ok(()); + } + let powershell = system_powershell()?; + // Only the exact relocated executable and its now-empty directory + // may be removed. Never recursively delete a computed temp path. + let script = r#"$ErrorActionPreference='Stop' +$process=Get-Process -Id ([int]$env:OPENBITFUN_CLEANUP_PARENT) -ErrorAction SilentlyContinue +if($process) { $null=$process.Handle; $process.WaitForExit() } +$file=Join-Path $env:OPENBITFUN_CLEANUP_DIRECTORY 'openbitfun-maintenance.exe' +for($attempt=0;$attempt -lt 20;$attempt++) { + try { + if(Test-Path -LiteralPath $file) { Remove-Item -LiteralPath $file -Force } + [IO.Directory]::Delete($env:OPENBITFUN_CLEANUP_DIRECTORY,$false) + break + } catch { Start-Sleep -Milliseconds 500 } +} +"#; + create_windows_silent_command(powershell) + .args(["-NoProfile", "-NonInteractive", "-Command", script]) + .env("OPENBITFUN_CLEANUP_PARENT", std::process::id().to_string()) + .env("OPENBITFUN_CLEANUP_DIRECTORY", directory) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .map_err(|e| e.to_string())?; + Ok(()) + }; + if let Err(error) = schedule() { + eprintln!("Temporary uninstaller cleanup could not be scheduled: {error}"); } - s } - normalize(a) == normalize(b) } -#[cfg(target_os = "windows")] -fn append_uninstall_runtime_log(message: &str) { - let ts = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - let log_path = std::env::temp_dir().join(format!("{}-uninstall-runtime.log", data_namespace())); - if let Ok(mut file) = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&log_path) +/// Keep uninstall usable when an existing machine has no WebView2 runtime. +#[cfg(windows)] +pub(crate) fn uninstall_without_webview(path: String) -> Result<(), String> { + let root = Path::new(&path); + if !super::elevation::is_elevated() + && (super::registry::is_machine_install(root) + || prepare_install_target(root).is_err_and(|error| error.ends_with("not_writable"))) { - use std::io::Write; - let _ = writeln!(file, "[{}] {}", ts, message); + let args = vec![ + "/NATIVE_UNINSTALL".into(), + "--uninstall".into(), + path, + format!("/OWNER_SID={}", super::elevation::owner_sid()?), + ]; + let executable = std::env::current_exe().map_err(|e| e.to_string())?; + return match super::elevation::run(&executable, &args)? { + 0 => Ok(()), + 2 => { + Err("Uninstall completed, but some maintenance files remain; retry cleanup".into()) + } + code => Err(format!( + "Uninstall failed with exit code {code}; recovery data was retained" + )), + }; } + let result = tauri::async_runtime::block_on(uninstall_impl(None, path))?; + if result.cleanup_pending { + return Err("Uninstall completed, but some maintenance files remain; retry cleanup".into()); + } + Ok(()) } /// Launch the installed application. @@ -866,6 +1494,9 @@ pub(crate) fn launch_legacy_data_migrator( /// Close the installer window. #[tauri::command] pub(crate) fn close_installer(window: Window) { + if maintenance_busy() { + return; + } let _ = window.close(); } @@ -1094,13 +1725,15 @@ fn parse_custom_request_body(raw: &Option) -> Result, step: &str, percent: u32, message: &str) { let progress = InstallProgress { step: step.to_string(), percent, message: message.to_string(), }; - let _ = window.emit("install-progress", &progress); + if let Some(window) = window { + let _ = window.emit("install-progress", &progress); + } log::info!("[{}%] {}: {}", percent, step, message); } @@ -1132,10 +1765,12 @@ struct PayloadCandidate { is_zip: bool, } -fn build_payload_candidates(window: &Window, exe_dir: &Path) -> Vec { +fn build_payload_candidates(window: Option<&Window>, exe_dir: &Path) -> Vec { let mut candidates = Vec::new(); - if let Ok(resource_dir) = window.app_handle().path().resource_dir() { + if let Some(resource_dir) = + window.and_then(|window| window.app_handle().path().resource_dir().ok()) + { candidates.push(PayloadCandidate { label: "resource_dir/payload.zip".to_string(), path: resource_dir.join("payload.zip"), @@ -1198,6 +1833,18 @@ fn find_existing_ancestor(path: &Path) -> PathBuf { /// Actual install root is always under an `OpenBitFun` directory: `{user choice}/OpenBitFun`. /// If the user already chose a path whose last segment is `OpenBitFun`, do not append again. fn with_openbitfun_install_subdir(path: PathBuf) -> PathBuf { + if path.join(transaction::INVENTORY).exists() + || path + .join(transaction::WORK_DIR) + .join("journal.json") + .is_file() + || get_existing_installation() + .install_location + .as_deref() + .is_some_and(|existing| paths_equal_for_platform(Path::new(existing), &path)) + { + return path; + } let already_openbitfun = path .file_name() .and_then(|n| n.to_str()) @@ -1213,7 +1860,7 @@ fn with_openbitfun_install_subdir(path: PathBuf) -> PathBuf { /// Stable codes for `validate_install_path` / `prepare_install_target`; localized in the frontend. const INSTALL_PATH_ERR_PREFIX: &str = "INSTALL_PATH::"; -fn prepare_install_target(requested_path: &Path) -> Result { +fn resolve_install_target(requested_path: &Path) -> Result { if !requested_path.is_absolute() { return Err(format!("{}not_absolute", INSTALL_PATH_ERR_PREFIX)); } @@ -1232,7 +1879,13 @@ fn prepare_install_target(requested_path: &Path) -> Result { if !install_path.is_dir() { return Err(format!("{}path_not_directory", INSTALL_PATH_ERR_PREFIX)); } - if directory_has_entries(&install_path)? && !install_path.join(MAIN_APP_EXE).exists() { + if directory_has_entries(&install_path)? + && !install_path.join(MAIN_APP_EXE).exists() + && !install_path + .join(transaction::WORK_DIR) + .join("journal.json") + .is_file() + { return Err(format!( "{}directory_must_be_empty_or_openbitfun", INSTALL_PATH_ERR_PREFIX @@ -1240,32 +1893,128 @@ fn prepare_install_target(requested_path: &Path) -> Result { } } - let writable_dir = if install_path.exists() { + Ok(install_path) +} + +fn prepare_install_target(requested_path: &Path) -> Result { + let install_path = resolve_install_target(requested_path)?; + let writable = if install_path.exists() { install_path.clone() } else { find_existing_ancestor(&install_path) }; - let test_file = writable_dir.join(format!("{}_install_test", hidden_data_directory())); - match std::fs::write(&test_file, "test") { - Ok(_) => { - let _ = std::fs::remove_file(&test_file); - Ok(install_path) + let probe = tempfile::Builder::new() + .prefix(".openbitfun-write-probe-") + .tempfile_in(&writable); + match probe { + Ok(_) => Ok(install_path), + Err(error) if error.raw_os_error() == Some(112) => { + Err("Insufficient disk space for maintenance staging".into()) } + Err(error) if error.kind() != std::io::ErrorKind::PermissionDenied => Err(format!( + "Cannot probe installation directory {}: {error}", + writable.display() + )), Err(_) if install_path.exists() => { - Err(format!("{}directory_not_writable", INSTALL_PATH_ERR_PREFIX)) + Err(format!("{INSTALL_PATH_ERR_PREFIX}directory_not_writable")) } - Err(_) => Err(format!("{}parent_not_writable", INSTALL_PATH_ERR_PREFIX)), + Err(_) => Err(format!("{INSTALL_PATH_ERR_PREFIX}parent_not_writable")), } } fn directory_has_entries(path: &Path) -> Result { - let mut entries = std::fs::read_dir(path) - .map_err(|_| format!("{}inspect_directory_failed", INSTALL_PATH_ERR_PREFIX))?; - Ok(entries - .next() - .transpose() - .map_err(|_| format!("{}inspect_directory_failed", INSTALL_PATH_ERR_PREFIX))? - .is_some()) + for entry in std::fs::read_dir(path) + .map_err(|_| format!("{INSTALL_PATH_ERR_PREFIX}inspect_directory_failed"))? + { + let entry = + entry.map_err(|_| format!("{INSTALL_PATH_ERR_PREFIX}inspect_directory_failed"))?; + if ![ + transaction::WORK_DIR, + transaction::LOCK_FILE, + transaction::INVENTORY, + ] + .iter() + .any(|name| entry.file_name() == std::ffi::OsStr::new(name)) + { + return Ok(true); + } + } + Ok(false) +} + +#[cfg(windows)] +fn installed_binary_version(path: &Path) -> Option { + let output = create_windows_silent_command(system_powershell().ok()?) + .args(["-NoProfile", "-NonInteractive", "-Command", "[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false); [Console]::Write([Diagnostics.FileVersionInfo]::GetVersionInfo($env:OPENBITFUN_VERSION_TARGET).ProductVersion)"]) + .env("OPENBITFUN_VERSION_TARGET", path).output().ok()?; + if !output.status.success() { + return None; + } + let version = String::from_utf8(output.stdout).ok()?.trim().to_owned(); + // Numeric Windows file versions can lose prerelease information. Only an + // exact semantic ProductVersion may override legacy registration metadata. + semver::Version::parse(&version).ok().map(|_| version) +} + +fn validate_upgrade_version(installed: &str, incoming: &str) -> Result<(), String> { + let installed = semver::Version::parse(installed).map_err(|_| { + "Installed version is unreadable; retain the installation and repair its metadata" + })?; + let incoming = semver::Version::parse(incoming).map_err(|_| "Installer version is invalid")?; + if incoming < installed { + return Err(format!("Downgrade from {installed} to {incoming} was refused to protect existing configuration and session data")); + } + Ok(()) +} + +fn required_install_space() -> Result { + let manifest = if embedded_payload_available() { + Some(read_payload_manifest_from_zip_bytes( + EMBEDDED_PAYLOAD_ZIP, + "embedded payload", + )?) + } else { + None + }; + let payload = manifest + .map(|m| { + m.files + .iter() + .map(|f| f.size.saturating_add(8192)) + .sum::() + }) + .unwrap_or(ESTIMATED_INSTALL_SIZE); + let uninstaller = std::env::current_exe() + .ok() + .and_then(|p| std::fs::metadata(p).ok()) + .map(|m| m.len()) + .unwrap_or(0); + Ok(payload + .saturating_add(uninstaller) + .saturating_add(1024 * 1024)) +} + +fn read_installed_inventory(root: &Path) -> Result, String> { + match std::fs::read(root.join(transaction::INVENTORY)) { + Ok(bytes) => { + let value: Value = serde_json::from_slice(&bytes).map_err(|e| { + format!("Installed inventory is unreadable; retained for repair: {e}") + })?; + if value.get("schema").and_then(Value::as_u64) != Some(1) + || value.get("productId").and_then(Value::as_str) != Some("openbitfun") + { + return Err("Unsupported installed inventory; no files were removed".into()); + } + let manifest: PayloadManifest = + serde_json::from_value(value).map_err(|e| e.to_string())?; + for file in &manifest.files { + transaction::relative_path(&file.path)?; + } + Ok(Some(manifest)) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(format!("Cannot read installed inventory: {e}")), + } } fn ensure_config_directory() -> Result { @@ -1702,7 +2451,11 @@ fn collect_payload_relative_paths_for_uninstall() -> Result, String> } fn collect_uninstall_targets(install_path: &Path) -> Result, String> { - let mut relative_paths = collect_payload_relative_paths_for_uninstall()?; + let mut relative_paths = match read_installed_inventory(install_path)? { + Some(manifest) => manifest.files.into_iter().map(|file| file.path).collect(), + None => collect_payload_relative_paths_for_uninstall()?, + }; + relative_paths.push(transaction::INVENTORY.into()); relative_paths.push("uninstall.exe".to_string()); let mut targets: Vec = relative_paths @@ -1717,38 +2470,6 @@ fn collect_uninstall_targets(install_path: &Path) -> Result, String Ok(targets) } -fn remove_installed_targets( - install_path: &Path, - targets: &[PathBuf], - skip_file: Option<&Path>, -) -> Result<(), String> { - for path in targets { - if skip_file - .map(|skip| paths_equal_for_platform(path, skip)) - .unwrap_or(false) - { - continue; - } - - if !path.exists() { - continue; - } - - if path.is_file() { - std::fs::remove_file(path).map_err(|e| { - format!("Failed to remove installed file {}: {}", path.display(), e) - })?; - } - } - - for dir in collect_parent_directories(install_path, targets) { - let _ = std::fs::remove_dir(&dir); - } - let _ = std::fs::remove_dir(install_path); - - Ok(()) -} - fn collect_parent_directories(root: &Path, paths: &[PathBuf]) -> Vec { let mut dirs: Vec = Vec::new(); for path in paths { @@ -1775,19 +2496,7 @@ fn collect_parent_directories(root: &Path, paths: &[PathBuf]) -> Vec { } fn sanitize_manifest_relative_path(raw: &str) -> Result { - let path = PathBuf::from(raw); - if path.is_absolute() { - return Err(format!("Manifest entry must be relative: {}", raw)); - } - - if path - .components() - .any(|component| matches!(component, std::path::Component::ParentDir)) - { - return Err(format!("Manifest entry escapes install directory: {}", raw)); - } - - Ok(path) + transaction::relative_path(raw) } fn verify_installed_payload(install_path: &Path, manifest: &PayloadManifest) -> Result<(), String> { @@ -1861,43 +2570,6 @@ fn paths_equal_for_platform(a: &Path, b: &Path) -> bool { } } -#[cfg(target_os = "windows")] -fn rollback_installation( - install_path: &Path, - install_dir_was_absent: bool, - windows_state: &WindowsInstallState, -) { - use super::registry; - use super::shortcut; - - log::warn!("Installation failed, starting rollback"); - - if windows_state.manufacturer_registered { - let _ = registry::remove_tauri_install_location(); - } - if windows_state.start_menu_shortcut_created { - let _ = shortcut::remove_start_menu_shortcut(); - } - if windows_state.desktop_shortcut_created { - let _ = shortcut::remove_desktop_shortcut(); - } - if windows_state.uninstall_registered { - let _ = registry::remove_uninstall_entry(); - } - - if install_dir_was_absent && install_path.exists() { - let _ = std::fs::remove_dir_all(install_path); - } -} - -#[cfg(not(target_os = "windows"))] -fn rollback_installation(install_path: &Path, install_dir_was_absent: bool) { - log::warn!("Installation failed, starting rollback"); - if install_dir_was_absent && install_path.exists() { - let _ = std::fs::remove_dir_all(install_path); - } -} - #[cfg(test)] mod tests { use super::{ @@ -1911,6 +2583,162 @@ mod tests { use std::io::{Cursor, Write}; use zip::write::SimpleFileOptions; + #[test] + fn upgrades_accept_repair_and_prerelease_transitions_but_refuse_downgrades() { + assert!(super::validate_upgrade_version("1.0.9", "1.0.10").is_ok()); + assert!(super::validate_upgrade_version("1.0.10", "1.0.10").is_ok()); + assert!(super::validate_upgrade_version("1.0.10-beta.1", "1.0.10").is_ok()); + assert!(super::validate_upgrade_version("1.0.10", "1.0.10-beta.1").is_err()); + assert!(super::validate_upgrade_version("2.0.0", "1.0.10").is_err()); + assert!(super::validate_upgrade_version("invalid", "1.0.10").is_err()); + } + + #[cfg(windows)] + #[test] + #[ignore = "requires OPENBITFUN_PACKAGED_VERSION_FIXTURE pointing to a built desktop executable"] + fn packaged_binary_version_prevents_unregistered_downgrades() { + let executable = std::env::var_os("OPENBITFUN_PACKAGED_VERSION_FIXTURE") + .expect("packaged executable fixture"); + let version = super::installed_binary_version(std::path::Path::new(&executable)) + .expect("built application must expose a semantic ProductVersion"); + assert_eq!(version, env!("CARGO_PKG_VERSION")); + assert!(super::validate_upgrade_version(&version, "0.0.0").is_err()); + assert!(super::validate_upgrade_version(&version, &version).is_ok()); + } + + #[test] + fn path_validation_keeps_interrupted_roots_recoverable_before_the_main_binary_is_restored() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("OpenBitFun"); + std::fs::create_dir_all(root.join(super::transaction::WORK_DIR)).unwrap(); + std::fs::write(root.join("runtime.js"), "old runtime").unwrap(); + assert!(super::validate_install_path(root.to_string_lossy().into()).is_err()); + std::fs::write( + root.join(super::transaction::WORK_DIR).join("journal.json"), + "invalid journal retained for repair", + ) + .unwrap(); + let validated = super::validate_install_path(root.to_string_lossy().into()).unwrap(); + assert_eq!(std::path::PathBuf::from(validated.install_path), root); + assert!(super::Transaction::open(&root).is_err()); + } + + #[cfg(windows)] + #[test] + fn legacy_update_flags_wait_for_the_previous_executable_to_exit() { + use std::process::Stdio; + let temp = tempfile::tempdir().unwrap(); + let ping = std::path::PathBuf::from(std::env::var_os("SYSTEMROOT").unwrap()) + .join("System32/ping.exe"); + let target = temp.path().join(MAIN_APP_EXE); + std::fs::copy(ping, &target).unwrap(); + let mut previous = super::create_windows_silent_command(&target) + .args(["-n", "2", "127.0.0.1"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + super::wait_for_legacy_update_parent(temp.path()).unwrap(); + assert!( + previous.try_wait().unwrap().is_some(), + "legacy handoff must not publish while the old executable is still running" + ); + } + + #[cfg(windows)] + #[test] + fn update_handoff_waits_for_the_parent_and_aborts_when_the_supervisor_dies() { + use std::{ + ffi::c_void, + process::{Child, Stdio}, + }; + #[link(name = "kernel32")] + extern "system" { + fn CreateEventW( + attributes: *mut c_void, + manual: i32, + initial: i32, + name: *const u16, + ) -> *mut c_void; + fn WaitForSingleObject(handle: *mut c_void, timeout: u32) -> u32; + fn CloseHandle(handle: *mut c_void) -> i32; + } + struct Process(Child); + impl Drop for Process { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + struct Event(*mut c_void); + impl Drop for Event { + fn drop(&mut self) { + unsafe { + CloseHandle(self.0); + } + } + } + let temp = tempfile::tempdir().unwrap(); + let ping = std::path::PathBuf::from(std::env::var_os("SYSTEMROOT").unwrap()) + .join("System32/ping.exe"); + let fake_app = temp.path().join(MAIN_APP_EXE); + std::fs::copy(&ping, &fake_app).unwrap(); + let spawn = |path: &std::path::Path| { + Process( + super::create_windows_silent_command(path) + .args(["-n", "30", "127.0.0.1"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(), + ) + }; + for parent_exits in [true, false] { + let mut parent = spawn(&fake_app); + let mut supervisor = spawn(&ping); + let name = format!( + "Local\\OpenBitFunUpdate-fixture-{}-{parent_exits}", + std::process::id() + ); + let wide = name + .encode_utf16() + .chain(std::iter::once(0)) + .collect::>(); + let ready = Event(unsafe { CreateEventW(std::ptr::null_mut(), 1, 0, wide.as_ptr()) }); + assert!(!ready.0.is_null()); + let args = vec![ + format!("/READY_EVENT={name}"), + format!("/WAITPID={}", parent.0.id()), + format!("/SUPERVISOR_PID={}", supervisor.0.id()), + ]; + let root = temp.path().to_path_buf(); + let waiter = std::thread::spawn(move || super::wait_for_update_parent(&args, &root)); + assert_eq!( + unsafe { WaitForSingleObject(ready.0, 10_000) }, + 0, + "native preflight must acknowledge readiness" + ); + assert!( + !waiter.is_finished(), + "readiness must not authorize replacement before parent exit" + ); + if parent_exits { + parent.0.kill().unwrap(); + parent.0.wait().unwrap(); + } else { + supervisor.0.kill().unwrap(); + supervisor.0.wait().unwrap(); + } + let result = waiter.join().unwrap(); + assert_eq!(result.is_ok(), parent_exits, "{result:?}"); + if !parent_exits { + assert!(parent.0.try_wait().unwrap().is_none()); + } + } + } + #[test] fn legacy_migration_command_reports_the_independent_distribution() { let result = diff --git a/OpenBitFun-Installer/src-tauri/src/installer/elevation.rs b/OpenBitFun-Installer/src-tauri/src/installer/elevation.rs new file mode 100644 index 0000000000..3ee1182c40 --- /dev/null +++ b/OpenBitFun-Installer/src-tauri/src/installer/elevation.rs @@ -0,0 +1,291 @@ +//! A one-shot native maintenance launch. The interactive installer stays under +//! the original user's token, including when a machine install uses another admin. +use std::{ffi::c_void, path::Path}; + +#[link(name = "advapi32")] +extern "system" { + fn OpenProcessToken(process: *mut c_void, access: u32, token: *mut *mut c_void) -> i32; + fn GetTokenInformation( + token: *mut c_void, + class: u32, + information: *mut c_void, + size: u32, + required: *mut u32, + ) -> i32; +} +#[link(name = "kernel32")] +extern "system" { + fn CloseHandle(handle: *mut c_void) -> i32; + fn WaitForSingleObject(handle: *mut c_void, timeout: u32) -> u32; + fn GetExitCodeProcess(handle: *mut c_void, code: *mut u32) -> i32; +} + +pub(super) fn is_elevated() -> bool { + let mut token = std::ptr::null_mut(); + let mut elevated = 0_u32; + let mut required = 0; + unsafe { + if OpenProcessToken(-1_isize as *mut c_void, 8, &mut token) == 0 { + return false; + } + let ok = GetTokenInformation( + token, + 20, + &mut elevated as *mut u32 as *mut c_void, + 4, + &mut required, + ); + CloseHandle(token); + ok != 0 && elevated != 0 + } +} + +pub(super) fn owner_sid() -> Result { + let output = super::commands::create_windows_silent_command( + super::commands::windows_system_directory()?.join("whoami.exe"), + ) + .args(["/user", "/fo", "csv", "/nh"]) + .output() + .map_err(|e| e.to_string())?; + let text = String::from_utf8_lossy(&output.stdout); + if !output.status.success() { + return Err("Cannot identify the maintenance owner".into()); + } + text.split([',', '"', '\r', '\n']) + .find(|part| part.starts_with("S-1-")) + .map(str::to_string) + .ok_or_else(|| "Cannot identify the maintenance owner".into()) +} + +fn quote(arg: &str) -> String { + let mut result = String::from("\""); + let mut slashes = 0; + for c in arg.chars() { + if c == '\\' { + slashes += 1; + continue; + } + result.extend(std::iter::repeat_n( + '\\', + slashes * if c == '"' { 2 } else { 1 }, + )); + slashes = 0; + if c == '"' { + result.push('\\'); + } + result.push(c); + } + result.extend(std::iter::repeat_n('\\', slashes * 2)); + result.push('"'); + result +} + +pub(super) fn run(executable: &Path, args: &[String]) -> Result { + use std::os::windows::ffi::OsStrExt; + #[link(name = "ole32")] + extern "system" { + fn CoInitializeEx(reserved: *mut c_void, flags: u32) -> i32; + fn CoUninitialize(); + } + struct Com(bool); + impl Drop for Com { + fn drop(&mut self) { + if self.0 { + unsafe { + CoUninitialize(); + } + } + } + } + let status = unsafe { CoInitializeEx(std::ptr::null_mut(), 2 | 4) }; + if status < 0 && status != -2147417850 { + return Err(format!( + "Cannot initialize maintenance authorization ({status:#x})" + )); + } + let _com = Com(status >= 0); + #[repr(C)] + struct ExecuteInfo { + size: u32, + mask: u32, + window: *mut c_void, + verb: *const u16, + file: *const u16, + parameters: *const u16, + directory: *const u16, + show: i32, + instance: *mut c_void, + id_list: *mut c_void, + class: *const u16, + class_key: *mut c_void, + hot_key: u32, + icon: *mut c_void, + process: *mut c_void, + } + #[link(name = "shell32")] + extern "system" { + fn ShellExecuteExW(info: *mut ExecuteInfo) -> i32; + } + let file = executable + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let verb = "runas" + .encode_utf16() + .chain(std::iter::once(0)) + .collect::>(); + let result_id = format!( + "{:x}{:x}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| e.to_string())? + .as_nanos() + ); + let mut arguments = vec![format!("/RESULT_ID={result_id}")]; + arguments.extend_from_slice(args); + let parameters = arguments + .iter() + .map(|arg| quote(arg)) + .collect::>() + .join(" ") + .encode_utf16() + .chain(std::iter::once(0)) + .collect::>(); + let mut info: ExecuteInfo = unsafe { std::mem::zeroed() }; + info.size = std::mem::size_of::() as u32; + info.mask = 0x40 | 0x100; + info.verb = verb.as_ptr(); + info.file = file.as_ptr(); + info.parameters = parameters.as_ptr(); + info.show = 0; + if unsafe { ShellExecuteExW(&mut info) } == 0 { + let error = std::io::Error::last_os_error(); + return Err(if error.raw_os_error() == Some(1223) { + "Administrator authorization was canceled; no files were changed".into() + } else { + format!("Cannot start elevated maintenance: {error}") + }); + } + if info.process.is_null() { + return Err("Maintenance did not return a process handle".into()); + } + let mut code = 1; + unsafe { + let waited = WaitForSingleObject(info.process, u32::MAX); + let received = GetExitCodeProcess(info.process, &mut code); + CloseHandle(info.process); + if waited != 0 || received == 0 { + return Err( + "Maintenance result is unknown; check the installation before retrying".into(), + ); + } + } + if code != 0 && code != 2 { + if let Ok(path) = native_result_path(&result_id, true) { + if let Some(error) = read_native_error(&path, code) { + return Err(error); + } + } + } + Ok(code) +} + +fn native_result_path(id: &str, machine: bool) -> Result { + if id.is_empty() || id.len() > 64 || !id.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err("Invalid native maintenance result identifier".into()); + } + let base = std::env::var_os(if machine { + "PROGRAMDATA" + } else { + "LOCALAPPDATA" + }) + .ok_or("Maintenance result directory is unavailable")?; + Ok(std::path::PathBuf::from(base) + .join(openbitfun_core_types::product_identity::data_namespace()) + .join("maintenance/results") + .join(format!("{id}.json"))) +} + +fn read_native_error(path: &Path, code: u32) -> Option { + let value: serde_json::Value = serde_json::from_slice(&std::fs::read(path).ok()?).ok()?; + if value.get("exitCode")?.as_u64()? != u64::from(code) { + return None; + } + value.get("error")?.as_str().map(str::to_owned) +} + +pub(super) fn persist_native_outcome(code: u32, error: Option<&str>) { + let Some(id) = + std::env::args().find_map(|arg| arg.strip_prefix("/RESULT_ID=").map(str::to_owned)) + else { + return; + }; + let write = || -> Result<(), String> { + use std::io::Write; + let path = native_result_path(&id, is_elevated())?; + let parent = path + .parent() + .ok_or("Maintenance result parent is unavailable")?; + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + let mut temporary = tempfile::NamedTempFile::new_in(parent).map_err(|e| e.to_string())?; + temporary + .write_all( + &serde_json::to_vec(&serde_json::json!({"exitCode":code,"error":error})) + .map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?; + temporary.as_file().sync_all().map_err(|e| e.to_string())?; + temporary.persist(path).map_err(|e| e.to_string())?; + Ok(()) + }; + if let Err(error) = write() { + eprintln!("Cannot persist native maintenance result: {error}"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn native_errors_require_a_matching_exit_code_and_safe_operation_id() { + for id in ["", "../outside", "C:\\Windows", "not-hex"] { + assert!(native_result_path(id, true).is_err()); + } + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("result.json"); + std::fs::write( + &path, + br#"{"exitCode":1,"error":"Cannot back up a locked file; previous files restored"}"#, + ) + .unwrap(); + assert!(read_native_error(&path, 1).unwrap().contains("locked file")); + assert!(read_native_error(&path, 2).is_none()); + std::fs::write(&path, b"unreadable").unwrap(); + assert!(read_native_error(&path, 1).is_none()); + assert_eq!(std::fs::read(&path).unwrap(), b"unreadable"); + } + #[test] + fn quotes_paths_and_arguments_without_command_line_injection() { + let values = [ + r"C:\Program Files\OpenBitFun", + "a\"b", + "trailing\\", + "", + "中文目录", + ]; + let command = format!( + "program.exe {}", + values + .iter() + .map(|arg| quote(arg)) + .collect::>() + .join(" ") + ); + assert_eq!( + &super::super::commands::parse_windows_command_line(&command).unwrap()[1..], + values + ); + } +} diff --git a/OpenBitFun-Installer/src-tauri/src/installer/extract.rs b/OpenBitFun-Installer/src-tauri/src/installer/extract.rs index 6d343c899b..fd7e706e63 100644 --- a/OpenBitFun-Installer/src-tauri/src/installer/extract.rs +++ b/OpenBitFun-Installer/src-tauri/src/installer/extract.rs @@ -2,7 +2,7 @@ use anyhow::{Context, Result}; use std::fs; use std::io; use std::io::Cursor; -use std::path::{Path, PathBuf}; +use std::path::Path; /// Estimated install size in bytes (~200MB for typical Tauri app with WebView) pub(super) const ESTIMATED_INSTALL_SIZE: u64 = 200 * 1024 * 1024; @@ -38,9 +38,9 @@ fn extract_zip_archive( ) -> Result<()> { for i in 0..archive.len() { let mut file = archive.by_index(i)?; - let rel_path: PathBuf = file - .mangled_name() - .context("Failed to decode zip entry path")?; + let name = file.name().context("Failed to decode zip entry path")?; + let name = name.trim_end_matches('/'); + let rel_path = super::transaction::relative_path(name).map_err(anyhow::Error::msg)?; if !should_extract(&rel_path) { continue; } @@ -88,6 +88,12 @@ fn copy_directory_internal( let rel = relative_prefix.join(&name); let dest = target.join(&name); + anyhow::ensure!( + !file_type.is_symlink(), + "Payload contains a symbolic link: {}", + entry.path().display() + ); + if file_type.is_dir() { bytes_copied += copy_directory_internal(&entry.path(), &dest, &rel, should_copy_file)?; } else { diff --git a/OpenBitFun-Installer/src-tauri/src/installer/mod.rs b/OpenBitFun-Installer/src-tauri/src/installer/mod.rs index c918bb1ecb..66cbb8e3cc 100644 --- a/OpenBitFun-Installer/src-tauri/src/installer/mod.rs +++ b/OpenBitFun-Installer/src-tauri/src/installer/mod.rs @@ -1,7 +1,10 @@ mod ai_config; pub(super) mod commands; +#[cfg(windows)] +mod elevation; mod extract; mod generated_locale_contract; +mod transaction; mod types; /// Windows main binary file name — must match `src/apps/desktop` `[[bin]]` and Tauri NSIS output. diff --git a/OpenBitFun-Installer/src-tauri/src/installer/registry.rs b/OpenBitFun-Installer/src-tauri/src/installer/registry.rs index 4e367c2b00..909c332550 100644 --- a/OpenBitFun-Installer/src-tauri/src/installer/registry.rs +++ b/OpenBitFun-Installer/src-tauri/src/installer/registry.rs @@ -51,21 +51,27 @@ pub(super) fn register_uninstall_entry( version: &str, uninstall_command: &str, ) -> Result<()> { - let hkcu = RegKey::predef(HKEY_CURRENT_USER); + let hkcu = root_for(install_path); + ensure_registration_owner(&hkcu, install_path)?; let (key, _) = hkcu .create_subkey(UNINSTALL_KEY) .with_context(|| "Failed to create uninstall registry key")?; let exe_path = install_path.join(MAIN_APP_EXE); + // Establish ownership before optional metadata so interrupted first-time + // registration remains recognizable during journal recovery. + key.set_value("InstallLocation", "e_windows_path(install_path))?; key.set_value("DisplayName", &APP_NAME)?; key.set_value("DisplayVersion", &version)?; key.set_value("Publisher", &TAURI_MANUFACTURER)?; key.set_value("MainBinaryName", &MAIN_APP_EXE)?; - key.set_value("InstallLocation", "e_windows_path(install_path))?; key.set_value("DisplayIcon", "e_windows_path(&exe_path))?; key.set_value("UninstallString", &uninstall_command)?; - key.set_value("QuietUninstallString", &uninstall_command)?; + key.set_value( + "QuietUninstallString", + &format!("{uninstall_command} --quiet"), + )?; key.set_value("NoModify", &1u32)?; key.set_value("NoRepair", &1u32)?; @@ -75,7 +81,8 @@ pub(super) fn register_uninstall_entry( /// Same as Tauri NSIS `WriteRegStr SHCTX "${MANUPRODUCTKEY}" "" $INSTDIR` — used for default install dir / upgrades. pub(super) fn register_tauri_install_location(install_path: &Path) -> Result<()> { - let hkcu = RegKey::predef(HKEY_CURRENT_USER); + let hkcu = root_for(install_path); + ensure_registration_owner(&hkcu, install_path)?; let path = tauri_manufacturer_product_key(); let (key, _) = hkcu .create_subkey(&path) @@ -95,47 +102,525 @@ pub(super) fn read_tauri_install_location() -> Option { normalize_registry_path(&s) } -/// Remove `MANUPRODUCTKEY` (HKCU and HKLM, matching Tauri NSIS `SHCTX` per-user / per-machine). -pub(super) fn remove_tauri_install_location() -> Result<()> { - let path = tauri_manufacturer_product_key(); - let hkcu = RegKey::predef(HKEY_CURRENT_USER); - if hkcu.delete_subkey_all(&path).is_ok() { - log::info!("Removed HKCU Tauri install location key {}", path); +/// Remove only keys that still point to the selected installation. +pub(super) fn remove_tauri_install_location(install_path: &Path) -> Result<()> { + remove_owned_key(install_path, &tauri_manufacturer_product_key(), "") +} +pub(super) fn remove_uninstall_entry(install_path: &Path) -> Result<()> { + remove_owned_key(install_path, UNINSTALL_KEY, "InstallLocation") +} +fn remove_owned_key(install_path: &Path, path: &str, value: &str) -> Result<()> { + let root = root_for(install_path); + delete_owned_key(&root, install_path, path, value) +} + +fn delete_owned_key(root: &RegKey, install_path: &Path, path: &str, value: &str) -> Result<()> { + match root.open_subkey(path) { + Ok(key) => { + let existing: String = key.get_value(value)?; + if path_matches(&existing, install_path) { + root.delete_subkey_all(path)?; + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), } - let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); - if hklm.delete_subkey_all(&path).is_ok() { - log::info!("Removed HKLM Tauri install location key {}", path); + Ok(()) +} +pub(super) fn remove_autostart_run_entry(install_path: &Path) -> Result<()> { + let root = maintenance_user_root()?; + let key = match root.open_subkey_with_flags( + r"Software\Microsoft\Windows\CurrentVersion\Run", + KEY_READ | KEY_WRITE, + ) { + Ok(key) => key, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + }; + match key.get_value::(APP_NAME) { + Ok(value) => { + if command_targets(&value, install_path) { + key.delete_value(APP_NAME)?; + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), } Ok(()) } +pub(super) fn is_machine_install(path: &Path) -> bool { + !read_uninstall_key(RegKey::predef(HKEY_CURRENT_USER), "hkcu") + .is_some_and(|v| path_matches(&v.install_location, path)) + && (read_uninstall_key(RegKey::predef(HKEY_LOCAL_MACHINE), "hklm") + .is_some_and(|v| path_matches(&v.install_location, path)) + || std::fs::read(path.join(super::transaction::INVENTORY)) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .is_some_and(|value| { + value.get("allUsers").and_then(serde_json::Value::as_bool) == Some(true) + }) + // The inventory and registration may already be backed up when an + // interrupted machine uninstall re-enters maintenance. + || std::fs::read(path.join(super::transaction::WORK_DIR).join("journal.json")) + .ok().and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .is_some_and(|value| value.get("schema").and_then(serde_json::Value::as_u64) == Some(1) + && value.pointer("/integrations/registry/machine").and_then(serde_json::Value::as_bool) == Some(true))) +} -/// Remove Add/Remove Programs entry — same subkey as Tauri NSIS `UNINSTKEY` (per-user and per-machine). -pub(super) fn remove_uninstall_entry() -> Result<()> { - let hkcu = RegKey::predef(HKEY_CURRENT_USER); - if hkcu.delete_subkey_all(UNINSTALL_KEY).is_ok() { - log::info!("Removed HKCU uninstall key {}", UNINSTALL_KEY); +pub(super) fn validate_scope(path: &Path, all_users: bool) -> Result<()> { + if all_users + && read_uninstall_key(RegKey::predef(HKEY_CURRENT_USER), "hkcu") + .is_some_and(|value| path_matches(&value.install_location, path)) + { + anyhow::bail!("This directory is a current-user installation; retain its scope or uninstall before changing scope"); } - let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); - if hklm.delete_subkey_all(UNINSTALL_KEY).is_ok() { - log::info!("Removed HKLM uninstall key {}", UNINSTALL_KEY); + Ok(()) +} + +pub(super) fn installed_version(path: &Path) -> Option { + [HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE] + .into_iter() + .filter_map(|hive| read_uninstall_key(RegKey::predef(hive), "registry")) + .find(|value| path_matches(&value.install_location, path)) + .and_then(|value| value.display_version) +} +fn root_for(path: &Path) -> RegKey { + RegKey::predef(if is_machine_install(path) { + HKEY_LOCAL_MACHINE + } else { + HKEY_CURRENT_USER + }) +} +fn path_matches(value: &str, path: &Path) -> bool { + normalize_registry_path(value) + .is_some_and(|v| super::commands::windows_path_eq_case_insensitive(Path::new(&v), path)) +} +fn command_targets(value: &str, path: &Path) -> bool { + let value = value.trim(); + let program = if let Some(v) = value.strip_prefix('"') { + v.split('"').next().unwrap_or("") + } else { + value.split_whitespace().next().unwrap_or("") + }; + path_matches(program, &path.join(MAIN_APP_EXE)) +} +fn ensure_registration_owner(root: &RegKey, path: &Path) -> Result<()> { + for (name, value) in [ + (UNINSTALL_KEY.to_string(), "InstallLocation"), + (tauri_manufacturer_product_key(), ""), + ] { + match root.open_subkey(name) { + Ok(key) => { + let existing: String = key.get_value(value).context( + "Cannot establish existing installation ownership; registry data was preserved", + )?; + anyhow::ensure!(path_matches(&existing, path), "An installation is registered at {existing}; select that directory to update or uninstall it first"); + // Older desktop builds rewrote the manufacturer location even + // in development. The owned uninstall record is authoritative; + // a successful maintenance transaction repairs that stale hint. + return Ok(()); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } } Ok(()) } -/// NSIS `DeleteRegValue HKCU ... Run "${PRODUCTNAME}"` — align uninstall with Tauri NSIS. -pub(super) fn remove_autostart_run_entry() -> Result<()> { - let hkcu = RegKey::predef(HKEY_CURRENT_USER); - let key = hkcu.open_subkey_with_flags( - r"Software\Microsoft\Windows\CurrentVersion\Run", - KEY_READ | KEY_WRITE, +#[derive(serde::Serialize, serde::Deserialize)] +pub(super) struct Snapshot { + machine: bool, + keys: Vec)>>>, + #[serde(default)] + user_sid: Option, + #[serde(default)] + user_values: Vec<(String, String, Option<(u32, Vec)>)>, + #[serde(default)] + context_keys: Vec<(String, Vec<(String, Vec<(String, u32, Vec)>)>)>, +} + +const CONTEXT_KEYS: [&str; 2] = [ + r"Software\Classes\Directory\Background\shell\OpenBitFun", + r"Software\Classes\Directory\shell\OpenBitFun", +]; + +fn maintenance_user_sid() -> Result { + let sid = std::env::args() + .find_map(|arg| arg.strip_prefix("/OWNER_SID=").map(str::to_string)) + .map(Ok) + .unwrap_or_else(super::elevation::owner_sid) + .map_err(anyhow::Error::msg)?; + anyhow::ensure!( + sid.starts_with("S-1-") + && sid + .bytes() + .all(|b| b.is_ascii_digit() || b == b'S' || b == b'-'), + "Invalid maintenance user SID" ); - if let Ok(key) = key { - let _ = key.delete_value(APP_NAME); - log::info!("Removed Run registry value for {}", APP_NAME); + Ok(sid) +} + +fn user_root(sid: &str) -> Result { + Ok(RegKey::predef(HKEY_USERS).open_subkey_with_flags(sid, KEY_READ | KEY_WRITE)?) +} + +fn maintenance_user_root() -> Result { + user_root(&maintenance_user_sid()?) +} + +fn snapshot_tree( + key: &RegKey, + relative: &str, + values: &mut Vec<(String, Vec<(String, u32, Vec)>)>, +) -> Result<()> { + values.push(( + relative.into(), + key.enum_values() + .map(|v| v.map(|(n, v)| (n, v.vtype as u32, v.bytes))) + .collect::>()?, + )); + for child in key.enum_keys() { + let child = child?; + let path = if relative.is_empty() { + child.clone() + } else { + format!(r"{relative}\{child}") + }; + snapshot_tree(&key.open_subkey(&child)?, &path, values)?; + } + Ok(()) +} + +fn raw_value(kind: u32, bytes: &[u8]) -> Result { + let vtype = match kind { + 0 => REG_NONE, + 1 => REG_SZ, + 2 => REG_EXPAND_SZ, + 3 => REG_BINARY, + 4 => REG_DWORD, + 5 => REG_DWORD_BIG_ENDIAN, + 6 => REG_LINK, + 7 => REG_MULTI_SZ, + 8 => REG_RESOURCE_LIST, + 9 => REG_FULL_RESOURCE_DESCRIPTOR, + 10 => REG_RESOURCE_REQUIREMENTS_LIST, + 11 => REG_QWORD, + _ => anyhow::bail!("Unsupported registry recovery value type"), + }; + Ok(winreg::RegValue { + vtype, + bytes: bytes.to_vec(), + }) +} + +pub(super) fn snapshot(path: &Path, machine: bool) -> Result { + let install_path = path; + let root = RegKey::predef(if machine { + HKEY_LOCAL_MACHINE + } else { + HKEY_CURRENT_USER + }); + ensure_registration_owner(&root, path)?; + let mut keys = Vec::new(); + for name in [UNINSTALL_KEY.to_string(), tauri_manufacturer_product_key()] { + keys.push(match root.open_subkey(name) { + Ok(key) => Some( + key.enum_values() + .map(|item| item.map(|(name, value)| (name, value.vtype as u32, value.bytes))) + .collect::>>()?, + ), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => return Err(e.into()), + }); + } + let sid = maintenance_user_sid()?; + let user = user_root(&sid)?; + let mut user_values = Vec::new(); + for (path, name) in [ + ("Environment", "Path"), + (r"Software\Microsoft\Windows\CurrentVersion\Run", APP_NAME), + ] { + let value = match user + .open_subkey(path) + .and_then(|key| key.get_raw_value(name)) + { + Ok(value) => Some((value.vtype as u32, value.bytes)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => return Err(e.into()), + }; + if let Some((kind, bytes)) = &value { + use winreg::types::FromRegValue; + let text = String::from_reg_value(&raw_value(*kind, bytes)?)?; + let owned = if name == "Path" { + text.split(';').any(|part| path_matches(part, install_path)) + } else { + command_targets(&text, install_path) + }; + if owned { + user_values.push((path.into(), name.into(), value)); + } + } + } + let mut context_keys = Vec::new(); + for path in CONTEXT_KEYS { + match user.open_subkey(path) { + Ok(key) => { + let command = key.open_subkey("command")?.get_value::("")?; + if !command_targets(&command, install_path) { + continue; + } + let mut tree = Vec::new(); + snapshot_tree(&key, "", &mut tree)?; + context_keys.push((path.into(), tree)); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e.into()), + } + } + Ok(Snapshot { + machine, + keys, + user_sid: Some(sid), + user_values, + context_keys, + }) +} + +pub(super) fn restore(install_path: &Path, snapshot: &Snapshot) -> Result<()> { + anyhow::ensure!( + snapshot.keys.len() == 2, + "Invalid registry recovery snapshot" + ); + let root = RegKey::predef(if snapshot.machine { + HKEY_LOCAL_MACHINE + } else { + HKEY_CURRENT_USER + }); + for ((path, name), original) in [ + (UNINSTALL_KEY.to_string(), "InstallLocation"), + (tauri_manufacturer_product_key(), ""), + ] + .into_iter() + .zip(&snapshot.keys) + { + match root.open_subkey(&path) { + Ok(key) => match key.get_value::(name) { + Ok(value) => anyhow::ensure!( + path_matches(&value, install_path) + || original.as_ref().is_some_and(|values| { + values.iter().find(|(key, _, _)| key == name).is_some_and( + |(_, kind, bytes)| { + use winreg::types::FromRegValue; + raw_value(*kind, bytes) + .ok() + .and_then(|value| String::from_reg_value(&value).ok()) + .is_some_and(|previous| previous == value) + }, + ) + }), + "Registration changed during maintenance; recovery data retained" + ), + Err(error) + if error.kind() == std::io::ErrorKind::NotFound + && original.is_none() + && key.enum_values().next().is_none() => {} + Err(error) => return Err(error.into()), + }, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + for (path, values) in [UNINSTALL_KEY.to_string(), tauri_manufacturer_product_key()] + .iter() + .zip(&snapshot.keys) + { + if let Some(values) = values { + let (key, _) = root.create_subkey(path)?; + let names = key + .enum_values() + .map(|v| v.map(|(n, _)| n)) + .collect::>>()?; + for name in names { + key.delete_value(name)?; + } + for (name, kind, bytes) in values { + let vtype = match kind { + 0 => REG_NONE, + 1 => REG_SZ, + 2 => REG_EXPAND_SZ, + 3 => REG_BINARY, + 4 => REG_DWORD, + 5 => REG_DWORD_BIG_ENDIAN, + 6 => REG_LINK, + 7 => REG_MULTI_SZ, + 8 => REG_RESOURCE_LIST, + 9 => REG_FULL_RESOURCE_DESCRIPTOR, + 10 => REG_RESOURCE_REQUIREMENTS_LIST, + 11 => REG_QWORD, + _ => anyhow::bail!("Unsupported registry recovery value type"), + }; + key.set_raw_value( + name, + &winreg::RegValue { + bytes: bytes.clone(), + vtype, + }, + )?; + } + } else { + match root.delete_subkey_all(path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e.into()), + } + } + } + if let Some(sid) = &snapshot.user_sid { + anyhow::ensure!( + sid == &maintenance_user_sid()?, + "Registry recovery belongs to another user" + ); + let user = user_root(sid)?; + for (path, name, value) in &snapshot.user_values { + anyhow::ensure!( + (path == "Environment" && name == "Path") + || (path == r"Software\Microsoft\Windows\CurrentVersion\Run" + && name == APP_NAME), + "Invalid user registry recovery path" + ); + if let Some((kind, bytes)) = value { + use winreg::types::FromRegValue; + let (key, _) = user.create_subkey(path)?; + let original = raw_value(*kind, bytes)?; + let current = match key.get_raw_value(name) { + Ok(value) => Some(value), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(error.into()), + }; + if let Some(current) = current { + if current.bytes == original.bytes && current.vtype == original.vtype { + continue; + } + anyhow::ensure!( + name == "Path", + "Autostart changed during maintenance; recovery data retained" + ); + let previous = String::from_reg_value(&original)?; + let present = String::from_reg_value(¤t)?; + let merged = merge_owned_path(&previous, &present, install_path); + let bytes = merged + .encode_utf16() + .chain(std::iter::once(0)) + .flat_map(u16::to_le_bytes) + .collect(); + key.set_raw_value( + name, + &winreg::RegValue { + bytes, + vtype: current.vtype, + }, + )?; + } else { + key.set_raw_value(name, &original)?; + } + } + } + for (path, tree) in &snapshot.context_keys { + anyhow::ensure!( + CONTEXT_KEYS.contains(&path.as_str()), + "Invalid context-menu recovery path" + ); + if let Ok(key) = user.open_subkey(format!(r"{path}\command")) { + let value: String = key.get_value("")?; + anyhow::ensure!( + command_targets(&value, install_path), + "Context menu changed during maintenance; recovery data retained" + ); + } + for (relative, values) in tree { + anyhow::ensure!( + !relative.split('\\').any(|part| part == ".." || part == ".") + && !relative.starts_with('\\'), + "Invalid context-menu recovery subkey" + ); + let (key, _) = user.create_subkey(if relative.is_empty() { + path.clone() + } else { + format!(r"{path}\{relative}") + })?; + for (name, kind, bytes) in values { + key.set_raw_value(name, &raw_value(*kind, bytes)?)?; + } + } + } } Ok(()) } +fn merge_owned_path(previous: &str, present: &str, install_path: &Path) -> String { + let mut merged = present.to_string(); + if !present + .split(';') + .any(|part| path_matches(part, install_path)) + { + if let Some(owned) = previous + .split(';') + .find(|part| path_matches(part, install_path)) + { + if !merged.is_empty() && !merged.ends_with(';') { + merged.push(';'); + } + merged.push_str(owned); + } + } + merged +} + +#[cfg(test)] +mod maintenance_tests { + use super::*; + #[test] + fn path_recovery_preserves_concurrent_changes_and_is_idempotent() { + let root = Path::new(r"C:\OpenBitFun"); + let recovered = merge_owned_path(r"C:\old;C:\OpenBitFun", r"C:\new;C:\other", root); + assert_eq!(recovered, r"C:\new;C:\other;C:\OpenBitFun"); + assert_eq!( + merge_owned_path(r"C:\old;C:\OpenBitFun", &recovered, root), + recovered + ); + } + #[test] + fn registry_removal_requires_exact_installation_ownership() { + let name = format!( + r"Software\OpenBitFunMaintenanceTests\{}", + std::process::id() + ); + let parent = RegKey::predef(HKEY_CURRENT_USER); + let (fixture, _) = parent.create_subkey(&name).unwrap(); + let (key, _) = fixture.create_subkey("Install").unwrap(); + key.set_value("InstallLocation", &r"C:\other").unwrap(); + drop(key); + delete_owned_key( + &fixture, + Path::new(r"C:\ours"), + "Install", + "InstallLocation", + ) + .unwrap(); + assert!(fixture.open_subkey("Install").is_ok()); + delete_owned_key( + &fixture, + Path::new(r"C:\other"), + "Install", + "InstallLocation", + ) + .unwrap(); + assert!(fixture.open_subkey("Install").is_err()); + drop(fixture); + parent.delete_subkey_all(&name).unwrap(); + } +} + /// Data read from `Uninstall\OpenBitFun` (Tauri NSIS / this installer). #[derive(Debug, Clone)] pub(super) struct UninstallRegistryData { @@ -165,27 +650,59 @@ pub(super) fn read_existing_install_from_uninstall_registry() -> Option Result<()> { - let hkcu = RegKey::predef(HKEY_CURRENT_USER); - let _ = hkcu.delete_subkey_all(r"Software\Classes\Directory\Background\shell\OpenBitFun"); - let _ = hkcu.delete_subkey_all(r"Software\Classes\Directory\shell\OpenBitFun"); +pub(super) fn remove_context_menu(install_path: &Path) -> Result<()> { + let hkcu = maintenance_user_root()?; + for path in CONTEXT_KEYS { + match hkcu.open_subkey(format!("{path}\\command")) { + Ok(key) => { + let value = key.get_value::("")?; + if command_targets(&value, install_path) { + hkcu.delete_subkey_all(path)?; + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } Ok(()) } /// Remove the install path from the user's PATH environment variable. pub(super) fn remove_from_path(install_path: &Path) -> Result<()> { - let hkcu = RegKey::predef(HKEY_CURRENT_USER); - let env_key = hkcu.open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE)?; + let hkcu = maintenance_user_root()?; + let env_key = match hkcu.open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE) { + Ok(key) => key, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => return Err(e.into()), + }; - let current_path: String = env_key.get_value("Path").unwrap_or_default(); + let raw = match env_key.get_raw_value("Path") { + Ok(raw) => raw, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => return Err(e.into()), + }; + let current_path: String = env_key.get_value("Path")?; let install_dir = install_path.to_string_lossy(); let new_path: String = current_path .split(';') - .filter(|p| !p.eq_ignore_ascii_case(&install_dir)) + .filter(|p| !path_matches(p.trim(), Path::new(install_dir.as_ref()))) .collect::>() .join(";"); - env_key.set_value("Path", &new_path)?; + if new_path != current_path { + let bytes = new_path + .encode_utf16() + .chain(std::iter::once(0)) + .flat_map(u16::to_le_bytes) + .collect(); + env_key.set_raw_value( + "Path", + &winreg::RegValue { + bytes, + vtype: raw.vtype, + }, + )?; + } Ok(()) } diff --git a/OpenBitFun-Installer/src-tauri/src/installer/shortcut.rs b/OpenBitFun-Installer/src-tauri/src/installer/shortcut.rs index b61fff76f4..e4a7820151 100644 --- a/OpenBitFun-Installer/src-tauri/src/installer/shortcut.rs +++ b/OpenBitFun-Installer/src-tauri/src/installer/shortcut.rs @@ -10,7 +10,7 @@ const NESTED_START_MENU_DIR: &str = "OpenBitFun"; /// Create a desktop shortcut for OpenBitFun. pub(super) fn create_desktop_shortcut(install_path: &Path) -> Result<()> { - let desktop = dirs::desktop_dir().with_context(|| "Cannot find Desktop directory")?; + let desktop = desktop_dir(install_path)?; let shortcut_path = desktop.join(SHORTCUT_NAME); let exe_path = install_path.join(MAIN_APP_EXE); @@ -21,8 +21,8 @@ pub(super) fn create_desktop_shortcut(install_path: &Path) -> Result<()> { /// Create a Start Menu shortcut for OpenBitFun. pub(super) fn create_start_menu_shortcut(install_path: &Path) -> Result<()> { - let start_menu = get_start_menu_dir()?; - remove_nested_start_menu_shortcut(&start_menu)?; + let start_menu = get_start_menu_dir(install_path)?; + remove_nested_start_menu_shortcut(&start_menu, install_path)?; let shortcut_path = start_menu.join(SHORTCUT_NAME); let exe_path = install_path.join(MAIN_APP_EXE); @@ -32,31 +32,38 @@ pub(super) fn create_start_menu_shortcut(install_path: &Path) -> Result<()> { } /// Remove desktop shortcut. -pub(super) fn remove_desktop_shortcut() -> Result<()> { - if let Some(desktop) = dirs::desktop_dir() { - let shortcut_path = desktop.join(SHORTCUT_NAME); - if shortcut_path.exists() { - std::fs::remove_file(&shortcut_path)?; - } - } - Ok(()) +pub(super) fn remove_desktop_shortcut(install_path: &Path) -> Result<()> { + remove_owned_shortcut( + &desktop_dir(install_path)?.join(SHORTCUT_NAME), + install_path, + ) } /// Remove Start Menu shortcut, including the nested folder layout. -pub(super) fn remove_start_menu_shortcut() -> Result<()> { - let start_menu = get_start_menu_dir()?; +pub(super) fn remove_start_menu_shortcut(install_path: &Path) -> Result<()> { + let start_menu = get_start_menu_dir(install_path)?; let shortcut_path = start_menu.join(SHORTCUT_NAME); - if shortcut_path.exists() { - std::fs::remove_file(&shortcut_path)?; - } - remove_nested_start_menu_shortcut(&start_menu)?; + remove_owned_shortcut(&shortcut_path, install_path)?; + remove_nested_start_menu_shortcut(&start_menu, install_path)?; Ok(()) } /// Get the current user's Start Menu Programs directory. -fn get_start_menu_dir() -> Result { - let appdata = - std::env::var("APPDATA").with_context(|| "APPDATA environment variable not set")?; +fn desktop_dir(install_path: &Path) -> Result { + if super::registry::is_machine_install(install_path) { + Ok(PathBuf::from(std::env::var("PUBLIC")?).join("Desktop")) + } else { + dirs::desktop_dir().with_context(|| "Cannot find Desktop directory") + } +} + +fn get_start_menu_dir(install_path: &Path) -> Result { + let appdata = std::env::var(if super::registry::is_machine_install(install_path) { + "PROGRAMDATA" + } else { + "APPDATA" + }) + .with_context(|| "Start Menu location is unavailable")?; Ok(PathBuf::from(appdata) .join("Microsoft") .join("Windows") @@ -64,16 +71,130 @@ fn get_start_menu_dir() -> Result { .join("Programs")) } -fn remove_nested_start_menu_shortcut(start_menu: &Path) -> Result<()> { - let nested_dir = start_menu.join(NESTED_START_MENU_DIR); - if nested_dir.exists() { - std::fs::remove_dir_all(&nested_dir)?; +fn remove_nested_start_menu_shortcut(start_menu: &Path, install_path: &Path) -> Result<()> { + let nested = start_menu.join(NESTED_START_MENU_DIR); + remove_owned_shortcut(&nested.join(SHORTCUT_NAME), install_path)?; + // Preserve user-created shortcuts and subfolders. + if nested.try_exists()? && std::fs::read_dir(&nested)?.next().is_none() { + std::fs::remove_dir(nested)?; + } + Ok(()) +} + +fn shortcut_target(path: &Path) -> Result { + let output = super::commands::create_windows_silent_command(super::commands::system_powershell().map_err(anyhow::Error::msg)?) + .args(["-NoProfile", "-NonInteractive", "-Command", "[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false); $s=New-Object -ComObject WScript.Shell; [Console]::Out.Write($s.CreateShortcut($env:OPENBITFUN_SHORTCUT_PATH).TargetPath)"]) + .env("OPENBITFUN_SHORTCUT_PATH", path).output()?; + anyhow::ensure!( + output.status.success(), + "Cannot inspect shortcut {}", + path.display() + ); + Ok(PathBuf::from(String::from_utf8(output.stdout)?.trim())) +} + +fn remove_owned_shortcut(path: &Path, install_path: &Path) -> Result<()> { + if path.try_exists()? + && super::commands::windows_path_eq_case_insensitive( + &shortcut_target(path)?, + &install_path.join(MAIN_APP_EXE), + ) + { + std::fs::remove_file(path)?; + } + Ok(()) +} + +#[derive(serde::Serialize, serde::Deserialize)] +pub(super) struct Snapshot { + files: Vec>>, + machine: bool, +} + +pub(super) fn snapshot(_install_path: &Path, machine: bool) -> Result { + let start = PathBuf::from(std::env::var(if machine { + "PROGRAMDATA" + } else { + "APPDATA" + })?) + .join("Microsoft/Windows/Start Menu/Programs"); + let desktop = if machine { + PathBuf::from(std::env::var("PUBLIC")?).join("Desktop") + } else { + dirs::desktop_dir().with_context(|| "Desktop is unavailable")? + }; + let paths = [ + desktop.join(SHORTCUT_NAME), + start.join(SHORTCUT_NAME), + start.join(NESTED_START_MENU_DIR).join(SHORTCUT_NAME), + ]; + let files = paths + .iter() + .map(|path| match std::fs::read(path) { + Ok(bytes) => Ok(Some(bytes)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e), + }) + .collect::>>()?; + Ok(Snapshot { files, machine }) +} + +pub(super) fn restore(install_path: &Path, snapshot: &Snapshot) -> Result<()> { + anyhow::ensure!( + snapshot.files.len() == 3, + "Invalid shortcut recovery snapshot" + ); + let start = if snapshot.machine { + PathBuf::from(std::env::var("PROGRAMDATA")?).join("Microsoft/Windows/Start Menu/Programs") + } else { + PathBuf::from(std::env::var("APPDATA")?).join("Microsoft/Windows/Start Menu/Programs") + }; + let desktop = if snapshot.machine { + PathBuf::from(std::env::var("PUBLIC")?).join("Desktop") + } else { + dirs::desktop_dir().with_context(|| "Desktop is unavailable")? + }; + let paths = [ + desktop.join(SHORTCUT_NAME), + start.join(SHORTCUT_NAME), + start.join(NESTED_START_MENU_DIR).join(SHORTCUT_NAME), + ]; + for (path, bytes) in paths.iter().zip(&snapshot.files) { + if let Some(bytes) = bytes { + if std::fs::read(path).is_ok_and(|current| current == *bytes) { + continue; + } + if path.try_exists()? { + anyhow::ensure!( + super::commands::windows_path_eq_case_insensitive( + &shortcut_target(path)?, + &install_path.join(MAIN_APP_EXE) + ), + "Shortcut changed during maintenance; recovery data retained: {}", + path.display() + ); + } + std::fs::create_dir_all(path.parent().unwrap())?; + std::fs::write(path, bytes)?; + } else { + remove_owned_shortcut(path, install_path)?; + } } Ok(()) } /// Create a .lnk shortcut file using the mslnk crate. fn create_lnk(shortcut_path: &Path, target: &Path, _working_dir: &Path) -> Result<()> { + if shortcut_path.try_exists()? { + anyhow::ensure!( + super::commands::windows_path_eq_case_insensitive( + &shortcut_target(shortcut_path)?, + target + ), + "Shortcut belongs to another installation: {}", + shortcut_path.display() + ); + } let lnk = mslnk::ShellLink::new(target) .with_context(|| format!("Failed to create shell link for {}", target.display()))?; diff --git a/OpenBitFun-Installer/src-tauri/src/installer/transaction.rs b/OpenBitFun-Installer/src-tauri/src/installer/transaction.rs new file mode 100644 index 0000000000..256525338b --- /dev/null +++ b/OpenBitFun-Installer/src-tauri/src/installer/transaction.rs @@ -0,0 +1,595 @@ +//! Same-volume, journaled file changes for the standalone maintenance host. +//! Backups are never owned by an automatically deleted temporary directory. + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Write}; +use std::path::{Component, Path, PathBuf}; + +pub(super) const WORK_DIR: &str = ".openbitfun-maintenance"; +pub(super) const LOCK_FILE: &str = ".openbitfun-maintenance.lock"; +pub(super) const INVENTORY: &str = ".openbitfun-installed.json"; + +#[derive(Serialize, Deserialize)] +struct Entry { + path: String, + original: bool, + replacement: Option, +} + +#[derive(Serialize, Deserialize)] +struct Journal { + schema: u32, + committed: bool, + entries: Vec, + #[serde(default)] + integrations: Option, +} + +pub(super) struct Transaction { + root: PathBuf, + work: PathBuf, + _lock: File, + journal: Journal, + restore_integrations: fn(&Path, &serde_json::Value) -> Result<(), String>, +} + +pub(super) fn relative_path(raw: &str) -> Result { + let path = Path::new(raw); + // Backslashes are separators in Windows payloads even on a Unix test host. + if raw.is_empty() || raw.contains(':') || raw.starts_with(['/', '\\']) { + return Err(format!("Invalid installed file path: {raw}")); + } + let normalized = raw.replace('\\', "/"); + #[cfg(windows)] + if normalized.split('/').any(|part| { + let stem = part.split('.').next().unwrap_or("").to_ascii_uppercase(); + part.ends_with([' ', '.']) + || part.chars().any(|c| c.is_control()) + || matches!( + stem.as_str(), + "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$" + ) + || stem + .strip_prefix("COM") + .or_else(|| stem.strip_prefix("LPT")) + .is_some_and(|number| { + number.len() == 1 && matches!(number.as_bytes()[0], b'1'..=b'9') + }) + }) { + return Err(format!("Ambiguous Windows installed file path: {raw}")); + } + if normalized + .split('/') + .any(|part| part.is_empty() || part == "." || part == "..") + || path + .components() + .any(|part| !matches!(part, Component::Normal(_))) + || normalized.eq_ignore_ascii_case(WORK_DIR) + || normalized + .to_lowercase() + .starts_with(&format!("{WORK_DIR}/")) + || normalized.eq_ignore_ascii_case(LOCK_FILE) + { + return Err(format!( + "Installed file escapes its managed directory: {raw}" + )); + } + Ok(PathBuf::from(normalized)) +} + +fn checked_path(root: &Path, relative: &Path) -> Result { + let mut current = root.to_path_buf(); + for component in relative.components() { + current.push(component); + match fs::symlink_metadata(¤t) { + Ok(meta) => { + #[cfg(windows)] + let redirected = { + use std::os::windows::fs::MetadataExt; + meta.file_attributes() & 0x400 != 0 + }; + #[cfg(not(windows))] + let redirected = meta.file_type().is_symlink(); + if redirected { + return Err(format!( + "Refusing redirected installed path: {}", + current.display() + )); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(format!("Cannot inspect {}: {error}", current.display())), + } + } + Ok(current) +} + +fn digest(path: &Path) -> Result { + let mut file = File::open(path).map_err(|e| format!("Cannot read {}: {e}", path.display()))?; + let mut hash = Sha256::new(); + let mut buffer = [0_u8; 65536]; + loop { + let count = file.read(&mut buffer).map_err(|e| e.to_string())?; + if count == 0 { + break; + } + hash.update(&buffer[..count]); + } + Ok(format!("{:x}", hash.finalize())) +} + +impl Transaction { + pub(super) fn open(root: &Path) -> Result { + Self::open_with_recovery(root, |_, _| { + Err("Integration recovery is unavailable".into()) + }) + } + + pub(super) fn open_with_recovery( + root: &Path, + restore_integrations: fn(&Path, &serde_json::Value) -> Result<(), String>, + ) -> Result { + if !root.is_absolute() + || root.parent().is_none() + || root.components().any(|c| matches!(c, Component::ParentDir)) + { + return Err("Maintenance requires an absolute installation directory".into()); + } + fs::create_dir_all(root) + .map_err(|e| format!("Cannot create installation directory: {e}"))?; + let root = root.canonicalize().map_err(|e| e.to_string())?; + let parent = root + .parent() + .ok_or("Installation has no parent directory")?; + let name = root + .file_name() + .ok_or("Installation has no directory name")? + .to_string_lossy(); + let lock_path = checked_path(parent, Path::new(&format!(".{name}.maintenance.lock")))?; + let lock = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(&lock_path) + .map_err(|e| format!("Cannot open maintenance lock: {e}"))?; + lock.try_lock() + .map_err(|_| "Another maintenance operation is already in progress".to_string())?; + let work = checked_path(&root, Path::new(WORK_DIR))?; + let journal_path = checked_path(&work, Path::new("journal.json"))?; + let journal = match fs::read(&journal_path) { + Ok(bytes) => { + let journal: Journal = serde_json::from_slice(&bytes).map_err(|e| { + format!( + "Maintenance journal is unreadable; preserved at {}: {e}", + work.display() + ) + })?; + if journal.schema != 1 { + return Err(format!( + "Unsupported maintenance journal; preserved at {}", + work.display() + )); + } + journal + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Journal { + schema: 1, + committed: false, + entries: vec![], + integrations: None, + }, + Err(e) => return Err(format!("Cannot read maintenance journal: {e}")), + }; + let mut transaction = Self { + root, + work, + _lock: lock, + journal, + restore_integrations, + }; + if transaction.journal.committed { + transaction.clean_work()?; + } else if !transaction.journal.entries.is_empty() + || transaction.journal.integrations.is_some() + { + transaction.rollback()?; + } else if transaction.work.exists() { + // An interruption before publication can leave staging but no backups. + if transaction.work.join("backup").exists() { + return Err(format!( + "Unrecorded maintenance backup; repair required at {}", + transaction.work.display() + )); + } + transaction.clean_work()?; + } + fs::create_dir_all(transaction.stage()).map_err(|e| e.to_string())?; + Ok(transaction) + } + + pub(super) fn stage(&self) -> PathBuf { + self.work.join("stage") + } + + pub(super) fn snapshot_integrations( + &mut self, + snapshot: serde_json::Value, + ) -> Result<(), String> { + self.journal.integrations = Some(snapshot); + self.persist() + } + + pub(super) fn publish( + &mut self, + replacements: &[String], + removals: &[String], + ) -> Result<(), String> { + if !self.journal.entries.is_empty() { + return Err("Maintenance already prepared".into()); + } + let mut all = std::collections::BTreeMap::new(); + for raw in removals { + all.insert(raw.to_lowercase(), (raw.clone(), false)); + } + for raw in replacements { + all.insert(raw.to_lowercase(), (raw.clone(), true)); + } + // Prepare the entire journal before the first rename, including validation + // of every destination. A locked executable never leaves a half-install. + for (_, (raw, replace)) in all { + let relative = relative_path(&raw)?; + let target = checked_path(&self.root, &relative)?; + if target.exists() && !target.is_file() { + return Err(format!( + "Installed file is not a regular file: {}", + target.display() + )); + } + let replacement = if replace { + Some(digest(&checked_path(&self.stage(), &relative)?)?) + } else { + None + }; + self.journal.entries.push(Entry { + path: raw, + original: target.exists(), + replacement, + }); + } + self.persist()?; + let result = self.apply(); + if let Err(error) = result { + return match self.rollback() { + Ok(()) => Err(format!("{error}. Previous files were restored")), + Err(rollback) => Err(format!( + "{error}. Recovery failed: {rollback}. Backups preserved at {}", + self.work.display() + )), + }; + } + Ok(()) + } + + fn apply(&self) -> Result<(), String> { + // Back up *all* old files before publishing any replacement. Failure to + // move a running Windows binary therefore restores only old file names. + for entry in &self.journal.entries { + let relative = relative_path(&entry.path)?; + if entry.original { + let backup = checked_path(&self.work.join("backup"), &relative)?; + fs::create_dir_all(backup.parent().unwrap()).map_err(|e| e.to_string())?; + fs::rename(checked_path(&self.root, &relative)?, backup).map_err(|e| { + format!( + "Cannot back up {} (close the application and retry): {e}", + entry.path + ) + })?; + } + } + for entry in &self.journal.entries { + if entry.replacement.is_some() { + let relative = relative_path(&entry.path)?; + let target = checked_path(&self.root, &relative)?; + fs::create_dir_all(target.parent().unwrap()).map_err(|e| e.to_string())?; + fs::rename(checked_path(&self.stage(), &relative)?, target) + .map_err(|e| format!("Cannot publish {}: {e}", entry.path))?; + } + } + Ok(()) + } + + pub(super) fn rollback(&mut self) -> Result<(), String> { + let mut failures = Vec::new(); + for entry in self.journal.entries.iter().rev() { + let relative = relative_path(&entry.path)?; + let target = checked_path(&self.root, &relative)?; + let backup = checked_path(&self.work.join("backup"), &relative)?; + if backup.exists() || !entry.original { + if target.exists() { + if entry.replacement.as_deref() != Some(digest(&target)?.as_str()) { + failures.push(format!( + "{} was modified after maintenance started", + entry.path + )); + continue; + } + if let Err(e) = fs::remove_file(&target) { + failures.push(e.to_string()); + continue; + } + } + if backup.exists() { + if let Err(e) = fs::rename(&backup, &target) { + failures.push(format!("restore {}: {e}", entry.path)); + } + } + } + } + if let Some(snapshot) = &self.journal.integrations { + if let Err(error) = (self.restore_integrations)(&self.root, snapshot) { + failures.push(error); + } + } + if !failures.is_empty() { + return Err(failures.join("; ")); + } + self.clean_work()?; + self.journal.entries.clear(); + self.journal.integrations = None; + Ok(()) + } + + pub(super) fn commit(&mut self) -> Result { + self.journal.committed = true; + if let Err(error) = self.persist() { + self.journal.committed = false; + return match self.rollback() { + Ok(()) => Err(format!( + "Cannot commit maintenance; previous installation restored: {error}" + )), + Err(recovery) => Err(format!( + "Cannot commit maintenance: {error}; recovery retained at {}: {recovery}", + self.work.display() + )), + }; + } + // Cleanup is not allowed to turn a committed installation into a + // reported failure or to trigger rollback. Retry it on the next open. + match self.clean_work() { + Ok(()) => Ok(true), + Err(error) => { + log::warn!("Maintenance cleanup pending: {error}"); + Ok(false) + } + } + } + + fn persist(&self) -> Result<(), String> { + fs::create_dir_all(&self.work).map_err(|e| e.to_string())?; + let mut temp = tempfile::NamedTempFile::new_in(&self.work).map_err(|e| e.to_string())?; + temp.write_all(&serde_json::to_vec(&self.journal).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + temp.as_file().sync_all().map_err(|e| e.to_string())?; + temp.persist(self.work.join("journal.json")) + .map_err(|e| e.to_string())?; + Ok(()) + } + + fn clean_work(&self) -> Result<(), String> { + // Never follow links while recursively deleting our own transaction tree. + fn remove_tree(path: &Path) -> Result<(), String> { + if !path.exists() { + return Ok(()); + } + for entry in fs::read_dir(path).map_err(|e| e.to_string())? { + let entry = entry.map_err(|e| e.to_string())?; + checked_path(path, Path::new(&entry.file_name()))?; + if entry.file_type().map_err(|e| e.to_string())?.is_dir() { + remove_tree(&entry.path())?; + } else { + fs::remove_file(entry.path()).map_err(|e| e.to_string())?; + } + } + fs::remove_dir(path).map_err(|e| e.to_string()) + } + remove_tree(&self.work) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn staged(transaction: &Transaction, name: &str, bytes: &[u8]) { + let target = transaction.stage().join(name); + fs::create_dir_all(target.parent().unwrap()).unwrap(); + fs::write(target, bytes).unwrap(); + } + + #[test] + fn interruption_restores_old_files_and_removes_only_new_owned_files() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join("app.exe"), b"old").unwrap(); + fs::write(temp.path().join("user.txt"), b"user").unwrap(); + { + let mut tx = Transaction::open(temp.path()).unwrap(); + staged(&tx, "app.exe", b"new"); + staged(&tx, "runtime/new.js", b"new runtime"); + tx.publish(&["app.exe".into(), "runtime/new.js".into()], &[]) + .unwrap(); + // No commit: simulate process death, releasing only the OS lock. + } + let _recovered = Transaction::open(temp.path()).unwrap(); + assert_eq!(fs::read(temp.path().join("app.exe")).unwrap(), b"old"); + assert!(!temp.path().join("runtime/new.js").exists()); + assert_eq!(fs::read(temp.path().join("user.txt")).unwrap(), b"user"); + } + + #[test] + fn committed_replacement_removes_obsolete_owned_files_only() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join("obsolete.js"), b"old").unwrap(); + let mut tx = Transaction::open(temp.path()).unwrap(); + staged(&tx, "app.exe", b"new"); + tx.publish(&["app.exe".into()], &["obsolete.js".into()]) + .unwrap(); + tx.commit().unwrap(); + drop(tx); + let _next = Transaction::open(temp.path()).unwrap(); + assert_eq!(fs::read(temp.path().join("app.exe")).unwrap(), b"new"); + assert!(!temp.path().join("obsolete.js").exists()); + } + + #[test] + fn second_writer_is_refused_without_deleting_the_first_lock() { + let temp = tempfile::tempdir().unwrap(); + let first = Transaction::open(temp.path()).unwrap(); + assert!(Transaction::open(temp.path()).is_err()); + drop(first); + assert!(Transaction::open(temp.path()).is_ok()); + } + + #[test] + fn modified_replacement_keeps_recovery_backup() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join("app.exe"), b"old").unwrap(); + let mut tx = Transaction::open(temp.path()).unwrap(); + staged(&tx, "app.exe", b"new"); + tx.publish(&["app.exe".into()], &[]).unwrap(); + fs::write(temp.path().join("app.exe"), b"unexpected").unwrap(); + assert!(tx.rollback().is_err()); + assert_eq!(fs::read(tx.work.join("backup/app.exe")).unwrap(), b"old"); + } + + #[test] + fn rejects_root_relative_drive_relative_and_parent_entries() { + for path in [ + "", + "/etc/a", + "\\outside", + "C:outside", + "C:\\outside", + "a/../b", + "a\\..\\b", + WORK_DIR, + LOCK_FILE, + ] { + assert!(relative_path(path).is_err(), "accepted {path}"); + } + assert!(relative_path("frontend/dist/index.html").is_ok()); + #[cfg(windows)] + for path in ["a/.. /outside", "NUL", "COM1.txt", "trailing."] { + assert!(relative_path(path).is_err(), "accepted {path}"); + } + } + + #[cfg(windows)] + #[test] + fn junctions_cannot_redirect_publication_into_another_directory() { + let root = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + fs::write(outside.path().join("user.txt"), b"preserve").unwrap(); + let status = super::super::commands::create_windows_silent_command(super::super::commands::system_powershell().unwrap()) + .args(["-NoProfile", "-NonInteractive", "-Command", "New-Item -ItemType Junction -Path $env:OPENBITFUN_TEST_LINK -Target $env:OPENBITFUN_TEST_TARGET -ErrorAction Stop | Out-Null"]) + .env("OPENBITFUN_TEST_LINK", root.path().join("redirect")) + .env("OPENBITFUN_TEST_TARGET", outside.path()).status().unwrap(); + assert!(status.success()); + let mut tx = Transaction::open(root.path()).unwrap(); + staged(&tx, "redirect/user.txt", b"replacement"); + assert!(tx + .publish(&["redirect/user.txt".into()], &[]) + .unwrap_err() + .contains("redirected")); + assert_eq!( + fs::read(outside.path().join("user.txt")).unwrap(), + b"preserve" + ); + } + + #[test] + fn unreadable_unknown_and_unrecorded_recovery_data_are_preserved() { + for data in [ + b"invalid".as_slice(), + br#"{"schema":99,"committed":false,"entries":[]}"#, + ] { + let temp = tempfile::tempdir().unwrap(); + fs::create_dir(temp.path().join(WORK_DIR)).unwrap(); + let journal = temp.path().join(WORK_DIR).join("journal.json"); + fs::write(&journal, data).unwrap(); + assert!(Transaction::open(temp.path()).is_err()); + assert_eq!(fs::read(journal).unwrap(), data); + } + let temp = tempfile::tempdir().unwrap(); + let backup = temp.path().join(WORK_DIR).join("backup"); + fs::create_dir_all(&backup).unwrap(); + fs::write(backup.join("app.exe"), b"only copy").unwrap(); + assert!(Transaction::open(temp.path()).is_err()); + assert_eq!(fs::read(backup.join("app.exe")).unwrap(), b"only copy"); + } + + #[cfg(windows)] + #[test] + fn occupied_file_restores_every_earlier_backup() { + use std::os::windows::fs::OpenOptionsExt; + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join("a.js"), b"old runtime").unwrap(); + fs::write(temp.path().join("z.exe"), b"old app").unwrap(); + let locked = OpenOptions::new() + .read(true) + .share_mode(0) + .open(temp.path().join("z.exe")) + .unwrap(); + let mut tx = Transaction::open(temp.path()).unwrap(); + staged(&tx, "a.js", b"new runtime"); + staged(&tx, "z.exe", b"new app"); + assert!(tx.publish(&["a.js".into(), "z.exe".into()], &[]).is_err()); + drop(locked); + assert_eq!(fs::read(temp.path().join("a.js")).unwrap(), b"old runtime"); + assert_eq!(fs::read(temp.path().join("z.exe")).unwrap(), b"old app"); + } + + #[cfg(windows)] + #[test] + fn mapped_uninstaller_defers_cleanup_without_reversing_a_committed_removal() { + use std::process::{Child, Stdio}; + struct Running(Child); + impl Drop for Running { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + let temp = tempfile::tempdir().unwrap(); + let ping = PathBuf::from(std::env::var_os("SYSTEMROOT").unwrap()).join("System32/ping.exe"); + let mapped = temp.path().join("uninstall.exe"); + fs::copy(ping, &mapped).unwrap(); + let running = Running( + super::super::commands::create_windows_silent_command(&mapped) + .args(["-n", "30", "127.0.0.1"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(), + ); + let mut tx = Transaction::open(temp.path()).unwrap(); + tx.publish(&[], &["uninstall.exe".into()]).unwrap(); + assert!( + !tx.commit().unwrap(), + "the mapped image must report pending cleanup" + ); + assert!(!mapped.exists()); + assert!(tx.work.join("backup/uninstall.exe").exists()); + drop(tx); + drop(running); + let mut recovered = Transaction::open(temp.path()).unwrap(); + assert!( + !mapped.exists(), + "cleanup must not restore an already committed removal" + ); + assert!(recovered.commit().unwrap()); + } +} diff --git a/OpenBitFun-Installer/src-tauri/src/installer/types.rs b/OpenBitFun-Installer/src-tauri/src/installer/types.rs index ca5b0a0dc5..e403c9ee6a 100644 --- a/OpenBitFun-Installer/src-tauri/src/installer/types.rs +++ b/OpenBitFun-Installer/src-tauri/src/installer/types.rs @@ -5,6 +5,8 @@ use std::collections::HashMap; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct InstallOptions { + #[serde(default)] + pub all_users: bool, /// Target installation directory pub install_path: String, /// Create a desktop shortcut @@ -151,6 +153,7 @@ pub(crate) struct DiskSpaceInfo { impl Default for InstallOptions { fn default() -> Self { Self { + all_users: false, install_path: String::new(), desktop_shortcut: true, start_menu: true, diff --git a/OpenBitFun-Installer/src-tauri/src/lib.rs b/OpenBitFun-Installer/src-tauri/src/lib.rs index c85627d10d..00e72c1058 100644 --- a/OpenBitFun-Installer/src-tauri/src/lib.rs +++ b/OpenBitFun-Installer/src-tauri/src/lib.rs @@ -5,7 +5,67 @@ use installer::commands; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + match commands::relocate_uninstaller() { + Ok(true) => return, + Ok(false) => {} + Err(error) => { + if std::env::args().any(|arg| arg == "--quiet") { + eprintln!("Cannot start uninstaller: {error}"); + } else { + show_native_error(&format!("Cannot start uninstaller: {error}")); + } + std::process::exit(1); + } + } + commands::schedule_uninstaller_cleanup(); + if let Some(result) = commands::native_mode() { + let code = match &result { + Ok(()) => 0, + Err(error) + if error.starts_with("Update installed, but") + || error.starts_with("Uninstall completed, but") => + { + 2 + } + Err(_) => 1, + }; + commands::persist_native_outcome(code, result.as_ref().err().map(String::as_str)); + if let Err(error) = result { + eprintln!("Maintenance failed: {error}"); + if !std::env::args().any(|arg| arg.starts_with("/READY_EVENT=") || arg == "--quiet") { + show_native_error(&error); + } + std::process::exit(code as i32); + } + return; + } let preview_only = preview::is_enabled(); + #[cfg(windows)] + if !preview_only && tauri::webview_version().is_err() { + let context = commands::get_launch_context(); + if context.mode == "uninstall" { + let Some(path) = context.uninstall_path else { + show_native_error("The uninstall directory is unavailable"); + std::process::exit(1); + }; + if !confirm_native_uninstall() { + std::process::exit(1223); + } + if let Err(error) = commands::uninstall_without_webview(path) { + show_native_error(&error); + std::process::exit(if error.starts_with("Uninstall completed, but") { + 2 + } else { + 1 + }); + } + return; + } + if let Err(error) = install_webview_runtime() { + show_native_error(&error); + std::process::exit(1); + } + } let handler: fn(tauri::ipc::Invoke) -> bool = tauri::generate_handler![ commands::get_launch_context, commands::get_default_install_path, @@ -26,6 +86,11 @@ pub fn run() { ]; tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) + .on_window_event(|_, event| { + if let tauri::WindowEvent::CloseRequested { api, .. } = event { + if commands::maintenance_busy() { api.prevent_close(); } + } + }) .invoke_handler(move |invoke| { if preview_only && !preview::allows_command(invoke.message.command()) { invoke @@ -36,5 +101,110 @@ pub fn run() { handler(invoke) }) .run(tauri::generate_context!()) - .expect("error while running OpenBitFun Installer"); + .unwrap_or_else(|error| { + show_native_error(&format!("Cannot open the installer. Check the Microsoft WebView2 Runtime and retry. {error}")); + std::process::exit(1); + }); +} + +#[cfg(windows)] +fn confirm_native_uninstall() -> bool { + #[link(name = "user32")] + extern "system" { + fn MessageBoxW( + window: *mut std::ffi::c_void, + text: *const u16, + title: *const u16, + flags: u32, + ) -> i32; + } + let message = "Remove the OpenBitFun application? Your settings, sessions and workspaces will be preserved. WebView2 is unavailable, so a native uninstaller will be used." + .encode_utf16().chain(std::iter::once(0)).collect::>(); + let title = "OpenBitFun" + .encode_utf16() + .chain(std::iter::once(0)) + .collect::>(); + unsafe { + MessageBoxW( + std::ptr::null_mut(), + message.as_ptr(), + title.as_ptr(), + 0x124, + ) == 6 + } +} + +#[cfg(windows)] +fn install_webview_runtime() -> Result<(), String> { + #[link(name = "user32")] + extern "system" { + fn MessageBoxW( + window: *mut std::ffi::c_void, + text: *const u16, + title: *const u16, + flags: u32, + ) -> i32; + } + let text = "OpenBitFun requires Microsoft WebView2 Runtime. Download and install the signed Microsoft runtime now? An internet connection is required." + .encode_utf16().chain(std::iter::once(0)).collect::>(); + let title = "OpenBitFun" + .encode_utf16() + .chain(std::iter::once(0)) + .collect::>(); + if unsafe { MessageBoxW(std::ptr::null_mut(), text.as_ptr(), title.as_ptr(), 0x24) } != 6 { + return Err("WebView2 Runtime is required. Install it from https://developer.microsoft.com/microsoft-edge/webview2/ and retry. No application files were changed.".into()); + } + let temp = tempfile::Builder::new() + .prefix("openbitfun-webview-") + .tempdir() + .map_err(|e| e.to_string())?; + let bootstrap = temp.path().join("MicrosoftEdgeWebview2Setup.exe"); + let script = r#"$ErrorActionPreference='Stop' +[Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12 +Invoke-WebRequest -UseBasicParsing -TimeoutSec 120 -Uri 'https://go.microsoft.com/fwlink/p/?LinkId=2124703' -OutFile $env:OPENBITFUN_WEBVIEW_BOOTSTRAP +$signature=Get-AuthenticodeSignature -LiteralPath $env:OPENBITFUN_WEBVIEW_BOOTSTRAP +if ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -notmatch '(?:^|,\s*)O=Microsoft Corporation(?:,|$)') { throw 'Microsoft WebView2 signature verification failed' } +$installer=Start-Process -FilePath $env:OPENBITFUN_WEBVIEW_BOOTSTRAP -ArgumentList '/silent /install' -PassThru +$installer.WaitForExit() +if ($installer.ExitCode -ne 0) { throw "WebView2 setup failed with exit code $($installer.ExitCode)" } +"#; + let output = commands::create_windows_silent_command(commands::system_powershell()?) + .args(["-NoProfile", "-NonInteractive", "-Command", script]) + .env("OPENBITFUN_WEBVIEW_BOOTSTRAP", &bootstrap) + .output() + .map_err(|e| e.to_string())?; + if !output.status.success() { + return Err(format!( + "Cannot install WebView2 Runtime. Install the runtime manually and retry. {}", + String::from_utf8_lossy(&output.stderr) + )); + } + tauri::webview_version().map_err(|e| format!("WebView2 Runtime is still unavailable: {e}. Restart Windows if runtime setup requires a reboot"))?; + Ok(()) +} + +fn show_native_error(message: &str) { + #[cfg(windows)] + unsafe { + #[link(name = "user32")] + extern "system" { + fn MessageBoxW( + window: *mut std::ffi::c_void, + text: *const u16, + title: *const u16, + flags: u32, + ) -> i32; + } + let text = message + .encode_utf16() + .chain(std::iter::once(0)) + .collect::>(); + let title = "OpenBitFun" + .encode_utf16() + .chain(std::iter::once(0)) + .collect::>(); + MessageBoxW(std::ptr::null_mut(), text.as_ptr(), title.as_ptr(), 0x10); + } + #[cfg(not(windows))] + eprintln!("{message}"); } diff --git a/OpenBitFun-Installer/src/App.tsx b/OpenBitFun-Installer/src/App.tsx index 77560281f3..9bebcf2313 100644 --- a/OpenBitFun-Installer/src/App.tsx +++ b/OpenBitFun-Installer/src/App.tsx @@ -150,7 +150,7 @@ function App() { /> )} - + {installer.step !== 'lang' && installer.step !== 'uninstall' && ( diff --git a/OpenBitFun-Installer/src/components/WindowControls.tsx b/OpenBitFun-Installer/src/components/WindowControls.tsx index 334eaf49b4..4d33ac318d 100644 --- a/OpenBitFun-Installer/src/components/WindowControls.tsx +++ b/OpenBitFun-Installer/src/components/WindowControls.tsx @@ -7,13 +7,14 @@ import { useTranslation } from 'react-i18next'; * Window controls — matches the OpenBitFun main app style. * 32x32 transparent buttons with SVG icons, subtle hover bg. */ -export function WindowControls() { +export function WindowControls({ busy = false }: { busy?: boolean }) { const { t } = useTranslation(); const handleMinimize = () => { getCurrentWindow().minimize(); }; const handleClose = () => { + if (busy) return; getCurrentWindow().close(); }; @@ -28,6 +29,7 @@ export function WindowControls() { /> ({ invoke: vi.fn(), listen: vi.fn() })); +vi.mock('@tauri-apps/api/core', () => ({ invoke: mocks.invoke })); +vi.mock('@tauri-apps/api/event', () => ({ listen: mocks.listen })); +vi.mock('../i18n', () => ({ default: { changeLanguage: vi.fn(), t: (key: string) => key } })); +let root: ReturnType; +let container: HTMLDivElement; +let current: UseInstallerReturn; +function Probe() { current = useInstaller(); return null; } +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} + +beforeEach(async () => { + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + vi.resetAllMocks(); + mocks.listen.mockResolvedValue(vi.fn()); + mocks.invoke.mockImplementation(async (command: string) => { + if (command === 'get_launch_context') return { previewOnly: false, mode: 'install' }; + if (command === 'get_initial_install_path') return 'D:\\OpenBitFun'; + if (command === 'get_existing_installation') return { detected: false }; + if (command === 'validate_install_path') return { installPath: 'D:\\OpenBitFun' }; + return undefined; + }); + container = document.createElement('div'); document.body.append(container); + root = createRoot(container); + await act(async () => root.render()); +}); +afterEach(() => { act(() => root.unmount()); container.remove(); }); + +it('serializes rapid install clicks and reports a launch failure without reinstalling on retry', async () => { + const installing = deferred<{ isUpdate: boolean; cleanupPending: boolean }>(); + mocks.invoke.mockImplementation(async (command: string) => { + if (command === 'get_existing_installation') return { detected: true, mainBinaryPresent: true, installLocation: 'D:\\OpenBitFun' }; + if (command === 'validate_install_path') return { installPath: 'D:\\OpenBitFun' }; + if (command === 'start_installation') return installing.promise; + if (command === 'launch_application') throw new Error('Launch denied'); + }); + act(() => current.setOptions(prev => ({ ...prev, launchAfterInstall: true }))); + let first!: Promise; + await act(async () => { first = current.install(); await current.install(); }); + expect(mocks.invoke.mock.calls.filter(([command]) => command === 'start_installation')).toHaveLength(1); + act(() => current.closeInstaller()); + expect(mocks.invoke.mock.calls.some(([command]) => command === 'close_installer')).toBe(false); + await act(async () => { installing.resolve({ isUpdate: true, cleanupPending: false }); await first; }); + expect(current.error).toBe('Launch denied'); + expect(current.installationCompleted).toBe(true); + await act(async () => current.retryInstall()); + expect(mocks.invoke.mock.calls.filter(([command]) => command === 'start_installation')).toHaveLength(1); +}); + +it('ignores stale disk responses and surfaces a failed query', async () => { + const old = deferred<{ availableGb: number }>(); + const latest = deferred<{ availableGb: number }>(); + mocks.invoke.mockImplementation((command: string, args: { path: string }) => command === 'get_disk_space' + ? (args.path === 'old' ? old.promise : latest.promise) : Promise.resolve()); + let first!: Promise; let second!: Promise; + act(() => { first = current.refreshDiskSpace('old'); second = current.refreshDiskSpace('new'); }); + await act(async () => { latest.reject('Disk query failed'); await second; }); + await act(async () => { old.resolve({ availableGb: 100 }); await first; }); + expect(current.diskSpace).toBeNull(); + expect(current.error).toBe('Disk query failed'); +}); + +it('waits for real uninstall completion and keeps a failure visible', async () => { + const removing = deferred(); + mocks.invoke.mockImplementation((command: string) => command === 'uninstall' ? removing.promise : Promise.resolve()); + let operation!: Promise; + await act(async () => { operation = current.startUninstall(); await current.startUninstall(); }); + expect(current.isUninstalling).toBe(true); + expect(current.uninstallCompleted).toBe(false); + expect(mocks.invoke.mock.calls.filter(([command]) => command === 'uninstall')).toHaveLength(1); + await act(async () => { removing.reject('File is occupied'); await operation; }); + expect(current.uninstallError).toBe('File is occupied'); + expect(current.uninstallCompleted).toBe(false); + expect(current.isUninstalling).toBe(false); +}); + +it('keeps immediate install failures visible after normalizing a path and ignores earlier disk-query failures', async () => { + const disk = deferred(); + mocks.invoke.mockImplementation(async (command: string) => { + if (command === 'get_disk_space') return disk.promise; + if (command === 'get_existing_installation') return { detected: false }; + if (command === 'validate_install_path') return { installPath: 'D:\\Other\\OpenBitFun' }; + if (command === 'start_installation') throw 'Another maintenance process is already running'; + }); + let query!: Promise; + act(() => { query = current.refreshDiskSpace('old'); }); + await act(async () => current.install()); + await act(async () => { disk.reject('stale disk error'); await query; }); + expect(current.error).toBe('Another maintenance process is already running'); + expect(current.options.installPath).toBe('D:\\Other\\OpenBitFun'); +}); diff --git a/OpenBitFun-Installer/src/hooks/useInstaller.ts b/OpenBitFun-Installer/src/hooks/useInstaller.ts index ab50d6b1db..13e3c6f49e 100644 --- a/OpenBitFun-Installer/src/hooks/useInstaller.ts +++ b/OpenBitFun-Installer/src/hooks/useInstaller.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; import i18n from '../i18n'; @@ -70,6 +70,10 @@ function isSameInstallPath(left: string, right: string): boolean { } export function useInstaller(): UseInstallerReturn { + const operation = useRef<'install' | 'uninstall' | null>(null); + const diskRequest = useRef(0); + const installedPath = useRef(null); + const cleanupPending = useRef(false); const [previewOnly, setPreviewOnly] = useState(false); const [step, setStep] = useState('lang'); const [options, setOptions] = useState(DEFAULT_OPTIONS); @@ -146,6 +150,7 @@ export function useInstaller(): UseInstallerReturn { useEffect(() => { const unlisten = listen('install-progress', (event) => { setProgress(event.payload); + if (operation.current === 'uninstall') setUninstallProgress(event.payload.percent); }); return () => { unlisten.then((fn) => fn()); }; }, []); @@ -154,10 +159,6 @@ export function useInstaller(): UseInstallerReturn { setError(null); }, []); - useEffect(() => { - setError(null); - }, [options.installPath, step]); - const readExistingInstall = useCallback(async (): Promise => { try { return await invoke('get_existing_installation'); @@ -218,11 +219,14 @@ export function useInstaller(): UseInstallerReturn { }, [step]); const refreshDiskSpace = useCallback(async (path: string) => { + const request = ++diskRequest.current; + setDiskSpace(null); try { const info = await invoke('get_disk_space', { path }); - setDiskSpace(info); + if (request === diskRequest.current) setDiskSpace(info); } catch (err) { console.warn('Failed to get disk space:', err); + if (request === diskRequest.current) setError(String(err)); } }, []); @@ -254,6 +258,11 @@ export function useInstaller(): UseInstallerReturn { }, [refreshExistingInstall]); const install = useCallback(async () => { + if (operation.current) return; + operation.current = 'install'; + ++diskRequest.current; + installedPath.current = null; + cleanupPending.current = false; setError(null); setIsInstalling(true); @@ -264,7 +273,7 @@ export function useInstaller(): UseInstallerReturn { const validated = await invoke('validate_install_path', { path: options.installPath, }); - const isUpdate = Boolean( + const detectedUpdate = Boolean( detectedInstall.detected && detectedInstall.mainBinaryPresent && detectedInstall.installLocation @@ -279,8 +288,16 @@ export function useInstaller(): UseInstallerReturn { } setStep('progress'); setProgress({ step: 'prepare', percent: 0, message: '' }); - await invoke('start_installation', { options: effectiveOptions }); + const result = await invoke<{ isUpdate: boolean; cleanupPending: boolean; warning?: string } | null>('start_installation', { options: effectiveOptions }); + installedPath.current = effectiveOptions.installPath; + cleanupPending.current = result?.cleanupPending === true; setInstallationCompleted(true); + const isUpdate = result?.isUpdate ?? detectedUpdate; + if (result?.cleanupPending) { + setError(i18n.t('errors.maintenance.cleanupPending')); + return; + } + if (result?.warning) { setError(result.warning); return; } if (isUpdate) { if (options.launchAfterInstall) { await invoke('launch_application', { installPath: effectiveOptions.installPath }); @@ -299,6 +316,7 @@ export function useInstaller(): UseInstallerReturn { const raw = typeof err === 'string' ? err : err?.message; setError((raw && String(raw).trim()) ? String(raw) : i18n.t('errors.install.failed')); } finally { + operation.current = null; setIsInstalling(false); } }, [options, readExistingInstall, refreshExistingInstall]); @@ -310,7 +328,14 @@ export function useInstaller(): UseInstallerReturn { }, [canConfirmProgress]); const retryInstall = useCallback(async () => { - if (isInstalling) return; + if (isInstalling || operation.current) return; + if (installedPath.current && !cleanupPending.current) { + try { + await invoke('launch_application', { installPath: installedPath.current }); + await invoke('close_installer'); + } catch (error) { setError(String(error)); } + return; + } await install(); }, [install, isInstalling]); @@ -335,41 +360,28 @@ export function useInstaller(): UseInstallerReturn { }, [options.installPath]); const closeInstaller = useCallback(() => { + if (operation.current) return; invoke('close_installer'); }, []); const startUninstall = useCallback(async () => { - if (isUninstalling) return; + if (operation.current) return; + operation.current = 'uninstall'; + ++diskRequest.current; setUninstallError(null); setUninstallCompleted(false); setIsUninstalling(true); setUninstallProgress(0); try { - await new Promise((resolve) => { - const durationMs = 1800; - const startedAt = Date.now(); - const timer = window.setInterval(() => { - const elapsed = Date.now() - startedAt; - const ratio = Math.min(elapsed / durationMs, 1); - const percent = Math.round(ratio * 85); - setUninstallProgress(percent); - if (ratio >= 1) { - window.clearInterval(timer); - resolve(); - } - }, 80); - }); - - await invoke('uninstall', { installPath: options.installPath }); + const result = await invoke<{ cleanupPending: boolean } | null>('uninstall', { installPath: options.installPath }); setUninstallProgress(100); setUninstallCompleted(true); - window.setTimeout(() => { - closeInstaller(); - }, 600); + if (result?.cleanupPending) setUninstallError(i18n.t('errors.maintenance.cleanupPending')); } catch (err: any) { setUninstallError(typeof err === 'string' ? err : err.message || 'Uninstall failed'); setUninstallProgress(0); } finally { + operation.current = null; setIsUninstalling(false); } }, [closeInstaller, isUninstalling, options.installPath]); diff --git a/OpenBitFun-Installer/src/i18n/locales/en.json b/OpenBitFun-Installer/src/i18n/locales/en.json index 027aabaa47..1265ab80ca 100644 --- a/OpenBitFun-Installer/src/i18n/locales/en.json +++ b/OpenBitFun-Installer/src/i18n/locales/en.json @@ -12,6 +12,16 @@ "directoryNotWritable": "The installation folder is not writable. Choose another location or run the installer as administrator (see below).", "parentNotWritable": "The parent folder is not writable. System folders such as Program Files often require administrator rights (see below).", "adminHint": "To install under protected locations (for example Program Files), close this installer, right-click the installer executable, choose \"Run as administrator\", then try again. Alternatively install under your user profile, for example %LOCALAPPDATA%\\Programs, which does not require elevation." + }, + "maintenance": { + "cleanupPending": "The operation completed, but some maintenance files remain. Retry cleanup before removing the installation folder.", + "busy": "Another installation, update or uninstall is running. Wait for it to finish and retry.", + "canceled": "Administrator authorization was canceled. No installation files were changed.", + "recovery": "Automatic recovery needs repair. Keep the backup files and share these details: {{detail}}", + "fileLocked": "An application or background process is using installed files. Close it and retry. The previous files were retained.", + "space": "There is not enough disk space for staging. Free space on the target drive and retry.", + "diskUnknown": "Available disk space could not be determined. Check that the drive is connected and accessible, then retry.", + "failed": "The operation could not complete. Details: {{detail}}" } }, "options": { @@ -36,7 +46,8 @@ "existingInstallVersion": "Installed version: {{version}}", "existingInstallLocation": "Install location: {{path}}", "existingInstallBinaryMissing": "The installed app is missing. Reinstall to repair it.", - "existingInstallRunUninstaller": "Run uninstaller" + "existingInstallRunUninstaller": "Run uninstaller", + "allUsers": "Install for all users (administrator authorization required)" }, "model": { "title": "Connect a model", @@ -46,7 +57,7 @@ "modelName": "Model name (e.g. deepseek-v4-flash)", "skip": "Skip for now", "nextTheme": "Next: Theme", - "description": "Manage default models, providers, subscriptions, catalog data, and network connections.", + "description": "Connect model providers and choose models for each type of task", "providerLabel": "Select Model Provider", "selectProvider": "or select a preset provider", "customProvider": "Custom Configuration", @@ -57,8 +68,8 @@ "name": "OpenBitFun Service", "description": "OpenBitFun Model Platform", "urlOptions": { - "default": "Anthropic Format - Default", - "openai": "OpenAI Compatible Format" + "default": "Official API", + "openai": "Official API" } }, "gemini": { @@ -73,40 +84,41 @@ "name": "MiniMax", "description": "MiniMax series models", "urlOptions": { - "default": "China - Anthropic Format - Default", - "openai": "China - OpenAI Compatible", - "intl": "Global - Anthropic Format", - "intlOpenai": "Global - OpenAI Compatible" + "default": "China - Default", + "openai": "China", + "intl": "Global", + "intlOpenai": "Global" } }, "moonshot": { "name": "Moonshot AI", "description": "Moonshot Kimi series models", "urlOptions": { - "default": "China - OpenAI Format - Default", - "anthropic": "China - Anthropic Format", - "intl": "Global - OpenAI Format", - "intlAnthropic": "Global - Anthropic Format", - "kimiForCoding": "Kimi For Coding plan (models: k3 / kimi-for-coding)" + "default": "China - Default", + "anthropic": "China", + "intl": "Global", + "intlAnthropic": "Global", + "kimiForCoding": "Kimi For Coding plan (models: k3 / kimi-for-coding)", + "kimiForCodingAnthropic": "Kimi For Coding plan" } }, "deepseek": { "name": "DeepSeek", "description": "DeepSeek series models", "urlOptions": { - "default": "OpenAI Format - Default", - "anthropic": "Anthropic Format" + "default": "Official API - Default", + "anthropic": "Official API" } }, "zhipu": { "name": "Zhipu AI", "description": "Zhipu AI GLM series models", "urlOptions": { - "default": "China - OpenAI Format - Default", - "anthropic": "China - Anthropic Format", + "default": "China - Default", + "anthropic": "China", "codingPlan": "China - Coding Plan", - "intl": "Global (Z.AI) - OpenAI Format", - "intlAnthropic": "Global (Z.AI) - Anthropic Format", + "intl": "Global (Z.AI)", + "intlAnthropic": "Global (Z.AI)", "intlCodingPlan": "Global (Z.AI) - Coding Plan" } }, @@ -114,10 +126,10 @@ "name": "Qwen", "description": "Alibaba Cloud Bailian Model Platform", "urlOptions": { - "default": "China - OpenAI Format - Default", + "default": "China - Default", "codingPlan": "China - Coding Plan", - "codingPlanAnthropic": "China - Coding Plan - Anthropic Format", - "intl": "Global (International) - OpenAI Format", + "codingPlanAnthropic": "China - Coding Plan", + "intl": "Global (International)", "intlCodingPlan": "Global - Coding Plan" } }, @@ -134,9 +146,9 @@ "name": "SiliconFlow", "description": "SiliconFlow Model Platform", "urlOptions": { - "default": "China - OpenAI Format - Default", - "anthropic": "China - Anthropic Format", - "intl": "Global - OpenAI Format" + "default": "China - Default", + "anthropic": "China", + "intl": "Global" } }, "nvidia": { @@ -150,6 +162,42 @@ "tokendance": { "name": "TokenDance", "description": "Unified AI model gateway" + }, + "magpie": { + "name": "Magpie", + "description": "Models and routing groups from your Magpie gateway", + "urlOptions": { + "openai": "Magpie gateway", + "responses": "Magpie gateway", + "anthropic": "Magpie gateway" + } + }, + "opencode-zen": { + "name": "OpenCode Zen", + "description": "OpenCode models with free access and pay-as-you-go API keys." + }, + "opencode-go": { + "name": "OpenCode Go", + "description": "A model subscription plan from OpenCode." + }, + "openai": { + "name": "OpenAI", + "description": "OpenAI GPT and reasoning models", + "urlOptions": { + "default": "Official API", + "chatCompletions": "Official API" + } + }, + "xiaomi": { + "name": "Xiaomi MiMo", + "description": "Xiaomi MiMo models. API and Token Plan keys are separate.", + "urlOptions": { + "default": "API - Default", + "responses": "API", + "anthropic": "API", + "tokenPlan": "China - Token Plan", + "tokenPlanAnthropic": "China - Token Plan" + } } }, "modelNameSelectPlaceholder": "Select model", diff --git a/OpenBitFun-Installer/src/i18n/locales/zh-TW.json b/OpenBitFun-Installer/src/i18n/locales/zh-TW.json index 0e4b73405c..2e60ac2253 100644 --- a/OpenBitFun-Installer/src/i18n/locales/zh-TW.json +++ b/OpenBitFun-Installer/src/i18n/locales/zh-TW.json @@ -12,6 +12,16 @@ "directoryNotWritable": "安裝目錄不可寫入。請更換路徑,或以管理員身份運行安裝器(見下方說明)。", "parentNotWritable": "上級目錄不可寫入。系統目錄(如 Program Files)通常需要管理員權限(見下方說明)。", "adminHint": "若需安裝到受保護位置(例如 Program Files),請關閉本安裝器,在安裝程式上右鍵選擇「以管理員身份運行」後重新安裝。也可安裝到目前用戶目錄(例如 %LOCALAPPDATA%\\Programs),一般無需管理員權限。" + }, + "maintenance": { + "cleanupPending": "操作已完成,但部分維護檔案仍待清理。請重試清理後再移除安裝資料夾。", + "busy": "另一個安裝、更新或解除安裝操作正在執行,請等待完成後重試。", + "canceled": "已取消系統管理員授權,未變更安裝檔案。", + "recovery": "自動復原需要修復。請保留備份檔案,並提供以下詳情:{{detail}}", + "fileLocked": "應用程式或背景程序正在使用安裝檔案,請關閉相關程式後重試。原有檔案已保留。", + "space": "暫存所需磁碟空間不足,請釋放目標磁碟空間後重試。", + "diskUnknown": "無法確定可用磁碟空間,請檢查磁碟已連接且可存取後重試。", + "failed": "操作未能完成。詳情:{{detail}}" } }, "options": { @@ -36,7 +46,8 @@ "existingInstallVersion": "已安裝版本:{{version}}", "existingInstallLocation": "安裝位置:{{path}}", "existingInstallBinaryMissing": "偵測到安裝記錄,主程式缺失,可重新安裝修復。", - "existingInstallRunUninstaller": "運行解除安裝程式" + "existingInstallRunUninstaller": "運行解除安裝程式", + "allUsers": "為所有使用者安裝(需要系統管理員授權)" }, "model": { "title": "連接模型", @@ -46,7 +57,7 @@ "modelName": "模型名稱(如 deepseek-v4-flash)", "skip": "稍後設定", "nextTheme": "下一步:主題", - "description": "管理預設模型、模型服務、訂閱帳戶、模型目錄與網路連線", + "description": "連線模型服務,並選擇各類任務使用的模型", "providerLabel": "選擇模型提供商", "selectProvider": "或選擇預設提供商", "customProvider": "自定義設定", @@ -57,8 +68,8 @@ "name": "OpenBitFun 服務", "description": "OpenBitFun 大模型平臺", "urlOptions": { - "default": "Anthropic格式-默認", - "openai": "OpenAI兼容格式" + "default": "官方介面", + "openai": "官方介面" } }, "gemini": { @@ -73,40 +84,41 @@ "name": "MiniMax", "description": "MiniMax 系列模型", "urlOptions": { - "default": "國內-Anthropic格式-默認", - "openai": "國內-OpenAI兼容格式", - "intl": "海外-Anthropic格式", - "intlOpenai": "海外-OpenAI兼容格式" + "default": "國內-默認", + "openai": "國內", + "intl": "海外", + "intlOpenai": "海外" } }, "moonshot": { "name": "月之暗面", "description": "月之暗面 Kimi 系列模型", "urlOptions": { - "default": "國內-OpenAI格式-默認", - "anthropic": "國內-Anthropic格式", - "intl": "海外-OpenAI格式", - "intlAnthropic": "海外-Anthropic格式", - "kimiForCoding": "Kimi For Coding 套餐(模型 k3 / kimi-for-coding)" + "default": "國內-默認", + "anthropic": "國內", + "intl": "海外", + "intlAnthropic": "海外", + "kimiForCoding": "Kimi For Coding 套餐(模型 k3 / kimi-for-coding)", + "kimiForCodingAnthropic": "Kimi For Coding 套餐" } }, "deepseek": { "name": "DeepSeek", "description": "DeepSeek 系列模型", "urlOptions": { - "default": "OpenAI格式-默認", - "anthropic": "Anthropic格式" + "default": "官方介面-默認", + "anthropic": "官方介面" } }, "zhipu": { "name": "智譜AI", "description": "智譜AI GLM 系列模型", "urlOptions": { - "default": "國內-OpenAI格式-默認", - "anthropic": "國內-Anthropic格式", + "default": "國內-默認", + "anthropic": "國內", "codingPlan": "國內-Coding Plan", - "intl": "海外(Z.AI)-OpenAI格式", - "intlAnthropic": "海外(Z.AI)-Anthropic格式", + "intl": "海外(Z.AI)", + "intlAnthropic": "海外(Z.AI)", "intlCodingPlan": "海外(Z.AI)-Coding Plan" } }, @@ -114,10 +126,10 @@ "name": "通義千問", "description": "阿里雲百鍊大模型平臺", "urlOptions": { - "default": "國內-OpenAI格式-默認", - "intl": "海外(國際站)-OpenAI格式", + "default": "國內-默認", + "intl": "海外(國際站)", "codingPlan": "國內-Coding Plan", - "codingPlanAnthropic": "國內-Coding Plan-Anthropic格式", + "codingPlanAnthropic": "國內-Coding Plan", "intlCodingPlan": "海外-Coding Plan" } }, @@ -134,9 +146,9 @@ "name": "硅基流動", "description": "硅基流動大模型平臺", "urlOptions": { - "default": "國內-OpenAI格式-默認", - "anthropic": "國內-Anthropic格式", - "intl": "海外-OpenAI格式" + "default": "國內-默認", + "anthropic": "國內", + "intl": "海外" } }, "nvidia": { @@ -150,6 +162,42 @@ "tokendance": { "name": "詞元跳動", "description": "統一的大模型 API 閘道" + }, + "magpie": { + "name": "Magpie", + "description": "使用 Magpie 閘道中的模型和路由群組", + "urlOptions": { + "openai": "Magpie 閘道", + "responses": "Magpie 閘道", + "anthropic": "Magpie 閘道" + } + }, + "opencode-zen": { + "name": "OpenCode Zen", + "description": "OpenCode 模型服務,支援免費模型和按量付費 API Key。" + }, + "opencode-go": { + "name": "OpenCode Go", + "description": "OpenCode 推出的模型訂閱方案" + }, + "openai": { + "name": "OpenAI", + "description": "OpenAI GPT 與推理模型", + "urlOptions": { + "default": "官方介面", + "chatCompletions": "官方介面" + } + }, + "xiaomi": { + "name": "Xiaomi MiMo", + "description": "小米 MiMo 系列模型,API 與 Token Plan 金鑰不可混用", + "urlOptions": { + "default": "API-默認", + "responses": "API", + "anthropic": "API", + "tokenPlan": "中國 - Token Plan", + "tokenPlanAnthropic": "中國 - Token Plan" + } } }, "modelNameSelectPlaceholder": "選擇模型", diff --git a/OpenBitFun-Installer/src/i18n/locales/zh.json b/OpenBitFun-Installer/src/i18n/locales/zh.json index 6ce3016d21..8071f03cb5 100644 --- a/OpenBitFun-Installer/src/i18n/locales/zh.json +++ b/OpenBitFun-Installer/src/i18n/locales/zh.json @@ -12,6 +12,16 @@ "directoryNotWritable": "安装目录不可写入。请更换路径,或以管理员身份运行安装器(见下方说明)。", "parentNotWritable": "上级目录不可写入。系统目录(如 Program Files)通常需要管理员权限(见下方说明)。", "adminHint": "若需安装到受保护位置(例如 Program Files),请关闭本安装器,在安装程序上右键选择「以管理员身份运行」后重新安装。也可安装到当前用户目录(例如 %LOCALAPPDATA%\\Programs),一般无需管理员权限。" + }, + "maintenance": { + "cleanupPending": "操作已完成,但部分维护文件仍待清理。请重试清理后再移除安装文件夹。", + "busy": "另一个安装、更新或卸载操作正在运行,请等待完成后重试。", + "canceled": "已取消管理员授权,未更改安装文件。", + "recovery": "自动恢复需要修复。请保留备份文件,并提供以下详情:{{detail}}", + "fileLocked": "应用或后台进程正在使用安装文件,请关闭相关程序后重试。原有文件已保留。", + "space": "暂存所需磁盘空间不足,请释放目标磁盘空间后重试。", + "diskUnknown": "无法确定可用磁盘空间,请检查磁盘已连接且可访问后重试。", + "failed": "操作未能完成。详情:{{detail}}" } }, "options": { @@ -36,7 +46,8 @@ "existingInstallVersion": "已安装版本:{{version}}", "existingInstallLocation": "安装位置:{{path}}", "existingInstallBinaryMissing": "检测到安装记录,主程序缺失,可重新安装修复。", - "existingInstallRunUninstaller": "运行卸载程序" + "existingInstallRunUninstaller": "运行卸载程序", + "allUsers": "为所有用户安装(需要管理员授权)" }, "model": { "title": "连接模型", @@ -46,7 +57,7 @@ "modelName": "模型名称(如 deepseek-v4-flash)", "skip": "稍后配置", "nextTheme": "下一步:主题", - "description": "管理默认模型、模型服务、订阅账户、模型目录与网络连接", + "description": "连接模型服务,并选择各类任务使用的模型", "providerLabel": "选择模型提供商", "selectProvider": "或选择预设提供商", "customProvider": "自定义配置", @@ -57,8 +68,8 @@ "name": "OpenBitFun 服务", "description": "OpenBitFun 大模型平台", "urlOptions": { - "default": "Anthropic格式-默认", - "openai": "OpenAI兼容格式" + "default": "官方接口", + "openai": "官方接口" } }, "gemini": { @@ -73,40 +84,41 @@ "name": "MiniMax", "description": "MiniMax 系列模型", "urlOptions": { - "default": "国内-Anthropic格式-默认", - "openai": "国内-OpenAI兼容格式", - "intl": "海外-Anthropic格式", - "intlOpenai": "海外-OpenAI兼容格式" + "default": "国内-默认", + "openai": "国内", + "intl": "海外", + "intlOpenai": "海外" } }, "moonshot": { "name": "月之暗面", "description": "月之暗面 Kimi 系列模型", "urlOptions": { - "default": "国内-OpenAI格式-默认", - "anthropic": "国内-Anthropic格式", - "intl": "海外-OpenAI格式", - "intlAnthropic": "海外-Anthropic格式", - "kimiForCoding": "Kimi For Coding 套餐(模型 k3 / kimi-for-coding)" + "default": "国内-默认", + "anthropic": "国内", + "intl": "海外", + "intlAnthropic": "海外", + "kimiForCoding": "Kimi For Coding 套餐(模型 k3 / kimi-for-coding)", + "kimiForCodingAnthropic": "Kimi For Coding 套餐" } }, "deepseek": { "name": "DeepSeek", "description": "DeepSeek 系列模型", "urlOptions": { - "default": "OpenAI格式-默认", - "anthropic": "Anthropic格式" + "default": "官方接口-默认", + "anthropic": "官方接口" } }, "zhipu": { "name": "智谱AI", "description": "智谱AI GLM 系列模型", "urlOptions": { - "default": "国内-OpenAI格式-默认", - "anthropic": "国内-Anthropic格式", + "default": "国内-默认", + "anthropic": "国内", "codingPlan": "国内-Coding Plan", - "intl": "海外(Z.AI)-OpenAI格式", - "intlAnthropic": "海外(Z.AI)-Anthropic格式", + "intl": "海外(Z.AI)", + "intlAnthropic": "海外(Z.AI)", "intlCodingPlan": "海外(Z.AI)-Coding Plan" } }, @@ -114,10 +126,10 @@ "name": "通义千问", "description": "阿里云百炼大模型平台", "urlOptions": { - "default": "国内-OpenAI格式-默认", + "default": "国内-默认", "codingPlan": "国内-Coding Plan", - "codingPlanAnthropic": "国内-Coding Plan-Anthropic格式", - "intl": "海外(国际站)-OpenAI格式", + "codingPlanAnthropic": "国内-Coding Plan", + "intl": "海外(国际站)", "intlCodingPlan": "海外-Coding Plan" } }, @@ -134,9 +146,9 @@ "name": "硅基流动", "description": "硅基流动大模型平台", "urlOptions": { - "default": "国内-OpenAI格式-默认", - "anthropic": "国内-Anthropic格式", - "intl": "海外-OpenAI格式" + "default": "国内-默认", + "anthropic": "国内", + "intl": "海外" } }, "nvidia": { @@ -150,6 +162,42 @@ "tokendance": { "name": "词元跳动", "description": "统一的大模型 API 网关" + }, + "magpie": { + "name": "Magpie", + "description": "使用 Magpie 网关中的模型和路由组", + "urlOptions": { + "openai": "Magpie 网关", + "responses": "Magpie 网关", + "anthropic": "Magpie 网关" + } + }, + "opencode-zen": { + "name": "OpenCode Zen", + "description": "OpenCode 模型服务,支持免费模型和按量付费 API Key。" + }, + "opencode-go": { + "name": "OpenCode Go", + "description": "OpenCode 推出的模型订阅套餐" + }, + "openai": { + "name": "OpenAI", + "description": "OpenAI GPT 与推理模型", + "urlOptions": { + "default": "官方接口", + "chatCompletions": "官方接口" + } + }, + "xiaomi": { + "name": "Xiaomi MiMo", + "description": "小米 MiMo 系列模型,API 与 Token Plan 密钥不可混用", + "urlOptions": { + "default": "API-默认", + "responses": "API", + "anthropic": "API", + "tokenPlan": "国内 - Token Plan", + "tokenPlanAnthropic": "国内 - Token Plan" + } } }, "modelNameSelectPlaceholder": "选择模型", @@ -221,7 +269,7 @@ "followSystem": "跟随系统", "themeNames": { "openbitfun-dark": "暗色", - "openbitfun-light": "亮色", + "openbitfun-light": "浅色", "openbitfun-midnight": "午夜", "openbitfun-china-style": "墨韵", "openbitfun-china-night": "墨夜", diff --git a/OpenBitFun-Installer/src/infrastructure/installPathApi.ts b/OpenBitFun-Installer/src/infrastructure/installPathApi.ts new file mode 100644 index 0000000000..2426a2c029 --- /dev/null +++ b/OpenBitFun-Installer/src/infrastructure/installPathApi.ts @@ -0,0 +1,9 @@ +import { invoke } from '@tauri-apps/api/core'; +import { open } from '@tauri-apps/plugin-dialog'; +import type { InstallPathValidation } from '../types/installer'; + +export const installPathApi = { + defaultPath: (allUsers: boolean) => invoke('get_default_install_path', { allUsers }), + validate: (path: string) => invoke('validate_install_path', { path }), + chooseDirectory: (defaultPath: string, title: string) => open({ directory: true, defaultPath, title }), +}; diff --git a/OpenBitFun-Installer/src/pages/ModelSetup.tsx b/OpenBitFun-Installer/src/pages/ModelSetup.tsx index f37cb7215a..d9c40937ec 100644 --- a/OpenBitFun-Installer/src/pages/ModelSetup.tsx +++ b/OpenBitFun-Installer/src/pages/ModelSetup.tsx @@ -163,14 +163,13 @@ export function ModelSetup({ options, setOptions, onSkip, onNext, onTestConnecti ); const handleBaseUrlOptionSelect = useCallback( - (url: string) => { - setBaseUrl(url); + (value: string) => { + const opt = template?.baseUrlOptions?.find((option) => `${option.format}:${option.url}` === value); + if (!opt) return; + setBaseUrl(opt.url); + setApiFormat(opt.format); resetTestState(); resetRemoteDiscovery(); - if (template?.baseUrlOptions) { - const opt = template.baseUrlOptions.find((o) => o.url === url.trim()); - if (opt) setApiFormat(opt.format); - } }, [template, resetTestState, resetRemoteDiscovery], ); @@ -221,7 +220,7 @@ export function ModelSetup({ options, setOptions, onSkip, onNext, onTestConnecti const baseUrlOptions = useMemo(() => { if (!template?.baseUrlOptions?.length) return []; return template.baseUrlOptions.map((opt) => ({ - value: opt.url, + value: `${opt.format}:${opt.url}`, label: opt.noteKey ? t(opt.noteKey, { defaultValue: opt.noteKey.split('.').pop() }) : opt.format.toUpperCase(), @@ -338,7 +337,7 @@ export function ModelSetup({ options, setOptions, onSkip, onNext, onTestConnecti {baseUrlOptions.length > 0 && ( { setOptions((prev) => ({ ...prev, installPath: event.target.value })); clearInstallError(); + setPathError(null); }} placeholder={t('options.pathPlaceholder')} spellCheck={false} @@ -133,7 +148,7 @@ export function Options({ {!diskSpace.sufficient && {t('options.insufficientSpace')}} )} - {error && } + {shownError && }
@@ -150,20 +165,26 @@ export function Options({ onCheckedChange={(checked) => setOptions((prev) => ({ ...prev, startMenu: checked }))} label={t('options.startMenu')} /> + { void changeScope(checked); }} + label={t('options.allUsers')} + />
- diff --git a/OpenBitFun-Installer/src/pages/ThemeSetup.tsx b/OpenBitFun-Installer/src/pages/ThemeSetup.tsx index 5ffcac51d6..5693cbdd90 100644 --- a/OpenBitFun-Installer/src/pages/ThemeSetup.tsx +++ b/OpenBitFun-Installer/src/pages/ThemeSetup.tsx @@ -35,11 +35,7 @@ export function ThemeSetup({ options, setOptions, onLaunch, onClose, previewOnly setFinishError(null); try { - try { - await invoke('set_theme_preference', { themePreference: options.themePreference }); - } catch (err) { - console.warn('Failed to persist theme preference:', err); - } + await invoke('set_theme_preference', { themePreference: options.themePreference }); if (options.launchAfterInstall) { await onLaunch(); diff --git a/OpenBitFun-Installer/src/pages/Uninstall.tsx b/OpenBitFun-Installer/src/pages/Uninstall.tsx index ee46347ba8..a3f64f5193 100644 --- a/OpenBitFun-Installer/src/pages/Uninstall.tsx +++ b/OpenBitFun-Installer/src/pages/Uninstall.tsx @@ -2,6 +2,7 @@ import { Button, PageHeader } from '@openbitfun/ui'; import { useTranslation } from 'react-i18next'; import { BrandMark } from '../components/BrandMark'; import { ProgressBar } from '../components/ProgressBar'; +import { InstallErrorPanel } from '../components/InstallErrorPanel'; interface UninstallPageProps { previewOnly?: boolean; @@ -36,7 +37,7 @@ export function UninstallPage({ {t('uninstall.installPath')} {installPath || t('uninstall.pathUnknown')}
- {uninstallError &&
{uninstallError}
} + {uninstallError && } {uninstallCompleted &&

{t('uninstall.completed')}

} {(isUninstalling || uninstallCompleted) && (
@@ -48,7 +49,7 @@ export function UninstallPage({
- {!uninstallCompleted && ( diff --git a/OpenBitFun-Installer/src/types/installer.ts b/OpenBitFun-Installer/src/types/installer.ts index 5cd6d9c550..f137b5b3f8 100644 --- a/OpenBitFun-Installer/src/types/installer.ts +++ b/OpenBitFun-Installer/src/types/installer.ts @@ -80,6 +80,7 @@ export interface RemoteModelInfo { /** Installation options sent to the Rust backend */ export interface InstallOptions { + allUsers?: boolean; installPath: string; desktopShortcut: boolean; startMenu: boolean; diff --git a/OpenBitFun-Installer/src/utils/installPathErrors.test.ts b/OpenBitFun-Installer/src/utils/installPathErrors.test.ts new file mode 100644 index 0000000000..471d7924f1 --- /dev/null +++ b/OpenBitFun-Installer/src/utils/installPathErrors.test.ts @@ -0,0 +1,13 @@ +import { expect, it } from 'vitest'; +import type { TFunction } from 'i18next'; +import { formatInstallPathError, parseInstallPathErrorCode } from './installPathErrors'; + +it('preserves the recovery diagnosis when its nested cause is access denied', () => { + const raw = 'Recovery failed: access is denied (os error 5); backup preserved at C:\\OpenBitFun\\.openbitfun-maintenance'; + const t = ((key: string) => key) as TFunction; + expect(parseInstallPathErrorCode(raw)).toBeNull(); + expect(formatInstallPathError(raw, t)).toBe('errors.maintenance.recovery'); + expect(formatInstallPathError('Update installed, but cleanup remains pending: access is denied (os error 5)', t)) + .toBe('errors.maintenance.cleanupPending'); + expect(parseInstallPathErrorCode('Access is denied (os error 5)')).toBe('directory_not_writable'); +}); diff --git a/OpenBitFun-Installer/src/utils/installPathErrors.ts b/OpenBitFun-Installer/src/utils/installPathErrors.ts index fbb175db2b..e0eef9ca20 100644 --- a/OpenBitFun-Installer/src/utils/installPathErrors.ts +++ b/OpenBitFun-Installer/src/utils/installPathErrors.ts @@ -4,7 +4,10 @@ import type { TFunction } from 'i18next'; export const INSTALL_PATH_ERROR_PREFIX = 'INSTALL_PATH::'; export function parseInstallPathErrorCode(message: string | null | undefined): string | null { - if (!message || !message.startsWith(INSTALL_PATH_ERROR_PREFIX)) return null; + if (!message) return null; + if (/recovery failed|repair required|recovery needs repair|backup.*preserv|cleanup.*pending|files.*remain/i.test(message)) return null; + if (/access is denied|permission denied|os error 5\b/i.test(message)) return 'directory_not_writable'; + if (!message.startsWith(INSTALL_PATH_ERROR_PREFIX)) return null; return message.slice(INSTALL_PATH_ERROR_PREFIX.length); } @@ -18,7 +21,16 @@ function snakeToCamelKey(s: string): string { */ export function formatInstallPathError(message: string, t: TFunction): string { const code = parseInstallPathErrorCode(message); - if (!code) return message; + if (!code) { + if (/recovery failed|repair required|recovery needs repair|backup.*preserv/i.test(message)) return t('errors.maintenance.recovery', { detail: message }); + if (/cleanup.*pending|files.*remain/i.test(message)) return t('errors.maintenance.cleanupPending'); + if (/another maintenance/i.test(message)) return t('errors.maintenance.busy'); + if (/authorization.*cancel|canceled|cancelled/i.test(message)) return t('errors.maintenance.canceled'); + if (/close the application|used by another process|os error 32/i.test(message)) return t('errors.maintenance.fileLocked'); + if (/insufficient disk space/i.test(message)) return t('errors.maintenance.space'); + if (/disk space|target drive/i.test(message)) return t('errors.maintenance.diskUnknown'); + return t('errors.maintenance.failed', { detail: message }); + } const key = `errors.installPath.${snakeToCamelKey(code)}`; const translated = t(key); if (translated === key) return message; diff --git a/docs/interactive-capabilities/README.md b/docs/interactive-capabilities/README.md index e299a17086..abd30cdf6c 100644 --- a/docs/interactive-capabilities/README.md +++ b/docs/interactive-capabilities/README.md @@ -27,9 +27,9 @@ OpenBitFun Playbook currently contains **22 features**, **22 settings pages**, a - Generated per-item interaction audit: `docs/interactive-capabilities/technical/product-control-open-audit.json` - Generated low-level audit map: `docs/interactive-capabilities/technical/tauri-command-map.json` -说明书、网站、搜索和智能体只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **702** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。 +说明书、网站、搜索和智能体只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **703** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。 -Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **702** Tauri commands remain implementation-audit evidence only. Product UI interaction sources are scanned and validated during generation and checks, but are not stored as a versioned snapshot that churns with ordinary UI changes. +Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **703** Tauri commands remain implementation-audit evidence only. Product UI interaction sources are scanned and validated during generation and checks, but are not stored as a versioned snapshot that churns with ordinary UI changes. ## 控制边界 / Control boundary diff --git a/docs/interactive-capabilities/technical/product-control-open-audit.json b/docs/interactive-capabilities/technical/product-control-open-audit.json index d3b1b3a617..4e6d6387e6 100644 --- a/docs/interactive-capabilities/technical/product-control-open-audit.json +++ b/docs/interactive-capabilities/technical/product-control-open-audit.json @@ -2332,6 +2332,7 @@ "evidence": [ "command:check_for_updates", "command:download_update", + "command:cancel_app_update_download", "command:get_pending_update", "command:install_pending_update", "command:get_app_version" diff --git a/docs/interactive-capabilities/technical/tauri-command-map.json b/docs/interactive-capabilities/technical/tauri-command-map.json index 49fa90a04e..084ef3a1cd 100644 --- a/docs/interactive-capabilities/technical/tauri-command-map.json +++ b/docs/interactive-capabilities/technical/tauri-command-map.json @@ -2,10 +2,10 @@ "schemaVersion": 2, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", "catalogDigest": "778506ae9a8c979c4cea4de5c575be1bde0517def6ee5854dba203665098fa29", - "commandCount": 702, + "commandCount": 703, "coverage": { - "commandCount": 702, - "documentedCommandCount": 637, + "commandCount": 703, + "documentedCommandCount": 638, "implementationCommandCount": 65, "implementationDigest": "1e1e2b424a391bd866585162da35a043dcca3d6f67e0a770a8f8c886007b677a" }, @@ -804,6 +804,22 @@ "signature": "fn cancel_acp_dialog_turn( state: State<'_, AppState>, request: CancelAcpDialogTurnRequest, ) -> Result<(), String>", "remoteWorkspacePolicy": "LegacyUnaudited" }, + { + "id": "cancel_app_update_download", + "moduleId": "update", + "capabilityId": "setting.application.general", + "capabilityIds": [ + "setting.application.general" + ], + "documentedItemIds": [ + "setting.application.general:manual-update" + ], + "visibility": "documented", + "rustPath": "api::update_api::cancel_app_update_download", + "sourceFile": "src/apps/desktop/src/api/update_api.rs", + "signature": "fn cancel_app_update_download(request: PendingUpdateRequest) -> Result<(), String>", + "remoteWorkspacePolicy": "WorkspaceAgnostic" + }, { "id": "cancel_dialog_turn", "moduleId": "agentic", diff --git a/scripts/collect-tauri-updater-assets.mjs b/scripts/collect-tauri-updater-assets.mjs index 1e78f00571..5e3f579f8c 100644 --- a/scripts/collect-tauri-updater-assets.mjs +++ b/scripts/collect-tauri-updater-assets.mjs @@ -1,11 +1,12 @@ #!/usr/bin/env node -import { copyFileSync, existsSync, mkdirSync, readdirSync, rmSync } from 'fs'; +import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from 'fs'; import { basename, join } from 'path'; const args = parseArgs(process.argv.slice(2)); const assetsDir = requireArg(args, 'assets-dir'); const version = requireArg(args, 'version'); const outDir = requireArg(args, 'out-dir'); +const windowsInstallerDir = args['windows-installer-dir']; const requiredPlatforms = parseListArg( args['required-platforms'] || @@ -27,6 +28,7 @@ for (const sigPath of walkFiles(assetsDir).filter((file) => file.endsWith('.sig' } const platform = inferPlatform(bundlePath); + if (windowsInstallerDir && platform?.startsWith('windows-')) continue; if (!platform) { console.warn(`[collect-updater] Skipping artifact with unknown platform: ${bundlePath}`); continue; @@ -52,6 +54,20 @@ for (const sigPath of walkFiles(assetsDir).filter((file) => file.endsWith('.sig' console.log(`[collect-updater] ${platform}: ${basename(outputPath)}`); } +if (windowsInstallerDir) { + const candidates = walkFiles(windowsInstallerDir).filter(file => file.endsWith('_windows-x86_64-installer.exe')); + if (candidates.length !== 1) fail('Expected exactly one signed transactional Windows installer'); + const source = candidates[0]; + if (!existsSync(`${source}.sig`)) fail('Transactional Windows updater signature is missing'); + if (!readFileSync(source).includes(Buffer.from('OPENBITFUN_MAINTENANCE_V1'))) { + fail('Windows installer does not support the native maintenance protocol'); + } + const output = join(outDir, updaterOutputName(source, version, 'windows-x86_64')); + copyFileSync(source, output); + copyFileSync(`${source}.sig`, `${output}.sig`); + collected['windows-x86_64'] = { source, output, signature: `${output}.sig` }; +} + const missing = requiredPlatforms.filter((platform) => !collected[platform]); if (missing.length > 0) { const found = Object.keys(collected).sort(); diff --git a/scripts/desktop-tauri-build.test.mjs b/scripts/desktop-tauri-build.test.mjs index 37c8a56bed..7955b6432e 100644 --- a/scripts/desktop-tauri-build.test.mjs +++ b/scripts/desktop-tauri-build.test.mjs @@ -22,6 +22,12 @@ const FAILED_BUILD = { status: 1 }; const DMG_ARGS = ['--target', 'x86_64-apple-darwin', '--bundles', 'app,dmg']; const ROOT = join(import.meta.dirname, '..'); +test('desktop startup leaves installation registration to its maintenance owner', () => { + const source = readFileSync(join(ROOT, 'src/apps/desktop/src/lib.rs'), 'utf8'); + assert.doesNotMatch(source, /sync_install_location_registry|Synced Tauri install-location registry/); + assert.doesNotMatch(source, /HKCU\\Software\\OpenBitFun Team\\OpenBitFun/); +}); + test('modern icon tooling is required only for macOS bundles, including bundle-only builds', () => { assert.equal(requestsMacosBundle([], 'darwin'), true); assert.equal(requestsMacosBundle(['--bundle-only', '--target=aarch64-apple-darwin'], 'darwin'), true); diff --git a/scripts/tauri-release-manifest.test.mjs b/scripts/tauri-release-manifest.test.mjs index e941222633..d22d7c3596 100644 --- a/scripts/tauri-release-manifest.test.mjs +++ b/scripts/tauri-release-manifest.test.mjs @@ -7,6 +7,31 @@ import test from 'node:test'; const root = path.resolve(import.meta.dirname, '..'); +test('Windows updater uses the signed transactional installer and rejects old protocol payloads', t => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'openbitfun-transactional-updater-')); + t.after(() => fs.rmSync(temp, { recursive: true, force: true })); + const assets = path.join(temp, 'assets'); + const manual = path.join(temp, 'manual'); + const out = path.join(temp, 'updater'); + fs.mkdirSync(assets); fs.mkdirSync(manual); + fs.writeFileSync(path.join(assets, 'OpenBitFun_1.2.3_x64-setup.exe'), 'legacy'); + fs.writeFileSync(path.join(assets, 'OpenBitFun_1.2.3_x64-setup.exe.sig'), 'legacy signature'); + const source = path.join(manual, 'OpenBitFun_1.2.3_windows-x86_64-installer.exe'); + const args = ['--assets-dir', assets, '--version', '1.2.3', '--out-dir', out, + '--required-platforms', 'windows-x86_64', '--windows-installer-dir', manual]; + fs.writeFileSync(source, 'old payload'); + fs.writeFileSync(`${source}.sig`, 'signed custom bytes'); + assert.match(run('scripts/collect-tauri-updater-assets.mjs', args).stderr, /maintenance protocol/); + const payload = Buffer.from('MZ signed payload OPENBITFUN_MAINTENANCE_V1'); + fs.writeFileSync(source, payload); + const result = run('scripts/collect-tauri-updater-assets.mjs', args); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(fs.readFileSync(path.join(out, 'OpenBitFun_1.2.3_windows-x86_64-setup.exe')), payload); + assert.equal(fs.readFileSync(path.join(out, 'OpenBitFun_1.2.3_windows-x86_64-setup.exe.sig'), 'utf8'), 'signed custom bytes'); + fs.unlinkSync(`${source}.sig`); + assert.match(run('scripts/collect-tauri-updater-assets.mjs', args).stderr, /signature is missing/); +}); + test('desktop and mobile release metadata are synchronized within their own groups', () => { const version = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version; const result = run('scripts/verify-release-version-sync.mjs', [ diff --git a/scripts/updater-maintenance.test.mjs b/scripts/updater-maintenance.test.mjs new file mode 100644 index 0000000000..0587cfb223 --- /dev/null +++ b/scripts/updater-maintenance.test.mjs @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; + +test('desktop updates use the official registry crate without a source patch', () => { + const manifest = readFileSync(new URL('../Cargo.toml', import.meta.url), 'utf8'); + const patches = manifest.split('[patch.crates-io]')[1].split(/^\[/m)[0]; + assert.doesNotMatch(patches, /^tauri-plugin-updater\s*=/m); + assert.equal(existsSync(new URL('../third_party/tauri-plugin-updater', import.meta.url)), false); + const lock = readFileSync(new URL('../Cargo.lock', import.meta.url), 'utf8'); + const updater = lock.match(/\[\[package\]\]\r?\nname = "tauri-plugin-updater"[\s\S]*?(?=\[\[package\]\]|$)/)?.[0]; + assert.ok(updater); + assert.match(updater, /source = "registry\+https:\/\/github.com\/rust-lang\/crates.io-index"/); + assert.match(updater, /checksum = "[0-9a-f]{64}"/); +}); + +test('native Windows supervisor records startup failure before authorizing application exit', { skip: process.platform !== 'win32' }, t => { + const temp = mkdtempSync(join(tmpdir(), "openbitfun-update-'测试-")); + t.after(() => rmSync(temp, { recursive: true, force: true })); + const source = readFileSync(new URL('../src/apps/desktop/src/update_maintenance/windows.rs', import.meta.url), 'utf8'); + const template = source.match(/r#"(\$ErrorActionPreference='Stop'[\s\S]*?)"#,/)?.[1]; + assert.ok(template, 'use the production supervisor source'); + const quote = value => `'${value.replaceAll("'", "''")}'`; + const receipt = join(temp, 'result.json'); + const args = { + pid: String(process.pid), version: "'2.0.0'", file: quote(join(temp, 'missing-installer.exe')), + parameters: "''", transactional: '$true', event: quote(`Local\\OpenBitFunUpdate-test-${process.pid}`), + elevate: '$false', receipt: quote(receipt), current: quote(join(temp, 'missing-application.exe')), restart: "''", + user_results: quote(join(temp, 'user-results')), machine_results: quote(join(temp, 'machine-results')), cleanup: "''", + }; + const fields = []; + const script = template.replace(/\{([a-z_]+)\}/g, (_, key) => { + assert.ok(key in args, key); fields.push(args[key]); return `\u0001${fields.length - 1}\u0002`; + }).replaceAll('{{', '{').replaceAll('}}', '}') + .replace(/\u0001(\d+)\u0002/g, (_, index) => fields[Number(index)]); + const result = spawnSync(join(process.env.SYSTEMROOT, 'System32/WindowsPowerShell/v1.0/powershell.exe'), + ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], + { encoding: 'utf8', windowsHide: true, timeout: 20000 }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /^ERROR:/); + assert.doesNotMatch(result.stdout, /^READY$/m); + const saved = JSON.parse(readFileSync(receipt, 'utf8')); + assert.equal(saved.version, '2.0.0'); assert.equal(saved.success, false); + assert.ok(saved.error); assert.equal(saved.exitCode, null); + const fakeNativeWorker = `function Start-Process { + param([string]$FilePath,[string]$ArgumentList,[switch]$PassThru,[string]$Verb) + $id=[regex]::Match($ArgumentList,'/RESULT_ID=([0-9a-f]+)').Groups[1].Value + if(!$id) { throw 'Missing native result identifier' } + foreach($directory in @(${args.user_results},${args.machine_results})) { + [IO.Directory]::CreateDirectory($directory) | Out-Null + [IO.File]::WriteAllText((Join-Path $directory ($id+'.json')), '{"exitCode":1,"error":"Cannot back up locked runtime 运行时; previous files restored"}') + } + return [pscustomobject]@{HasExited=$true;ExitCode=1} + } +`; + const detailed = spawnSync(join(process.env.SYSTEMROOT, 'System32/WindowsPowerShell/v1.0/powershell.exe'), + ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(fakeNativeWorker + script, 'utf16le').toString('base64')], + { encoding: 'utf8', windowsHide: true, timeout: 20000 }); + // EncodedCommand may retain a handled failure in its process status. The + // readiness line and persisted native outcome are the supervisor contract. + assert.ok(detailed.status === 0 || detailed.status === 1, detailed.stdout + detailed.stderr); + assert.match(detailed.stdout, /^ERROR: Cannot back up locked runtime/); + const nativeResult = JSON.parse(readFileSync(receipt, 'utf8')); + assert.equal(nativeResult.exitCode, 1); + assert.match(nativeResult.error, /previous files restored/); + assert.match(nativeResult.error, /运行时/); +}); diff --git a/src/apps/cli/AGENTS.md b/src/apps/cli/AGENTS.md index 28a83902f6..04a27680a5 100644 --- a/src/apps/cli/AGENTS.md +++ b/src/apps/cli/AGENTS.md @@ -144,6 +144,12 @@ pnpm run cli:install ## Verification +For CLI installer concurrency, publication rollback and retained recovery copies, +run `node --test src/apps/cli/install.test.mjs` from the repository root. Run on +Windows for PowerShell and Linux for Bash; fixtures never touch the real install. +For self-update network and integrity behavior, run +`cargo test --locked -p openbitfun-cli --bin openbitfun self_update::tests`. + Run the smallest checks matching the changed path: ```bash diff --git a/src/apps/cli/install.ps1 b/src/apps/cli/install.ps1 index 65912e0833..acd12b90ad 100644 --- a/src/apps/cli/install.ps1 +++ b/src/apps/cli/install.ps1 @@ -98,6 +98,7 @@ function Install-OpenBitFunCli( [string]$Destination ) { New-Item -ItemType Directory -Path $Destination -Force | Out-Null + $installLock = [IO.File]::Open((Join-Path $Destination '.openbitfun-cli-install.lock'), [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) $stageDir = Join-Path $Destination ".openbitfun-install-$([guid]::NewGuid().ToString('N'))" $stagedPrimary = Join-Path $stageDir 'openbitfun.exe' $stagedPluginHost = Join-Path $stageDir 'ext-host' @@ -105,13 +106,14 @@ function Install-OpenBitFunCli( $pluginHostTarget = Join-Path $Destination 'resources\ext-host' $primaryBackup = Join-Path $stageDir 'previous-openbitfun.exe' $pluginHostBackup = Join-Path $stageDir 'previous-ext-host' + $completed = $false $primaryBackedUp = $false $pluginHostBackedUp = $false $primaryCommitted = $false $pluginHostCommitted = $false - New-Item -ItemType Directory -Path $stageDir | Out-Null try { + New-Item -ItemType Directory -Path $stageDir | Out-Null Copy-Item -LiteralPath $PrimarySource -Destination $stagedPrimary New-Item -ItemType Directory -Path $stagedPluginHost | Out-Null Copy-Item -LiteralPath (Join-Path $PluginHostSource 'extension-host.js') -Destination $stagedPluginHost @@ -134,6 +136,7 @@ function Install-OpenBitFunCli( $pluginHostCommitted = $true Assert-Entrypoint $primaryTarget Assert-PluginHostResources $pluginHostTarget + $completed = $true } catch { $installError = $_ @@ -150,10 +153,22 @@ function Install-OpenBitFunCli( New-Item -ItemType Directory -Path (Split-Path -Parent $pluginHostTarget) -Force | Out-Null Move-Item -LiteralPath $pluginHostBackup -Destination $pluginHostTarget -Force } + $completed = $true throw "CLI installation failed; the previous OpenBitFun CLI was restored. $installError" } finally { - Remove-Item -LiteralPath $stageDir -Recurse -Force -ErrorAction SilentlyContinue + try { + $resolvedStage = [IO.Path]::GetFullPath($stageDir) + $resolvedRoot = [IO.Path]::GetFullPath($Destination).TrimEnd('\') + '\' + if (-not $resolvedStage.StartsWith($resolvedRoot, [StringComparison]::OrdinalIgnoreCase)) { + throw 'Invalid CLI staging path' + } + if ($completed) { Remove-Item -LiteralPath $resolvedStage -Recurse -Force -ErrorAction SilentlyContinue } + else { Write-Warning "CLI recovery requires repair. Backups retained at $resolvedStage" } + } + finally { + $installLock.Dispose() + } } } diff --git a/src/apps/cli/install.sh b/src/apps/cli/install.sh index 9ef664f411..f945b9ace0 100755 --- a/src/apps/cli/install.sh +++ b/src/apps/cli/install.sh @@ -157,7 +157,7 @@ assert_plugin_host_resources() { done } -install_bundle() { +install_bundle() ( local primary_source="$1" local plugin_host_source="$2" local destination="$3" @@ -169,6 +169,16 @@ install_bundle() { local failed=0 mkdir -p "$destination" + if command -v flock >/dev/null 2>&1; then + exec 9>"$destination/.openbitfun-cli-install.lock" + flock -n 9 || { echo "Error: another CLI installation or update is running." >&2; return 1; } + elif command -v shlock >/dev/null 2>&1; then + shlock -p "$$" -f "$destination/.openbitfun-cli-install.lock" || { echo "Error: another CLI installation is running." >&2; return 1; } + trap 'rm -f "$destination/.openbitfun-cli-install.lock"' EXIT + else + echo "Error: a native installation lock (flock or shlock) is required." >&2 + return 1 + fi stage_dir="$(mktemp -d "${destination}/.openbitfun-install.XXXXXX")" staged_primary="${stage_dir}/openbitfun" staged_plugin_host="${stage_dir}/ext-host" @@ -209,12 +219,17 @@ install_bundle() { fi if [ "$failed" -ne 0 ]; then - if [ "$plugin_host_committed" -eq 1 ]; then rm -rf "$plugin_host_target"; fi - if [ "$primary_committed" -eq 1 ]; then rm -f "$primary_target"; fi - if [ "$primary_backed_up" -eq 1 ]; then mv "$primary_backup" "$primary_target"; fi + local recovery_failed=0 + if [ "$plugin_host_committed" -eq 1 ]; then rm -rf "$plugin_host_target" || recovery_failed=1; fi + if [ "$primary_committed" -eq 1 ]; then rm -f "$primary_target" || recovery_failed=1; fi + if [ "$primary_backed_up" -eq 1 ]; then mv "$primary_backup" "$primary_target" || recovery_failed=1; fi if [ "$plugin_host_backed_up" -eq 1 ]; then mkdir -p "$(dirname "$plugin_host_target")" - mv "$plugin_host_backup" "$plugin_host_target" + mv "$plugin_host_backup" "$plugin_host_target" || recovery_failed=1 + fi + if [ "$recovery_failed" -ne 0 ]; then + echo "Error: CLI rollback failed; recovery copies retained at $stage_dir" >&2 + return 1 fi rm -rf "$stage_dir" echo "Error: CLI installation failed; the previous OpenBitFun CLI was restored." >&2 @@ -222,7 +237,7 @@ install_bundle() { fi rm -rf "$stage_dir" -} +) # A running daemon keeps executing the previous binary (old inode) until it is # restarted; `systemctl enable --now` does NOT restart an already-active diff --git a/src/apps/cli/install.test.mjs b/src/apps/cli/install.test.mjs new file mode 100644 index 0000000000..71e13089f9 --- /dev/null +++ b/src/apps/cli/install.test.mjs @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; + +test('CLI installer serializes writers, rolls back publication errors and preserves failed recovery copies', t => { + const temp = mkdtempSync(join(tmpdir(), 'openbitfun-cli-install-test-')); + t.after(() => rmSync(temp, { recursive: true, force: true })); + let result; + if (process.platform === 'win32') { + const source = readFileSync(new URL('./install.ps1', import.meta.url), 'utf8').split('$repoRoot = Resolve-RepoRoot')[0]; + const script = source + ` +$fixture = $env:OPENBITFUN_INSTALL_TEST_ROOT +$source = Join-Path $fixture 'source.exe' +$hostSource = Join-Path $fixture 'host' +[IO.Directory]::CreateDirectory($hostSource) | Out-Null +[IO.File]::WriteAllText($source, 'new') +[IO.File]::WriteAllText((Join-Path $hostSource 'extension-host.js'), 'new host') +$script:failPublished = $false +$script:failRestore = $false +function Assert-Entrypoint([string]$Executable) { + if ([IO.File]::ReadAllText($Executable) -ne 'new') { throw 'Invalid staged binary' } + if ($script:failPublished -and [IO.Path]::GetDirectoryName($Executable) -eq $script:destination) { throw 'Injected final verification failure' } +} +function Move-Item { + param([string]$LiteralPath, [string]$Destination, [switch]$Force) + if ($script:failRestore -and [IO.Path]::GetFileName($LiteralPath) -eq 'previous-openbitfun.exe') { throw 'Injected recovery failure' } + Microsoft.PowerShell.Management\\Move-Item @PSBoundParameters +} +function Prepare([string]$name) { + $script:destination = Join-Path $fixture $name + [IO.Directory]::CreateDirectory((Join-Path $script:destination 'resources/ext-host')) | Out-Null + [IO.File]::WriteAllText((Join-Path $script:destination 'openbitfun.exe'), 'old') + [IO.File]::WriteAllText((Join-Path $script:destination 'resources/ext-host/extension-host.js'), 'old host') + [IO.File]::WriteAllText((Join-Path $script:destination 'user.txt'), 'keep') +} +function Check([bool]$condition) { if (!$condition) { throw 'Fixture assertion failed' } } +Prepare normal +Install-OpenBitFunCli $source $hostSource $destination +Check ([IO.File]::ReadAllText((Join-Path $destination 'openbitfun.exe')) -eq 'new') +Check ([IO.File]::ReadAllText((Join-Path $destination 'user.txt')) -eq 'keep') +Prepare locked +$lock = [IO.File]::Open((Join-Path $destination '.openbitfun-cli-install.lock'), 'OpenOrCreate', 'ReadWrite', 'None') +$failed = $false +try { Install-OpenBitFunCli $source $hostSource $destination } catch { $failed = $true } finally { $lock.Dispose() } +Check $failed +Check ([IO.File]::ReadAllText((Join-Path $destination 'openbitfun.exe')) -eq 'old') +Prepare rollback +$script:failPublished = $true +$failed = $false +try { Install-OpenBitFunCli $source $hostSource $destination } catch { $failed = $true } +Check $failed +Check ([IO.File]::ReadAllText((Join-Path $destination 'openbitfun.exe')) -eq 'old') +Check ([IO.File]::ReadAllText((Join-Path $destination 'resources/ext-host/extension-host.js')) -eq 'old host') +Prepare recovery +$script:failRestore = $true +$failed = $false +try { Install-OpenBitFunCli $source $hostSource $destination } catch { $failed = $true } +Check $failed +$backup = @(Get-ChildItem -LiteralPath $destination -Directory -Force | Where-Object Name -Like '.openbitfun-install-*') +Check ($backup.Count -eq 1) +Check ([IO.File]::ReadAllText((Join-Path $backup[0].FullName 'previous-openbitfun.exe')) -eq 'old') +Write-Output 'PASS maintenance fixtures' +`; + result = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], + { encoding: 'utf8', windowsHide: true, timeout: 30000, env: { ...process.env, OPENBITFUN_INSTALL_TEST_ROOT: temp } }); + } else { + const source = readFileSync(new URL('./install.sh', import.meta.url), 'utf8').split('for arg in "$@"; do')[0]; + const script = source + ` +fixture="$OPENBITFUN_INSTALL_TEST_ROOT" +mkdir -p "$fixture/host" +printf '%s' 'new host' > "$fixture/host/extension-host.js" +cat > "$fixture/source" <<'EXE' +#!/usr/bin/env bash +if [ "\${FAIL_PUBLISHED:-0}" = 1 ] && [ "$(dirname "$0")" = "$DESTINATION" ]; then exit 1; fi +echo new +EXE +chmod +x "$fixture/source" +prepare() { + export DESTINATION="$fixture/$1" + mkdir -p "$DESTINATION/resources/ext-host" + printf old > "$DESTINATION/openbitfun" + printf 'old host' > "$DESTINATION/resources/ext-host/extension-host.js" + printf keep > "$DESTINATION/user.txt" +} +prepare normal +install_bundle "$fixture/source" "$fixture/host" "$DESTINATION" +[ "$("$DESTINATION/openbitfun" --version)" = new ] +[ "$(cat "$DESTINATION/user.txt")" = keep ] +prepare locked +exec 8>"$DESTINATION/.openbitfun-cli-install.lock" +flock -n 8 +if install_bundle "$fixture/source" "$fixture/host" "$DESTINATION"; then exit 11; fi +[ "$(cat "$DESTINATION/openbitfun")" = old ] +exec 8>&- +prepare rollback +export FAIL_PUBLISHED=1 +if install_bundle "$fixture/source" "$fixture/host" "$DESTINATION"; then exit 12; fi +[ "$(cat "$DESTINATION/openbitfun")" = old ] +[ "$(cat "$DESTINATION/resources/ext-host/extension-host.js")" = 'old host' ] +prepare recovery +mv() { case "$1" in *previous-openbitfun) return 1;; esac; command mv "$@"; } +if install_bundle "$fixture/source" "$fixture/host" "$DESTINATION"; then exit 13; fi +backup=$(find "$DESTINATION" -name previous-openbitfun -type f) +[ "$(cat "$backup")" = old ] +echo 'PASS maintenance fixtures' +`; + const file = join(temp, 'fixture.sh'); writeFileSync(file, script); + result = spawnSync('bash', [file], { encoding: 'utf8', timeout: 30000, env: { ...process.env, OPENBITFUN_INSTALL_TEST_ROOT: temp } }); + } + assert.equal(result.status, 0, result.stdout + result.stderr); + assert.match(result.stdout, /PASS maintenance fixtures/); +}); diff --git a/src/apps/cli/src/daemon/service.rs b/src/apps/cli/src/daemon/service.rs index d1ca25c1ce..779b36f194 100644 --- a/src/apps/cli/src/daemon/service.rs +++ b/src/apps/cli/src/daemon/service.rs @@ -250,10 +250,18 @@ fn uninstall_platform_service() -> Result { return Ok("Auto-start service is not installed.".to_string()); } // disable --now stops and disables; ignore failure when already stopped. - let _ = run_systemctl_user(&["disable", "--now", SYSTEMD_UNIT_NAME]); + let stopped = run_systemctl_user(&["disable", "--now", SYSTEMD_UNIT_NAME])?; + if !stopped.status.success() && platform_service_active() != Some(false) { + return Err(anyhow!( + "Cannot stop the daemon; service registration was retained" + )); + } std::fs::remove_file(&unit_path) .with_context(|| format!("remove systemd unit {}", unit_path.display()))?; - let _ = run_systemctl_user(&["daemon-reload"]); + let reloaded = run_systemctl_user(&["daemon-reload"])?; + if !reloaded.status.success() { + return Err(anyhow!("Service file was removed but systemd reload failed; run systemctl --user daemon-reload")); + } Ok("Stopped and removed the systemd user service.".to_string()) } @@ -264,10 +272,15 @@ fn uninstall_platform_service() -> Result { return Ok("Auto-start service is not installed.".to_string()); } let uid = unsafe { libc::getuid() }; - let _ = run_command( + let stopped = run_command( "launchctl", &["bootout", &format!("gui/{uid}/{LAUNCH_AGENT_LABEL}")], - ); + )?; + if !stopped.status.success() && platform_service_active() != Some(false) { + return Err(anyhow!( + "Cannot stop the LaunchAgent; its definition was retained" + )); + } std::fs::remove_file(&plist_path) .with_context(|| format!("remove LaunchAgent {}", plist_path.display()))?; Ok("Stopped and removed the LaunchAgent.".to_string()) @@ -283,7 +296,18 @@ fn uninstall_platform_service() -> Result { #[cfg(all(unix, not(target_os = "macos")))] fn platform_service_active() -> Option { let output = run_systemctl_user(&["is-active", SYSTEMD_UNIT_NAME]).ok()?; - Some(output.status.success()) + systemd_activity(output.status.success(), &output.stdout) +} + +#[cfg(any(test, all(unix, not(target_os = "macos"))))] +fn systemd_activity(success: bool, stdout: &[u8]) -> Option { + if success { + return Some(true); + } + match String::from_utf8_lossy(stdout).trim() { + "inactive" | "failed" | "unknown" => Some(false), + _ => None, + } } #[cfg(target_os = "macos")] @@ -294,7 +318,14 @@ fn platform_service_active() -> Option { &["print", &format!("gui/{uid}/{LAUNCH_AGENT_LABEL}")], ) .ok()?; - Some(output.status.success()) + if output.status.success() { + return Some(true); + } + // A missing user GUI domain or denied query is an unknown state, not proof + // that the daemon stopped. Retain its registration in that case. + run_command("launchctl", &["print", &format!("gui/{uid}")]) + .ok() + .and_then(|domain| domain.status.success().then_some(false)) } #[cfg(not(unix))] @@ -330,7 +361,9 @@ pub(super) fn install_service_for_provisioning() -> Result { let executable = current_exe_path()?; let message = install_platform_service(&executable)?; if let Err(error) = ensure_platform_service_persistent() { - let _ = uninstall_platform_service(); + if let Err(recovery) = uninstall_platform_service() { + return Err(error.context(format!("Daemon provisioning rollback failed; service registration was retained: {recovery:#}"))); + } return Err(error); } Ok(message) @@ -400,6 +433,15 @@ pub(crate) fn print_status() -> Result<()> { mod tests { use super::*; + #[test] + fn service_query_failure_does_not_claim_the_daemon_is_stopped() { + assert_eq!(systemd_activity(false, b""), None); + assert_eq!(systemd_activity(false, b"deactivating\n"), None); + assert_eq!(systemd_activity(false, b"inactive\n"), Some(false)); + assert_eq!(systemd_activity(false, b"failed\n"), Some(false)); + assert_eq!(systemd_activity(true, b"active\n"), Some(true)); + } + #[test] fn systemd_unit_runs_daemon_run_and_restarts_on_failure() { let unit = render_systemd_unit(Path::new("/home/u/.local/bin/openbitfun")); diff --git a/src/apps/cli/src/self_update.rs b/src/apps/cli/src/self_update.rs index c911c98cfc..260b931a91 100644 --- a/src/apps/cli/src/self_update.rs +++ b/src/apps/cli/src/self_update.rs @@ -88,7 +88,7 @@ pub(crate) enum UpdateOutcome { } pub(crate) async fn run_manual(check_only: bool) -> Result { - let _lock = if check_only { + let _lock = if check_only || current_platform_key().is_none() { None } else { Some(InstallLock::acquire()?) @@ -241,77 +241,44 @@ fn report_background_failure() { /// Guards against two `openbitfun update` runs swapping the binary at once. struct InstallLock { - path: PathBuf, + _file: fs::File, } impl InstallLock { fn path() -> Option { - crate::config::CliConfig::config_dir() - .ok() - .map(|dir| dir.join("update.lock")) + std::env::current_exe() + .ok()? + .parent() + .map(|parent| parent.join(".openbitfun-cli-install.lock")) } - /// A lock older than this is treated as abandoned by a killed process. - const STALE_AFTER: Duration = Duration::from_secs(60 * 60); - fn is_held() -> bool { - let Some(path) = Self::path() else { - return false; - }; - let Ok(modified) = fs::metadata(&path).and_then(|meta| meta.modified()) else { - return false; - }; - SystemTime::now() - .duration_since(modified) - .is_ok_and(|age| age < Self::STALE_AFTER) + Self::acquire().is_err() } fn acquire() -> Result { - let path = - Self::path().ok_or_else(|| anyhow!("cannot resolve the CLI update lock location"))?; - if let Some(parent) = path.parent() { - let _ = fs::create_dir_all(parent); - } - // `create_new` is the whole point: a check-then-write leaves a window in - // which two `openbitfun update` processes both see no lock, and interleaving - // their backup/stage/swap renames can leave no working binary at all. - // Only a stale lock is cleared, and only then is the create retried. - match fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&path) + let path = Self::path() + .ok_or_else(|| anyhow!("cannot resolve the CLI installation lock location"))?; + let mut options = fs::OpenOptions::new(); + options.read(true).write(true).create(true).truncate(false); + #[cfg(windows)] { - Ok(mut file) => { - use std::io::Write as _; - let _ = write!(file, "{}", std::process::id()); - Ok(Self { path }) - } - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { - if Self::is_held() { - return Err(anyhow!( - "another OpenBitFun CLI update is already running ({})", - path.display() - )); - } - fs::remove_file(&path) - .with_context(|| format!("clear stale lock {}", path.display()))?; - let mut file = fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&path) - .with_context(|| format!("create {}", path.display()))?; - use std::io::Write as _; - let _ = write!(file, "{}", std::process::id()); - Ok(Self { path }) - } - Err(error) => Err(error).with_context(|| format!("create {}", path.display())), + use std::os::windows::fs::OpenOptionsExt; + options.share_mode(0); } - } -} - -impl Drop for InstallLock { - fn drop(&mut self) { - let _ = fs::remove_file(&self.path); + let file = options.open(&path).with_context(|| { + format!( + "open installation lock {}; check directory permissions", + path.display() + ) + })?; + file.try_lock().with_context(|| { + format!( + "another OpenBitFun CLI installation or update is already running ({})", + path.display() + ) + })?; + Ok(Self { _file: file }) } } @@ -1021,6 +988,9 @@ fn install_archive(archive: &[u8], current_exe: &Path) -> Result<()> { )) }; + // Failed rollback must retain the only recovery copies instead of allowing + // TempDir's destructor to erase them during unwinding. + let retained_stage = stage.keep(); fs::rename(current_exe, &primary_backup).context("back up current openbitfun")?; if plugin_host_existed { if let Err(error) = fs::rename(&plugin_host_target, &plugin_host_backup) { @@ -1116,6 +1086,12 @@ fn install_archive(archive: &[u8], current_exe: &Path) -> Result<()> { rollback_failures.join("; ") ))); } + if let Err(error) = fs::remove_dir_all(&retained_stage) { + tracing::warn!( + "CLI update completed; cleanup remains at {}: {error}", + retained_stage.display() + ); + } Ok(()) } diff --git a/src/apps/desktop/AGENTS.md b/src/apps/desktop/AGENTS.md index 0f048bd37b..369333630c 100644 --- a/src/apps/desktop/AGENTS.md +++ b/src/apps/desktop/AGENTS.md @@ -103,6 +103,25 @@ The `devtools` Cargo feature exists for debugging UI/UX in the desktop app. When ## Verification +Application updates use the official `tauri-plugin-updater` for discovery, +download and signature verification. `src/update_maintenance` owns native +authorization, publication/recovery and Windows process handoff; keep those +behaviors out of third-party source patches. Installation of a verified cached +package must not require another update-server request. + +For updater adapter and persisted-record changes, run: + +```bash +cargo test --locked -p openbitfun-desktop --no-default-features --lib update_maintenance +cargo test --locked -p openbitfun-desktop --no-default-features --lib api::update_api::tests +node --test scripts/updater-maintenance.test.mjs scripts/market/market-image-context.test.mjs +pnpm --dir src/web-ui exec vitest run src/infrastructure/api/service-api/SystemAPI.test.ts src/infrastructure/update +``` + +The filesystem/process tests use disposable fixtures. Real UAC and alternate +administrator accounts require a clean Windows VM; native macOS authorization +and Linux package/AppImage replacement require their respective platforms. + For application icon resources and packaging, run `pnpm run generate-brand-assets` and `node --test scripts/generate-brand-assets.test.mjs scripts/macos-icon-bundle.test.mjs scripts/desktop-tauri-build.test.mjs`. The [brand guide](../../../assets/brand/README.md#macos-icons) owns native exports. diff --git a/src/apps/desktop/src/api/system_api.rs b/src/apps/desktop/src/api/system_api.rs index d48773fc54..d7574065fd 100644 --- a/src/apps/desktop/src/api/system_api.rs +++ b/src/apps/desktop/src/api/system_api.rs @@ -1,19 +1,16 @@ //! System API use std::path::Path; -use std::sync::{Arc, Mutex, OnceLock}; +use std::sync::{Mutex, OnceLock}; use crate::api::app_state::AppState; use crate::startup_trace::DesktopStartupTrace; use openbitfun_core::service::system; use serde::{Deserialize, Serialize}; -use tauri::{AppHandle, Emitter, Manager, Position, Size, State}; +use tauri::{AppHandle, Manager, Position, Size, State}; use tauri_plugin_opener::OpenerExt; use tauri_plugin_updater::UpdaterExt; -/// Emitted during `install_update` download; matches `installUpdateWithProgress` / frontend listener. -const UPDATE_PROGRESS_EVENT: &str = "openbitfun-update-progress"; - /// Updater origins, in configured (fallback) order. Kept in step with /// `scripts/desktop-tauri-build.mjs`, which bakes the same pair into the bundle. const GITHUB_UPDATER_ENDPOINT: &str = match option_env!("OPENBITFUN_UPDATER_PRIMARY_ENDPOINT") { @@ -241,18 +238,11 @@ pub(super) async fn ranked_updater( app.updater_builder() } }; - crate::api::update_api::with_update_exit_cleanup(builder, app) + crate::api::update_api::configure_updater(builder) .build() .map_err(|error| error.to_string()) } -#[derive(Clone, serde::Serialize)] -#[serde(rename_all = "camelCase")] -struct UpdateProgressPayload { - downloaded: u64, - total: Option, -} - #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SystemInfoResponse { @@ -345,53 +335,14 @@ pub struct InstallUpdateRequest {} #[tauri::command] pub async fn install_update(app: AppHandle, request: InstallUpdateRequest) -> Result<(), String> { let _ = request; - let updater = ranked_updater(&app).await?; - let update = updater.check().await.map_err(|e| e.to_string())?; - let Some(update) = update else { - return Err("No update available".to_string()); - }; - let app_handle = app.clone(); - let progress = Arc::new(Mutex::new((0u64, None::))); - let progress_chunk = Arc::clone(&progress); - let app_chunk = app_handle.clone(); - let bytes = update - .download( - move |chunk_len, content_len| { - let (downloaded, total) = { - let mut g = progress_chunk - .lock() - .expect("update progress mutex poisoned"); - g.0 = g.0.saturating_add(chunk_len as u64); - g.1 = g.1.or(content_len); - (g.0, g.1) - }; - let _ = app_chunk.emit( - UPDATE_PROGRESS_EVENT, - UpdateProgressPayload { downloaded, total }, - ); - }, - { - let app_done = app_handle.clone(); - let progress_done = Arc::clone(&progress); - move || { - let (downloaded, total) = { - let g = progress_done - .lock() - .expect("update progress mutex poisoned"); - (g.0, g.1) - }; - let _ = app_done.emit( - UPDATE_PROGRESS_EVENT, - UpdateProgressPayload { downloaded, total }, - ); - } - }, - ) - .await - .map_err(|e| e.to_string())?; - tokio::task::spawn_blocking(move || update.install(bytes).map_err(|e| e.to_string())) - .await - .map_err(|e| e.to_string())? + let pending = super::update_api::download_update(app.clone(), Default::default()).await?; + super::update_api::install_pending_update( + app, + super::update_api::InstallPendingUpdateRequest { + version: pending.version, + }, + ) + .await } #[derive(Debug, Deserialize)] diff --git a/src/apps/desktop/src/api/update_api.rs b/src/apps/desktop/src/api/update_api.rs index ad3ece25e0..80adeb12e6 100644 --- a/src/apps/desktop/src/api/update_api.rs +++ b/src/apps/desktop/src/api/update_api.rs @@ -5,13 +5,41 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::{io::Write, path::Path, sync::OnceLock}; use tauri::{AppHandle, Emitter, Manager}; -use tauri_plugin_updater::{Update, UpdaterBuilder, UpdaterExt}; +use tauri_plugin_updater::UpdaterBuilder; const PROGRESS_EVENT: &str = "openbitfun-update-progress"; const RECORD_NAME: &str = "pending.json"; +const RECEIPT_NAME: &str = "install-result.json"; // Serialize downloads and installs at the host, including calls from multiple windows. -static UPDATE: OnceLock>> = OnceLock::new(); +static UPDATE: OnceLock> = OnceLock::new(); +static CANCEL_DOWNLOAD: OnceLock>>> = + OnceLock::new(); + +struct DownloadCancellation; +impl Drop for DownloadCancellation { + fn drop(&mut self) { + if let Ok(mut sender) = CANCEL_DOWNLOAD.get_or_init(Default::default).lock() { + sender.take(); + } + } +} + +#[tauri::command] +pub async fn cancel_app_update_download(request: PendingUpdateRequest) -> Result<(), String> { + let _ = request; + let sender = CANCEL_DOWNLOAD + .get_or_init(Default::default) + .lock() + .map_err(|_| "Download cancellation state is unavailable".to_string())? + .take(); + if let Some(sender) = sender { + let _ = sender.send(()); + Ok(()) + } else { + Err("No cancellable update download is running; package finalization may already be in progress".into()) + } +} #[derive(Debug, Deserialize, Default)] #[serde(rename_all = "camelCase")] @@ -47,6 +75,8 @@ pub struct InstallPendingUpdateRequest { #[serde(rename_all = "camelCase")] pub struct PendingUpdateResponse { pub version: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub install_error: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -56,12 +86,15 @@ struct PendingUpdateRecord { platform: String, signature: String, sha256: String, + #[serde(default)] + package_kind: Option, } impl PendingUpdateRecord { fn response(&self) -> PendingUpdateResponse { PendingUpdateResponse { version: self.version.clone(), + install_error: None, } } @@ -82,6 +115,56 @@ fn platform() -> String { format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH) } +fn package_kind() -> &'static str { + use tauri::utils::config::BundleType; + match tauri::utils::platform::bundle_type() { + Some(BundleType::Deb) => "deb", + Some(BundleType::Rpm) => "rpm", + Some(BundleType::AppImage) => "appimage", + Some(BundleType::App) => "app", + Some(BundleType::Nsis) => "nsis", + Some(BundleType::Msi) => "msi", + _ => "unbundled", + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct InstallationReceipt { + version: String, + success: bool, + #[serde(default)] + error: Option, +} + +fn pending_response( + dir: &Path, + record: PendingUpdateRecord, +) -> Result { + let mut response = record.response(); + match std::fs::read(dir.join(RECEIPT_NAME)) { + Ok(bytes) => { + let receipt: InstallationReceipt = serde_json::from_slice(&bytes).map_err(|e| { + format!("Cannot read update installation result; preserved for repair: {e}") + })?; + // A matching pending record is offered only while the running + // version is older. Even exit code zero must not hide that fact. + if receipt.version == record.version { + response.install_error = Some(receipt.error.unwrap_or_else(|| { + if receipt.success { + "The installer finished, but the running version is still older; restart or repair the installation".into() + } else { + "The previous update installation failed; retry or reinstall manually".into() + } + })); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(format!("Cannot read update installation result: {error}")), + } + Ok(response) +} + fn cache_dir(app: &AppHandle) -> Result { if crate::e2e_storage_guard_enabled() { return Ok(openbitfun_core::infrastructure::get_path_manager_arc() @@ -110,6 +193,9 @@ fn read_record( if !record.applies_to(current, platform)? { return Ok(None); } + if record.package_kind.as_deref() != Some(package_kind()) { + return Err("Update package belongs to another installation format or has legacy metadata; download it again".into()); + } let package = dir.join(record.package_name()?); if !package.is_file() { return Err("Pending update package is missing; download the update again".into()); @@ -142,8 +228,8 @@ fn verify_package(bytes: &[u8], record: &PendingUpdateRecord, pubkey: &str) -> R "Pending update signature verification failed: package checksum mismatch".into(), ); } - // Update::install does not verify bytes. Re-verify persisted bytes against - // the bundled trust root, never a public key supplied by the cache record. + // The official downloader verified the original transfer. Re-verify cached + // bytes against the bundled trust root before handing them to our adapter. let decode = |value: &str| -> Result { let bytes = base64::engine::general_purpose::STANDARD .decode(value.trim()) @@ -159,16 +245,29 @@ fn verify_package(bytes: &[u8], record: &PendingUpdateRecord, pubkey: &str) -> R .map_err(|e| format!("Update signature verification failed: {e}")) } -pub(super) fn with_update_exit_cleanup(builder: UpdaterBuilder, app: &AppHandle) -> UpdaterBuilder { - let app = app.clone(); - builder.on_before_exit(move || { - // The updater calls this on Windows immediately before launching the - // installer. Install commands run on a blocking thread so async cleanup - // can finish without blocking a Tokio worker or the UI thread. - crate::save_main_window_state(&app, "install_update"); - tauri::async_runtime::block_on(crate::perform_process_exit_cleanup()); - crate::crash_diagnostics::mark_clean_shutdown("install_update"); - app.cleanup_before_exit(); +pub(super) fn configure_updater(builder: UpdaterBuilder) -> UpdaterBuilder { + // Public official-plugin API: bound stalls without a total download deadline. + builder.configure_client(|client| { + client + .connect_timeout(std::time::Duration::from_secs(20)) + .read_timeout(std::time::Duration::from_secs(60)) + }) +} + +#[cfg(any(windows, test))] +pub(crate) fn windows_machine_install(directory: &Path) -> bool { + let read = |path: &Path| { + std::fs::read(path) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + }; + read(&directory.join(".openbitfun-installed.json")).is_some_and(|value| { + value.get("allUsers").and_then(serde_json::Value::as_bool) == Some(true) + }) || read(&directory.join(".openbitfun-maintenance/journal.json")).is_some_and(|value| { + value + .pointer("/integrations/registry/machine") + .and_then(serde_json::Value::as_bool) + == Some(true) }) } @@ -181,7 +280,9 @@ pub async fn get_pending_update( let dir = cache_dir(&app)?; let current = app.package_info().version.clone(); tokio::task::spawn_blocking(move || { - read_record(&dir, ¤t, &platform()).map(|r| r.map(|r| r.response())) + read_record(&dir, ¤t, &platform())? + .map(|r| pending_response(&dir, r)) + .transpose() }) .await .map_err(|e| e.to_string())? @@ -192,43 +293,58 @@ pub async fn download_update( app: AppHandle, request: DownloadUpdateRequest, ) -> Result { - let mut state = UPDATE + let _operation = UPDATE .get_or_init(Default::default) .try_lock() .map_err(|_| "An update operation is already in progress".to_string())?; - let updater = super::system_api::ranked_updater(&app).await?; - let update = updater - .check() - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "No update available".to_string())?; - request.validate_version(&update.version)?; - let mut downloaded = 0u64; - let bytes = update - .download( - |chunk, total| { - downloaded = downloaded.saturating_add(chunk as u64); - let _ = app.emit( - PROGRESS_EVENT, - serde_json::json!({ "downloaded": downloaded, "total": total }), - ); - }, - || {}, - ) - .await - .map_err(|e| e.to_string())?; + let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel(); + *CANCEL_DOWNLOAD + .get_or_init(Default::default) + .lock() + .map_err(|_| "Download cancellation state is unavailable".to_string())? = Some(cancel_tx); + let cancellation = DownloadCancellation; + let transfer = async { + let updater = super::system_api::ranked_updater(&app).await?; + let update = updater + .check() + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "No update available".to_string())?; + request.validate_version(&update.version)?; + let mut downloaded = 0u64; + let bytes = update + .download( + |chunk, total| { + downloaded = downloaded.saturating_add(chunk as u64); + let _ = app.emit( + PROGRESS_EVENT, + serde_json::json!({ "downloaded": downloaded, "total": total }), + ); + }, + || {}, + ) + .await + .map_err(|e| e.to_string())?; + Ok::<_, String>((update, bytes)) + }; + let (update, bytes) = tokio::select! { + result = transfer => result?, + _ = cancel_rx => return Err("Update download canceled".into()), + }; + // Finalization cannot be canceled midway through committing pending.json. + drop(cancellation); let record = PendingUpdateRecord { version: update.version.clone(), platform: platform(), signature: update.signature.clone(), sha256: format!("{:x}", Sha256::digest(&bytes)), + package_kind: Some(package_kind().into()), }; let response = record.response(); let dir = cache_dir(&app)?; tokio::task::spawn_blocking(move || save_package(&dir, &record, &bytes)) .await .map_err(|e| e.to_string())??; - *state = Some(update); Ok(response) } @@ -237,11 +353,12 @@ pub async fn install_pending_update( app: AppHandle, request: InstallPendingUpdateRequest, ) -> Result<(), String> { - let mut state = UPDATE + let _operation = UPDATE .get_or_init(Default::default) .try_lock() .map_err(|_| "An update operation is already in progress".to_string())?; let dir = cache_dir(&app)?; + let receipt = dir.join(RECEIPT_NAME); let current = app.package_info().version.clone(); let pubkey = app .config() @@ -267,27 +384,29 @@ pub async fn install_pending_update( .await .map_err(|e| e.to_string())??; - if state.as_ref().is_none_or(|u| u.version != record.version) { - // Tauri 2.10 cannot deserialize an Update. After an application restart, - // obtain its platform installer context through the configured endpoint. - // No package is downloaded; the user's already verified version stays pinned. - let updater = with_update_exit_cleanup(app.updater_builder(), &app) - .timeout(std::time::Duration::from_secs(20)) - .version_comparator(|_, _| true) - .build() - .map_err(|e| e.to_string())?; - let mut update = updater.check().await - .map_err(|e| format!("Cannot restore update installer metadata; connect to the update server and retry: {e}"))? - .ok_or_else(|| "Update installer metadata is unavailable; retry later".to_string())?; - update.version = record.version.clone(); - update.signature = record.signature.clone(); - *state = Some(update); + let maintenance_app = app.clone(); + tokio::task::spawn_blocking(move || { + crate::update_maintenance::install( + &maintenance_app, + &bytes, + &record.version, + record.package_kind.as_deref().unwrap_or("unbundled"), + &receipt, + ) + }) + .await + .map_err(|e| e.to_string())??; + #[cfg(windows)] + { + // A ready supervisor now owns the handoff. It waits for this process, + // retains post-exit errors and restarts with the original user's token. + crate::save_main_window_state(&app, "install_update"); + crate::perform_process_exit_cleanup().await; + crate::crash_diagnostics::mark_clean_shutdown("install_update"); + app.cleanup_before_exit(); + std::process::exit(0); } - let update = state.as_ref().expect("update context initialized").clone(); - tokio::task::spawn_blocking(move || update.install(bytes).map_err(|e| e.to_string())) - .await - .map_err(|e| e.to_string())??; - // Windows exits inside install(); macOS and Linux return after replacement. + #[cfg(not(windows))] super::system_api::restart_app(app, Default::default()).await } @@ -295,6 +414,26 @@ pub async fn install_pending_update( mod tests { use super::*; + #[test] + fn machine_installs_request_elevation_even_in_writable_custom_directories() { + let root = tempfile::tempdir().unwrap(); + assert!(!windows_machine_install(root.path())); + std::fs::write( + root.path().join(".openbitfun-installed.json"), + br#"{"allUsers":true}"#, + ) + .unwrap(); + assert!(windows_machine_install(root.path())); + std::fs::remove_file(root.path().join(".openbitfun-installed.json")).unwrap(); + std::fs::create_dir(root.path().join(".openbitfun-maintenance")).unwrap(); + std::fs::write( + root.path().join(".openbitfun-maintenance/journal.json"), + br#"{"integrations":{"registry":{"machine":true}}}"#, + ) + .unwrap(); + assert!(windows_machine_install(root.path())); + } + #[test] fn download_request_preserves_legacy_payloads_and_checks_the_selected_version() { let legacy: DownloadUpdateRequest = serde_json::from_str("{}").unwrap(); @@ -317,6 +456,7 @@ mod tests { platform: "test".into(), signature: "invalid".into(), sha256: format!("{:x}", Sha256::digest(bytes)), + package_kind: Some(package_kind().into()), } } @@ -407,4 +547,54 @@ mod tests { serde_json::from_slice(&serde_json::to_vec(&decoded).unwrap()).unwrap(); assert_eq!(roundtrip.version, "2.0.0"); } + + #[test] + fn legacy_and_other_package_formats_are_preserved_for_explicit_redownload() { + let dir = tempfile::tempdir().unwrap(); + let mut record = record(b"package"); + for kind in [None, Some("other-format".into())] { + record.package_kind = kind; + save_package(dir.path(), &record, b"package").unwrap(); + assert!(read_record(dir.path(), &"1.0.0".parse().unwrap(), "test") + .unwrap_err() + .contains("installation format")); + assert!(dir.path().join(record.package_name().unwrap()).is_file()); + } + let mut legacy = serde_json::to_value(&record).unwrap(); + legacy.as_object_mut().unwrap().remove("packageKind"); + let decoded: PendingUpdateRecord = serde_json::from_value(legacy).unwrap(); + assert!(decoded.package_kind.is_none()); + } + + #[test] + fn installation_receipts_report_failures_and_a_still_running_old_version() { + let dir = tempfile::tempdir().unwrap(); + for success in [false, true] { + std::fs::write( + dir.path().join(RECEIPT_NAME), + serde_json::to_vec(&serde_json::json!({"version":"2.0.0", "success":success})) + .unwrap(), + ) + .unwrap(); + assert!(pending_response(dir.path(), record(b"package")) + .unwrap() + .install_error + .is_some()); + } + std::fs::write( + dir.path().join(RECEIPT_NAME), + br#"{"version":"1.5.0", "success":false,"error":"old failure"}"#, + ) + .unwrap(); + assert!(pending_response(dir.path(), record(b"package")) + .unwrap() + .install_error + .is_none()); + std::fs::write(dir.path().join(RECEIPT_NAME), b"invalid").unwrap(); + assert!(pending_response(dir.path(), record(b"package")).is_err()); + assert_eq!( + std::fs::read(dir.path().join(RECEIPT_NAME)).unwrap(), + b"invalid" + ); + } } diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index fe978a26ed..4c25ef279f 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -48,6 +48,7 @@ pub mod runtime; pub mod sleep_prevention; pub mod startup_trace; pub mod tray; +mod update_maintenance; mod webview_recovery; mod window_material; mod window_state_support; @@ -904,59 +905,8 @@ pub async fn run() { ); } - // Ensure the Tauri NSIS registry install-location key points to the - // actual install directory, so that auto-updates respect the custom - // install path chosen during initial installation. - #[cfg(target_os = "windows")] - { - use std::os::windows::process::CommandExt; - const CREATE_NO_WINDOW: u32 = 0x08000000; - let step_started = Instant::now(); - - if let Ok(exe) = std::env::current_exe() { - if let Some(install_dir) = exe.parent() { - let dir_str = install_dir.to_string_lossy(); - let need_update = - match std::process::Command::new("reg") - .args([ - "query", - r"HKCU\Software\OpenBitFun Team\OpenBitFun", - "/ve", - ]) - .creation_flags(CREATE_NO_WINDOW) - .output() - { - Ok(output) => { - let stdout = String::from_utf8_lossy(&output.stdout); - !stdout.contains(dir_str.as_ref()) - } - Err(_) => true, - }; - if need_update { - let _ = std::process::Command::new("reg") - .args([ - "add", - r"HKCU\Software\OpenBitFun Team\OpenBitFun", - "/ve", - "/d", - &dir_str, - "/f", - ]) - .creation_flags(CREATE_NO_WINDOW) - .status(); - log::info!( - "Synced Tauri install-location registry to: {}", - install_dir.display() - ); - } - } - } - startup_trace.record_elapsed_step( - "native_setup", - "sync_install_location_registry", - step_started, - ); - } + // Installation scope and registration belong to the installer. + // Development and portable launches must not rewrite installed paths. for step in startup_timings.steps() { log::debug!( "Desktop startup step completed: step={}, duration_ms={}", @@ -1859,6 +1809,7 @@ pub async fn run() { check_for_updates, install_update, api::update_api::download_update, + api::update_api::cancel_app_update_download, api::update_api::get_pending_update, api::update_api::install_pending_update, api::system_api::open_html_file_in_browser, diff --git a/src/apps/desktop/src/update_maintenance/linux.rs b/src/apps/desktop/src/update_maintenance/linux.rs new file mode 100644 index 0000000000..c3f0e99ce7 --- /dev/null +++ b/src/apps/desktop/src/update_maintenance/linux.rs @@ -0,0 +1,117 @@ +use openbitfun_services_core::process_manager::create_command; +use std::{fs, io, io::Write, path::Path, process::Stdio}; +use tauri::{AppHandle, Manager}; + +pub(super) fn install(app: &AppHandle, bytes: &[u8], kind: &str) -> io::Result<()> { + match kind { + "deb" | "rpm" => install_package(bytes, kind), + "appimage" => { + let target = app + .env() + .appimage + .ok_or_else(|| io::Error::other("AppImage path is unavailable"))?; + install_appimage(bytes, Path::new(&target)) + } + _ => Err(io::Error::other( + "This Linux installation format requires a manual update", + )), + } +} + +fn install_appimage(bytes: &[u8], target: &Path) -> io::Result<()> { + let parent = target + .parent() + .ok_or_else(|| io::Error::other("AppImage parent is unavailable"))?; + let name = target + .file_name() + .ok_or_else(|| io::Error::other("AppImage name is unavailable"))?; + let lock = fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(parent.join(format!(".{}.update.lock", name.to_string_lossy())))?; + lock.try_lock() + .map_err(|_| io::Error::other("Another application update is running"))?; + let stage = tempfile::Builder::new() + .prefix(".openbitfun-appimage-update-") + .tempdir_in(parent)?; + let new = stage.path().join("new.AppImage"); + super::publication::stage_appimage(bytes, &new)?; + fs::set_permissions(&new, fs::metadata(target)?.permissions())?; + fs::rename(new, target)?; + if let Err(error) = stage.close() { + log::warn!("Application updated; staging cleanup failed: {error}"); + } + Ok(()) +} + +fn install_package(bytes: &[u8], kind: &str) -> io::Result<()> { + if !(kind == "deb" && bytes.starts_with(b"!\n") + || kind == "rpm" && bytes.starts_with(&[0xed, 0xab, 0xee, 0xdb])) + { + return Err(io::Error::other( + "Update package does not match the installed package format", + )); + } + let stage = tempfile::Builder::new() + .prefix("openbitfun-package-update-") + .tempdir()?; + let package = stage.path().join(format!("package.{kind}")); + fs::write(&package, bytes)?; + let (program, argument) = if kind == "deb" { + ("dpkg", "-i") + } else { + ("rpm", "-U") + }; + match create_command("pkexec") + .args([program, argument]) + .arg(&package) + .output() + { + Ok(output) if output.status.success() => return Ok(()), + // Cancellation/failure must not open a second authorization prompt. + Ok(output) => { + return Err(io::Error::other(format!( + "Package authorization or installation failed: {}", + String::from_utf8_lossy(&output.stderr) + ))) + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + let password = match create_command("zenity") + .args(["--password", "--title=OpenBitFun update"]) + .output() + { + Ok(output) => output, + Err(error) if error.kind() == io::ErrorKind::NotFound => create_command("kdialog") + .args(["--password", "Authorize the OpenBitFun update"]) + .output()?, + Err(error) => return Err(error), + }; + if !password.status.success() { + return Err(io::Error::other("Package authorization was canceled")); + } + let mut child = create_command("sudo") + .args(["-S", "-p", "", "-k", program, argument]) + .arg(&package) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + if let Some(mut input) = child.stdin.take() { + input.write_all(&password.stdout)?; + if !password.stdout.ends_with(b"\n") { + input.write_all(b"\n")?; + } + } + // wait_with_output drains both pipes while the package manager runs. + let output = child.wait_with_output()?; + if !output.status.success() { + return Err(io::Error::other(format!( + "Package installation failed: {}", + String::from_utf8_lossy(&output.stderr) + ))); + } + Ok(()) +} diff --git a/src/apps/desktop/src/update_maintenance/macos.rs b/src/apps/desktop/src/update_maintenance/macos.rs new file mode 100644 index 0000000000..47583abbd6 --- /dev/null +++ b/src/apps/desktop/src/update_maintenance/macos.rs @@ -0,0 +1,105 @@ +use openbitfun_services_core::process_manager::create_command; +use std::{fs, io, path::Path}; + +pub(super) fn install(bytes: &[u8]) -> io::Result<()> { + let executable = std::env::current_exe()?; + // The official public helper understands the Contents/MacOS bundle layout. + let target = tauri_plugin_updater::extract_path_from_executable(&executable) + .map_err(|error| io::Error::other(error.to_string()))?; + if !target + .extension() + .is_some_and(|extension| extension == "app") + { + return Err(io::Error::other( + "This macOS installation requires a manual update", + )); + } + let parent = target + .parent() + .ok_or_else(|| io::Error::other("Application parent is unavailable"))?; + let (stage, authorize) = match tempfile::Builder::new() + .prefix(".openbitfun-update-") + .tempdir_in(parent) + { + Ok(stage) => (stage, false), + Err(error) if error.kind() == io::ErrorKind::PermissionDenied => { + (tempfile::tempdir()?, true) + } + Err(error) => return Err(error), + }; + let unpack = stage.path().join("unpack"); + fs::create_dir(&unpack)?; + // tar validates traversal and symlink parents during unpack. + tar::Archive::new(flate2::read::GzDecoder::new(bytes)).unpack(&unpack)?; + let apps = fs::read_dir(&unpack)? + .map(|entry| entry.map(|entry| entry.path())) + .collect::>>()? + .into_iter() + .filter(|path| { + path.extension().is_some_and(|extension| extension == "app") + && fs::symlink_metadata(path).is_ok_and(|metadata| metadata.is_dir()) + }) + .collect::>(); + if apps.len() != 1 { + return Err(io::Error::other( + "Update archive must contain one application bundle", + )); + } + let new_app = &apps[0]; + let name = executable + .file_name() + .ok_or_else(|| io::Error::other("Application binary name is unavailable"))?; + if !new_app.join("Contents/MacOS").join(name).is_file() { + return Err(io::Error::other( + "Update bundle is missing the application executable", + )); + } + if !authorize { + return super::publication::replace_bundle(&target, new_app, stage); + } + // osascript owns its main thread and the native authorization dialog. The + // Desktop worker receives compile/dispatch/authorization failures normally. + let shell = authorized_script(parent, new_app, &target); + let apple = format!( + "do shell script \"{}\" with administrator privileges", + shell + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") + ); + let result = create_command("/usr/bin/osascript") + .args(["-e", &apple]) + .output()?; + if !result.status.success() { + return Err(io::Error::other(format!( + "Authorized application replacement failed: {}", + String::from_utf8_lossy(&result.stderr) + ))); + } + Ok(()) +} + +fn authorized_script(parent: &Path, new_app: &Path, target: &Path) -> String { + let quote = |path: &Path| format!("'{}'", path.to_string_lossy().replace('\'', "'\\''")); + format!( + r#"set -eu +stage=$(/usr/bin/mktemp -d {parent}/.openbitfun-update.XXXXXXXX) +/usr/bin/ditto {new} "$stage/new.app" +/bin/mv {old} "$stage/previous.app" +if [ -e {old} ] || [ -L {old} ]; then + echo "Recovery backup retained at $stage; destination changed" >&2; exit 2 +fi +if /bin/mv "$stage/new.app" {old}; then + if /bin/rm -rf "$stage/previous.app"; then /bin/rmdir "$stage" || true; fi +else + if [ -e {old} ] || [ -L {old} ]; then + echo "Recovery backup retained at $stage; destination changed" >&2; exit 2 + fi + /bin/mv "$stage/previous.app" {old} || {{ echo "Recovery backup retained at $stage" >&2; exit 2; }} + exit 1 +fi"#, + parent = quote(parent), + new = quote(new_app), + old = quote(target) + ) +} diff --git a/src/apps/desktop/src/update_maintenance/mod.rs b/src/apps/desktop/src/update_maintenance/mod.rs new file mode 100644 index 0000000000..49defc623f --- /dev/null +++ b/src/apps/desktop/src/update_maintenance/mod.rs @@ -0,0 +1,33 @@ +//! Desktop-owned maintenance for bytes already verified by the official updater. +//! +//! Discovery, download and signature verification stay in tauri-plugin-updater. +//! These adapters own OS publication, authorization and the installer handoff. +use std::path::Path; +use tauri::AppHandle; + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "macos")] +mod macos; +#[cfg(any(target_os = "macos", target_os = "linux", test))] +mod publication; +#[cfg(windows)] +mod windows; + +pub(crate) fn install( + app: &AppHandle, + bytes: &[u8], + version: &str, + kind: &str, + receipt: &Path, +) -> Result<(), String> { + let _ = (app, version, kind, receipt); + #[cfg(windows)] + return windows::install(app, bytes, version, receipt).map_err(|error| error.to_string()); + #[cfg(target_os = "macos")] + return macos::install(bytes).map_err(|error| error.to_string()); + #[cfg(target_os = "linux")] + return linux::install(app, bytes, kind).map_err(|error| error.to_string()); + #[allow(unreachable_code, unused_variables)] + Err("Application maintenance is unsupported on this platform".into()) +} diff --git a/src/apps/desktop/src/update_maintenance/publication.rs b/src/apps/desktop/src/update_maintenance/publication.rs new file mode 100644 index 0000000000..e45d21d4a6 --- /dev/null +++ b/src/apps/desktop/src/update_maintenance/publication.rs @@ -0,0 +1,154 @@ +use std::{fs, io, path::Path}; + +/// Retain the only backup if either publication or its rollback fails. +pub(super) fn replace_bundle( + target: &Path, + staged: &Path, + stage: tempfile::TempDir, +) -> io::Result<()> { + replace_bundle_with(target, staged, stage, |source, destination| { + fs::rename(source, destination) + }) +} + +fn replace_bundle_with( + target: &Path, + staged: &Path, + stage: tempfile::TempDir, + mut rename: impl FnMut(&Path, &Path) -> io::Result<()>, +) -> io::Result<()> { + let backup = stage.path().join("previous.app"); + rename(target, &backup)?; + if let Err(error) = rename(staged, target) { + if let Err(recovery) = rename(&backup, target) { + let retained = stage.keep(); + return Err(io::Error::other(format!( + "App replacement failed: {error}; recovery failed: {recovery}; backup retained at {}", + retained.display() + ))); + } + return Err(error); + } + if let Err(error) = stage.close() { + log::warn!("Application updated; staging cleanup failed: {error}"); + } + Ok(()) +} + +/// Validate before touching the installed image, including archived packages. +pub(super) fn stage_appimage(bytes: &[u8], output: &Path) -> io::Result<()> { + use std::io::{Read, Write}; + let mut file = fs::File::create(output)?; + if bytes.starts_with(&[0x1f, 0x8b]) { + let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(bytes)); + let mut found = false; + for entry in archive.entries()? { + let mut entry = entry?; + if entry + .path()? + .extension() + .is_some_and(|extension| extension == "AppImage") + { + if found || !entry.header().entry_type().is_file() { + return Err(io::Error::other( + "Update archive has ambiguous AppImage entries", + )); + } + io::copy(&mut entry, &mut file)?; + found = true; + } + } + if !found { + return Err(io::Error::other("Update archive contains no AppImage")); + } + } else { + file.write_all(bytes)?; + } + file.sync_all()?; + drop(file); + let mut magic = [0_u8; 11]; + fs::File::open(output)?.read_exact(&mut magic)?; + if !magic.starts_with(b"\x7fELF") || !matches!(&magic[8..], b"AI\x01" | b"AI\x02") { + return Err(io::Error::other("Update package is not a valid AppImage")); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn failed_bundle_publication_restores_the_original() { + let root = tempfile::tempdir().unwrap(); + let target = root.path().join("original.app"); + fs::create_dir(&target).unwrap(); + fs::write(target.join("user.txt"), "old").unwrap(); + let stage = tempfile::tempdir_in(root.path()).unwrap(); + let missing = stage.path().join("missing.app"); + assert!(replace_bundle(&target, &missing, stage).is_err()); + assert_eq!(fs::read_to_string(target.join("user.txt")).unwrap(), "old"); + } + + #[test] + fn invalid_appimage_never_changes_the_installed_file() { + let root = tempfile::tempdir().unwrap(); + let target = root.path().join("installed.AppImage"); + fs::write(&target, "old").unwrap(); + assert!(stage_appimage(b"invalid", &root.path().join("new")).is_err()); + assert_eq!(fs::read_to_string(target).unwrap(), "old"); + let mut valid = b"\x7fELF\0\0\0\0AI\x02".to_vec(); + valid.extend_from_slice(b"fixture"); + stage_appimage(&valid, &root.path().join("new")).unwrap(); + assert_eq!(fs::read(root.path().join("new")).unwrap(), valid); + } + + #[test] + fn failed_rollback_preserves_the_only_recovery_copy() { + let root = tempfile::tempdir().unwrap(); + let target = root.path().join("original.app"); + fs::create_dir(&target).unwrap(); + fs::write(target.join("original.txt"), "old").unwrap(); + let stage = tempfile::tempdir_in(root.path()).unwrap(); + let retained = stage.path().to_owned(); + let staged = stage.path().join("new.app"); + fs::create_dir(&staged).unwrap(); + let mut calls = 0; + let error = replace_bundle_with(&target, &staged, stage, |source, destination| { + calls += 1; + if calls > 1 { + return Err(io::Error::other("Injected publication/restore failure")); + } + fs::rename(source, destination) + }) + .unwrap_err(); + assert!(error.to_string().contains("backup retained")); + assert_eq!( + fs::read_to_string(retained.join("previous.app/original.txt")).unwrap(), + "old" + ); + } + + #[test] + fn archived_appimages_reject_duplicate_candidates_before_publication() { + let root = tempfile::tempdir().unwrap(); + let valid = b"\x7fELF\0\0\0\0AI\x02fixture"; + let archive = |names: &[&str]| { + let gzip = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + let mut tar = tar::Builder::new(gzip); + for name in names { + let mut header = tar::Header::new_gnu(); + header.set_size(valid.len() as u64); + header.set_mode(0o755); + header.set_cksum(); + tar.append_data(&mut header, name, valid.as_slice()) + .unwrap(); + } + tar.into_inner().unwrap().finish().unwrap() + }; + let output = root.path().join("new.AppImage"); + stage_appimage(&archive(&["nested/app.AppImage"]), &output).unwrap(); + assert_eq!(fs::read(&output).unwrap(), valid); + assert!(stage_appimage(&archive(&["one.AppImage", "two.AppImage"]), &output).is_err()); + } +} diff --git a/src/apps/desktop/src/update_maintenance/windows.rs b/src/apps/desktop/src/update_maintenance/windows.rs new file mode 100644 index 0000000000..b4ae4d02e5 --- /dev/null +++ b/src/apps/desktop/src/update_maintenance/windows.rs @@ -0,0 +1,444 @@ +use base64::Engine; +use openbitfun_services_core::process_manager::create_command; +use std::{ + ffi::OsStr, + fs, + io::{self, BufRead, BufReader, Read, Write}, + os::windows::process::CommandExt, + path::{Path, PathBuf}, + process::Stdio, + time::Duration, +}; +use tauri::{AppHandle, Manager}; + +const PROTOCOL: &[u8] = b"OPENBITFUN_MAINTENANCE_V1"; + +fn system_directory() -> io::Result { + #[link(name = "kernel32")] + extern "system" { + fn GetSystemDirectoryW(buffer: *mut u16, size: u32) -> u32; + } + let mut buffer = vec![0; 32768]; + let length = unsafe { GetSystemDirectoryW(buffer.as_mut_ptr(), buffer.len() as u32) }; + if length == 0 || length as usize >= buffer.len() { + return Err(io::Error::last_os_error()); + } + use std::os::windows::ffi::OsStringExt; + Ok(std::ffi::OsString::from_wide(&buffer[..length as usize]).into()) +} + +struct Package { + stage: tempfile::TempDir, + path: PathBuf, + native: bool, + msi: bool, +} + +fn stage_package(bytes: &[u8]) -> io::Result { + let mut extracted = Vec::new(); + let contents = if bytes.starts_with(b"PK\x03\x04") { + let mut archive = zip::ZipArchive::new(io::Cursor::new(bytes))?; + let mut found = false; + for index in 0..archive.len() { + let mut entry = archive.by_index(index)?; + let installer = Path::new(entry.name()) + .extension() + .is_some_and(|extension| { + extension.eq_ignore_ascii_case("exe") || extension.eq_ignore_ascii_case("msi") + }); + if !entry.is_dir() && installer { + if found { + return Err(io::Error::other( + "Update archive contains multiple installers", + )); + } + entry.read_to_end(&mut extracted)?; + found = true; + } + } + if !found { + return Err(io::Error::other( + "Update archive contains no Windows installer", + )); + } + &extracted + } else { + bytes + }; + let msi = contents.starts_with(&[0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]); + if !msi && !contents.starts_with(b"MZ") { + return Err(io::Error::other( + "Update package is not a Windows installer", + )); + } + let native = !msi + && contents + .windows(PROTOCOL.len()) + .any(|window| window == PROTOCOL); + let stage = tempfile::Builder::new() + .prefix("openbitfun-update-") + .tempdir()?; + let path = stage.path().join(if msi { + "installer.msi" + } else { + "installer.exe" + }); + let mut file = fs::File::create(&path)?; + file.write_all(contents)?; + file.sync_all()?; + Ok(Package { + stage, + path, + native, + msi, + }) +} + +pub(super) fn install( + app: &AppHandle, + bytes: &[u8], + version: &str, + receipt: &Path, +) -> io::Result<()> { + let package = stage_package(bytes)?; + let current = std::env::current_exe()?; + let target = current + .parent() + .ok_or_else(|| io::Error::other("Installation path is unavailable"))?; + let protected = match tempfile::NamedTempFile::new_in(target) { + Ok(_) => false, + Err(error) if error.kind() == io::ErrorKind::PermissionDenied => true, + Err(error) => return Err(error), + }; + let machine = crate::api::update_api::windows_machine_install(target); + let event_name = format!("Local\\OpenBitFunUpdate-{}", uuid::Uuid::new_v4()); + let file = if package.msi { + system_directory()?.join("msiexec.exe") + } else { + package.path.clone() + }; + let parameters = if package.native { + format!( + "/UPDATE /S /WAITPID={} /READY_EVENT={} {} {}", + std::process::id(), + event_name, + quote_windows_argument(OsStr::new(&format!( + "/OPENBITFUN_TARGET={}", + target.display() + ))), + if machine { "/ALLUSERS" } else { "" }, + ) + } else if package.msi { + // The original-token supervisor owns restart, including cancellation. + format!( + "/i {} /passive /norestart AUTOLAUNCHAPP=False", + quote_windows_argument(package.path.as_os_str()) + ) + } else { + // NSIS /D must be last and unquoted. Keep the historical installer route. + format!("/S /UPDATE /D={}", target.display()) + }; + let namespace = openbitfun_services_core::product_identity::data_namespace(); + let result_root = |variable| -> io::Result { + std::env::var_os(variable) + .map(|root| { + PathBuf::from(root) + .join(namespace) + .join("maintenance/results") + }) + .ok_or_else(|| { + io::Error::other(format!("Maintenance directory is unavailable: {variable}")) + }) + }; + let user_results = result_root("LOCALAPPDATA")?; + let machine_results = result_root("PROGRAMDATA")?; + let restart = app + .env() + .args_os + .iter() + .skip(1) + .map(|argument| quote_windows_argument(argument)) + .collect::>() + .join(" "); + let quote = |value: &str| format!("'{}'", value.replace('\'', "''")); + let script = format!( + r#"$ErrorActionPreference='Stop' +[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false) +$parentProcess=Get-Process -Id {pid} +$null=$parentProcess.Handle +$nativeResultFile='' +function Read-NativeFailure($code) {{ + if($nativeResultFile -and (Test-Path -LiteralPath $nativeResultFile)) {{ + try {{ + $native=Get-Content -LiteralPath $nativeResultFile -Raw -Encoding UTF8 | ConvertFrom-Json + if($native.exitCode -eq $code) {{ return $native.error }} + }} catch {{}} + }} + return $null +}} +$result=@{{version={version};success=$false;exitCode=$null;error=$null}} +$announced=$false +try {{ + $parameters=@{{FilePath={file};ArgumentList={parameters};PassThru=$true}} + if ({transactional}) {{ + $event=[Threading.EventWaitHandle]::new($false,[Threading.EventResetMode]::ManualReset,{event}) + $parameters.ArgumentList='/SUPERVISOR_PID='+$PID+' /OWNER_SID='+[Security.Principal.WindowsIdentity]::GetCurrent().User.Value+' '+$parameters.ArgumentList + $principal=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) + $root=if({elevate} -or $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){{{machine_results}}}else{{{user_results}}} + $resultID=[Guid]::NewGuid().ToString('N') + $nativeResultFile=Join-Path $root ($resultID+'.json') + $parameters.ArgumentList='/RESULT_ID='+$resultID+' '+$parameters.ArgumentList + }} else {{ + [Console]::Out.WriteLine('READY'); [Console]::Out.Flush(); $announced=$true + $parentProcess.WaitForExit() + }} + if ({elevate}) {{ $parameters.Verb='RunAs' }} + $installer=Start-Process @parameters + if ({transactional}) {{ + $deadline=[DateTime]::UtcNow.AddSeconds(150) + while (!$event.WaitOne(100)) {{ + if ($installer.HasExited) {{ + $result.exitCode=$installer.ExitCode + $detail=Read-NativeFailure $installer.ExitCode + if($detail) {{ throw $detail }} + throw 'Maintenance exited before it was ready. Check permissions, disk space and installation ownership.' + }} + if ([DateTime]::UtcNow -gt $deadline) {{ $installer.Kill(); throw 'Maintenance readiness timed out; no files were changed.' }} + }} + [Console]::Out.WriteLine('READY'); [Console]::Out.Flush(); $announced=$true + }} + $installer.WaitForExit() + $result.exitCode=$installer.ExitCode + $detail=Read-NativeFailure $installer.ExitCode + if ($installer.ExitCode -ne 0 -and $installer.ExitCode -ne 2) {{ if($detail) {{ throw $detail }}; throw "Installer exited with code $($installer.ExitCode)" }} + $result.success=$true + if ($installer.ExitCode -eq 2) {{ $result.error=if($detail){{$detail}}else{{'The update was installed, but some maintenance files require cleanup.'}} }} +}} catch {{ + $result.error=$_.Exception.Message + if (!$announced) {{ [Console]::Out.WriteLine('ERROR: '+$result.error); [Console]::Out.Flush() }} +}} +try {{ + $json=$result | ConvertTo-Json -Compress + $tmp={receipt}+'.tmp' + [IO.File]::WriteAllText($tmp,$json,(New-Object Text.UTF8Encoding($false))) + if ([IO.File]::Exists({receipt})) {{ [IO.File]::Replace($tmp,{receipt},[NullString]::Value) }} else {{ [IO.File]::Move($tmp,{receipt}) }} +}} catch {{}} +if ($result.error -and $announced) {{ + Add-Type -AssemblyName System.Windows.Forms + $message=if($result.success){{'OpenBitFun update completed with a cleanup warning. '}}else{{'OpenBitFun update failed. The downloaded package is retained. '}} + [Windows.Forms.MessageBox]::Show($message+$result.error,'OpenBitFun',[Windows.Forms.MessageBoxButtons]::OK,[Windows.Forms.MessageBoxIcon]::Warning) | Out-Null +}} +if ({cleanup} -ne '') {{ + try {{ Remove-Item -LiteralPath {cleanup} -Force; [IO.Directory]::Delete([IO.Path]::GetDirectoryName({cleanup}),$false) }} catch {{}} +}} +if ($announced -and (Test-Path -LiteralPath {current})) {{ + $restart=@{{FilePath={current}}} + if ({restart} -ne '') {{ $restart.ArgumentList={restart} }} + Start-Process @restart +}} +"#, + pid = std::process::id(), + version = quote(version), + file = quote(&file.to_string_lossy()), + parameters = quote(¶meters), + transactional = if package.native { "$true" } else { "$false" }, + event = quote(&event_name), + elevate = if protected || machine { + "$true" + } else { + "$false" + }, + receipt = quote(&receipt.to_string_lossy()), + current = quote(¤t.to_string_lossy()), + restart = quote(&restart), + user_results = quote(&user_results.to_string_lossy()), + machine_results = quote(&machine_results.to_string_lossy()), + cleanup = quote(&package.path.to_string_lossy()), + ); + let encoded = base64::engine::general_purpose::STANDARD.encode( + script + .encode_utf16() + .flat_map(u16::to_le_bytes) + .collect::>(), + ); + let mut supervisor = + create_command(system_directory()?.join("WindowsPowerShell/v1.0/powershell.exe")) + .args(["-NoProfile", "-NonInteractive", "-EncodedCommand", &encoded]) + .creation_flags(windows_supervisor_creation_flags()) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn()?; + let stdout = supervisor + .stdout + .take() + .ok_or_else(|| io::Error::other("Supervisor output is unavailable"))?; + let (sender, receiver) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut line = String::new(); + let result = BufReader::new(stdout) + .read_line(&mut line) + .map(|_| line.trim().to_owned()); + let _ = sender.send(result); + }); + let ready = receiver.recv_timeout(Duration::from_secs(180)); + if !matches!(&ready, Ok(Ok(line)) if line == "READY") { + let _ = supervisor.kill(); + let _ = supervisor.wait(); + let detail = match ready { + Ok(Ok(line)) if !line.is_empty() => line.trim_start_matches("ERROR: ").to_owned(), + Ok(Err(error)) => error.to_string(), + _ => "Maintenance readiness timed out or the supervisor exited".into(), + }; + return Err(io::Error::other(format!( + "Cannot start update maintenance: {detail}; the application was left running" + ))); + } + // The ready worker owns only this exact staged installer and empty directory. + let _ = package.stage.keep(); + Ok(()) +} + +fn windows_supervisor_creation_flags() -> u32 { + // Host-lifetime cleanup must not kill the worker after the old app exits. + 0x08000000 | 0x00000200 | 0x01000000 +} + +fn quote_windows_argument(argument: &OsStr) -> String { + let mut result = String::from("\""); + let mut slashes = 0; + for character in argument.to_string_lossy().chars() { + if character == '\\' { + slashes += 1; + continue; + } + result.extend(std::iter::repeat_n( + '\\', + slashes * if character == '"' { 2 } else { 1 }, + )); + slashes = 0; + if character == '"' { + result.push('\\'); + } + result.push(character); + } + result.extend(std::iter::repeat_n('\\', slashes * 2)); + result.push('"'); + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn windows_supervisor_survives_the_host_lifetime_job() { + if let Some(receipt) = std::env::var_os("OPENBITFUN_UPDATER_JOB_FIXTURE") { + // Only the disposable child joins a host-lifetime Job. + let mut limits = win32job::ExtendedLimitInfo::new(); + limits.limit_kill_on_job_close().limit_breakaway_ok(); + let job = win32job::Job::create_with_limit_info(&limits).unwrap(); + job.assign_current_process().unwrap(); + create_command(system_directory().unwrap().join("WindowsPowerShell/v1.0/powershell.exe")) + .args(["-NoProfile", "-NonInteractive", "-Command", "Start-Sleep -Milliseconds 800; [IO.File]::WriteAllText($env:OPENBITFUN_UPDATER_JOB_FIXTURE, 'survived')"]) + .env("OPENBITFUN_UPDATER_JOB_FIXTURE", receipt) + .creation_flags(windows_supervisor_creation_flags()) + .stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn().unwrap(); + std::process::exit(0); + } + let root = tempfile::tempdir().unwrap(); + let receipt = root.path().join("supervisor.txt"); + let result = create_command(std::env::current_exe().unwrap()) + .args(["--exact", "update_maintenance::windows::tests::windows_supervisor_survives_the_host_lifetime_job"]) + .env("OPENBITFUN_UPDATER_JOB_FIXTURE", &receipt).output().unwrap(); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let deadline = std::time::Instant::now() + Duration::from_secs(15); + while !receipt.exists() && std::time::Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(50)); + } + assert_eq!(fs::read_to_string(receipt).unwrap(), "survived"); + } + + #[test] + fn native_windows_arguments_roundtrip_through_the_os_parser() { + #[link(name = "shell32")] + extern "system" { + fn CommandLineToArgvW(command: *const u16, count: *mut i32) -> *mut *mut u16; + } + #[link(name = "kernel32")] + extern "system" { + fn LocalFree(memory: *mut std::ffi::c_void) -> *mut std::ffi::c_void; + } + let values = [ + r"/OPENBITFUN_TARGET=C:\Program Files\测试目录", + "space here", + "quote\"inside", + "trailing\\", + "", + ]; + let command = format!( + "program.exe {}", + values + .iter() + .map(|value| quote_windows_argument(OsStr::new(value))) + .collect::>() + .join(" ") + ); + let wide = command + .encode_utf16() + .chain(std::iter::once(0)) + .collect::>(); + let mut count = 0; + let arguments = unsafe { CommandLineToArgvW(wide.as_ptr(), &mut count) }; + assert!(!arguments.is_null()); + let decoded = unsafe { std::slice::from_raw_parts(arguments, count as usize) } + .iter() + .skip(1) + .map(|pointer| { + let mut length = 0; + unsafe { + while *(*pointer).add(length) != 0 { + length += 1; + } + String::from_utf16_lossy(std::slice::from_raw_parts(*pointer, length)) + } + }) + .collect::>(); + unsafe { + LocalFree(arguments.cast()); + } + assert_eq!(decoded, values); + } + + #[test] + fn package_staging_validates_before_starting_any_process() { + assert!(stage_package(b"invalid package").is_err()); + let payload = [b"MZ".as_slice(), PROTOCOL].concat(); + let package = stage_package(&payload).unwrap(); + assert!(package.native); + assert!(!package.msi); + assert_eq!(fs::read(&package.path).unwrap(), payload); + let directory = package.stage.path().to_owned(); + drop(package); + assert!(!directory.exists()); + } + + #[test] + fn archived_installers_are_selected_without_extracting_unrelated_paths() { + let mut archive = zip::ZipWriter::new(io::Cursor::new(Vec::new())); + let options = zip::write::SimpleFileOptions::default(); + archive.start_file("../../unrelated.txt", options).unwrap(); + archive.write_all(b"keep outside paths untouched").unwrap(); + archive.start_file("nested/setup.exe", options).unwrap(); + archive.write_all(b"MZOPENBITFUN_MAINTENANCE_V1").unwrap(); + let bytes = archive.finish().unwrap().into_inner(); + let package = stage_package(&bytes).unwrap(); + assert!(package.native); + assert_eq!(fs::read_dir(package.stage.path()).unwrap().count(), 1); + assert_eq!(package.path.file_name().unwrap(), "installer.exe"); + } +} diff --git a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json index 5f8ed5f60e..6e9ca23ed4 100644 --- a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json +++ b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "digest": "fnv1a64:86aabcc23554987b", + "digest": "fnv1a64:9fb2c42fa23d9fee", "retiredCommandPrefixes": [ { "prefix": "lsp_", @@ -727,6 +727,18 @@ "reason": "ACP agents cannot be managed or launched from a controller on the CLI peer host yet; use the `acp clients` commands on that device" } }, + { + "id": "cancel_app_update_download", + "surface": "tauri_command", + "remoteWorkspace": "WorkspaceAgnostic", + "peer": { + "kind": "controller_local" + }, + "cliPeer": { + "kind": "unsupported", + "reason": "the controller keeps this command; peer hosts refuse it before dispatch" + } + }, { "id": "cancel_dialog_turn", "surface": "tauri_command", diff --git a/src/crates/contracts/product-domains/src/remote_surface/table.rs b/src/crates/contracts/product-domains/src/remote_surface/table.rs index 53bbd61648..d590a9f7b6 100644 --- a/src/crates/contracts/product-domains/src/remote_surface/table.rs +++ b/src/crates/contracts/product-domains/src/remote_surface/table.rs @@ -139,6 +139,7 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("btw_ask_stream", Routed, Proxied, HANDLED), op("btw_cancel", Routed, Proxied, HANDLED), op("cancel_acp_dialog_turn", Unaudited, Proxied, CLI_NO_ACP_CLIENT_HOST), + op("cancel_app_update_download", Agnostic, ControllerLocal, REFUSED), op("cancel_dialog_turn", Agnostic, Proxied, HANDLED), op("cancel_insights_generation", LocalOnly, ControllerLocal, REFUSED), op("cancel_mcp_remote_oauth", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), diff --git a/src/crates/services/services-integrations/AGENTS.md b/src/crates/services/services-integrations/AGENTS.md index 80bd5486b7..75f8d71d28 100644 --- a/src/crates/services/services-integrations/AGENTS.md +++ b/src/crates/services/services-integrations/AGENTS.md @@ -2,6 +2,11 @@ Scope: this guide applies to `src/crates/services/services-integrations`. +For remote CLI installation scripts and recovery behavior, run +`cargo test --locked -p openbitfun-services-integrations --no-default-features --features remote-ssh-concrete --lib remote_ssh::dispatch_ssh::tests`. +Generated Bash execution tests require Unix; Windows covers protocol and script +assembly checks. These fixtures do not establish live SSH behavior. + `openbitfun-services-integrations` owns reviewed integration contracts and runtime slices that are outside pure product logic but still platform-neutral. diff --git a/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs b/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs index 3614706fb5..5c957afe82 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs @@ -2650,23 +2650,30 @@ TARGET="$HOME/.local/bin/openbitfun" # publish step is one atomic rename. STAGE="$HOME/.local/bin/{hidden_data_directory}-dispatch-stage-$$" NEW="$STAGE/openbitfun" -BACKUP="$D/previous-openbitfun.$$" +BACKUP="$STAGE/previous-openbitfun" HAD_TARGET=0 TARGET_INSTALLED=0 COMMITTED=0 +RECOVERY_FAILED=0 +PID_LOCK=0 rollback_install() {{ - if [ "$TARGET_INSTALLED" = "1" ]; then rm -f "$TARGET"; fi + if [ "$TARGET_INSTALLED" = "1" ]; then rm -f "$TARGET" || RECOVERY_FAILED=1; fi if [ "$HAD_TARGET" = "1" ] && [ -f "$BACKUP" ]; then mv -f "$BACKUP" "$TARGET" \ - || echo "ERROR: could not restore previous openbitfun" >&2 + || RECOVERY_FAILED=1 fi }} finish() {{ code=$? trap - EXIT HUP INT TERM + set +e if [ "$code" -ne 0 ] && [ "$COMMITTED" != "1" ]; then rollback_install; fi - rm -rf "$STAGE" - if [ "$COMMITTED" = "1" ]; then rm -f "$BACKUP"; fi + if [ "$RECOVERY_FAILED" = "0" ]; then + rm -rf "$STAGE" + else + echo "ERROR: CLI recovery failed; backups retained at $STAGE" >&2 + fi + if [ "$PID_LOCK" = "1" ]; then rm -f "$HOME/.local/bin/.openbitfun-cli-install.lock"; fi rm -rf "$TMP" printf '%s\n' "$code" >"$EXITF" marker_token="$(sed -n '2p' "$PIDF" 2>/dev/null | tr -d '[:space:]')" @@ -2678,6 +2685,15 @@ trap 'exit 130' HUP INT TERM rm -f "$EXITF" mkdir -p "$TMP" "$STAGE" "$HOME/.local/bin" "{product_home}" chmod 700 "{product_home}" "$STAGE" +if command -v flock >/dev/null 2>&1; then + exec 9>"$HOME/.local/bin/.openbitfun-cli-install.lock" + flock -n 9 || {{ echo 'ERROR: another CLI installation or update is running' >&2; exit 1; }} +elif command -v shlock >/dev/null 2>&1; then + shlock -p "$$" -f "$HOME/.local/bin/.openbitfun-cli-install.lock" || {{ echo 'ERROR: another CLI installation is running' >&2; exit 1; }} + PID_LOCK=1 +else + echo 'ERROR: CLI installation requires flock or shlock' >&2; exit 1 +fi "#, dir = shell_quote_posix(dir), version = shell_quote_posix(expected_version), @@ -3766,10 +3782,11 @@ mod tests { .expect("run installed binary"); assert!(run.status.success(), "openbitfun must run after install"); assert!(String::from_utf8_lossy(&run.stdout).contains("openbitfun 1.2.3")); - let installed_entries: Vec<_> = std::fs::read_dir(home.join(".local/bin")) + let mut installed_entries: Vec<_> = std::fs::read_dir(home.join(".local/bin")) .expect("read bin dir") .map(|entry| entry.expect("read installed entry").file_name()) .collect(); + installed_entries.retain(|name| name != ".openbitfun-cli-install.lock"); assert_eq!( installed_entries, vec![std::ffi::OsString::from("openbitfun")], diff --git a/src/shared/interactive-capabilities/catalog.json b/src/shared/interactive-capabilities/catalog.json index 417577edce..cc091462d9 100644 --- a/src/shared/interactive-capabilities/catalog.json +++ b/src/shared/interactive-capabilities/catalog.json @@ -5611,6 +5611,7 @@ "evidence": [ "command:check_for_updates", "command:download_update", + "command:cancel_app_update_download", "command:get_pending_update", "command:install_pending_update", "command:get_app_version" diff --git a/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts b/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts index 57a610ec0d..1270a2d175 100644 --- a/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts +++ b/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts @@ -1,6 +1,6 @@ // Generated by scripts/generate-interactive-capabilities.mjs; do not edit. // Source: openbitfun_product_domains::remote_surface (Product Operation Registry). -export const REMOTE_SURFACE_REGISTRY_DIGEST = "fnv1a64:86aabcc23554987b" as const; +export const REMOTE_SURFACE_REGISTRY_DIGEST = "fnv1a64:9fb2c42fa23d9fee" as const; /** * Registered Tauri commands the Peer Device controller keeps on the controller @@ -38,6 +38,7 @@ export const PEER_CONTROLLER_LOCAL_COMMANDS: ReadonlySet = new Set([ "browser_webview_reload", "browser_webview_set_agent_target_state", "browser_webview_set_bounds", + "cancel_app_update_download", "cancel_insights_generation", "check_for_updates", "confirm_frontend_update", diff --git a/src/web-ui/src/infrastructure/api/service-api/SystemAPI.test.ts b/src/web-ui/src/infrastructure/api/service-api/SystemAPI.test.ts index eb5e7d59b9..d87fbd20eb 100644 --- a/src/web-ui/src/infrastructure/api/service-api/SystemAPI.test.ts +++ b/src/web-ui/src/infrastructure/api/service-api/SystemAPI.test.ts @@ -149,7 +149,7 @@ describe('SystemAPI', () => { await systemAPI.installPendingUpdate('2.0.0'); expect(invokeMock).toHaveBeenCalledWith('install_pending_update', { request: { version: '2.0.0' }, - }, { timeout: 120000, retries: 0 }); + }, { timeout: 300000, retries: 0 }); }); it('sends the requested app-wide state', async () => { diff --git a/src/web-ui/src/infrastructure/api/service-api/SystemAPI.ts b/src/web-ui/src/infrastructure/api/service-api/SystemAPI.ts index 7ab1bd2f19..838b4ca3a8 100644 --- a/src/web-ui/src/infrastructure/api/service-api/SystemAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/SystemAPI.ts @@ -28,6 +28,7 @@ export interface CheckForUpdatesResponse { export interface PendingUpdateResponse { version: string; + installError?: string; } /** Matches `toggle_main_window_fullscreen` / desktop `ToggleMainWindowFullscreenResponse`. */ @@ -134,10 +135,14 @@ export class SystemAPI { } } + async cancelUpdateDownload(): Promise { + await api.invoke('cancel_app_update_download', { request: {} }, { retries: 0, timeout: 10000 }); + } + async installPendingUpdate(version: string): Promise { try { await api.invoke('install_pending_update', { request: { version } }, { - timeout: 120000, + timeout: 300000, retries: 0, }); } catch (error) { diff --git a/src/web-ui/src/infrastructure/update/AppUpdatePanel.tsx b/src/web-ui/src/infrastructure/update/AppUpdatePanel.tsx index c3a13e26f6..11c4946d42 100644 --- a/src/web-ui/src/infrastructure/update/AppUpdatePanel.tsx +++ b/src/web-ui/src/infrastructure/update/AppUpdatePanel.tsx @@ -87,6 +87,7 @@ export function AppUpdatePanel() {
{downloading ? ( + <>
+ + ) : ( <> {canSkip && target &&