diff --git a/Cargo.lock b/Cargo.lock index fda857c960..9f7cca751c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5973,6 +5973,7 @@ dependencies = [ "libc", "log", "mime_guess", + "minisign-verify", "notify-rust", "objc2 0.6.4", "objc2-app-kit", diff --git a/Cargo.toml b/Cargo.toml index 43fc2eec80..18eaa6b17c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -248,6 +248,7 @@ unic-langid = "0.9" x25519-dalek = { version = "2.0", features = ["static_secrets"] } aes-gcm = "0.10" sha2 = "0.10" +minisign-verify = "0.2" sha1 = "0.10" argon2 = "0.5" rand = "0.8" diff --git a/docs/interactive-capabilities/README.md b/docs/interactive-capabilities/README.md index a63decab54..ce45d2818d 100644 --- a/docs/interactive-capabilities/README.md +++ b/docs/interactive-capabilities/README.md @@ -27,9 +27,9 @@ OpenBitFun Playbook currently contains **22 features**, **21 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` -说明书、网站、搜索和 Agent 只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **662** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。 +说明书、网站、搜索和 Agent 只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **665** 个 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 **662** 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 **665** 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/capabilities.json b/docs/interactive-capabilities/capabilities.json index 448472ae95..a635ba4c23 100644 --- a/docs/interactive-capabilities/capabilities.json +++ b/docs/interactive-capabilities/capabilities.json @@ -4,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "c21d28e130de07890a2ebd6793cf78905c7749f577d8b8e547d9fe12a6e26a95", + "digest": "9973af8307e72206bb5c4af5dfb3249fef2def6a27925a13aca9a25ac62ef8a3", "ownerDigest": "c0e5c187cf62bc6ed06196ce8520b3eb427bf268cf24659b72d2552fb1d99c54", "searchAcceptance": [ { @@ -24810,13 +24810,13 @@ }, { "id": "manual-update", - "titleZh": "手动检查、下载并安装可用更新,然后按需重启", - "titleEn": "Check for, download, and install an available update manually, then restart when needed", + "titleZh": "手动检查并后台下载更新,确认后安装并重启,或稍后从关于页面安装", + "titleEn": "Check and download updates in the background, then confirm installation and restart or install later from About", "control": { "kind": "open", "reasonCode": "unstructuredInteraction", - "reasonZh": "“手动检查、下载并安装可用更新,然后按需重启”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;Agent 会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Check for, download, and install an available update manually, then restart when needed” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "reasonZh": "“手动检查并后台下载更新,确认后安装并重启,或稍后从关于页面安装”需要下载后由用户确认安装与重启;Agent 会打开精确入口,并把后续交互保留在用户可见界面。", + "reasonEn": "“Check and download updates in the background, then confirm installation and restart or install later from About” requires user confirmation after downloading, before installation and restart; the Agent opens the exact entry and keeps the remaining interaction visible to the user." } }, { @@ -25020,8 +25020,8 @@ "Completion, permission, and startup-tip notifications", "启用或停用自动检查更新", "Enable or disable automatic update checks", - "手动检查、下载并安装可用更新,然后按需重启", - "Check for, download, and install an available update manually, then restart when needed", + "手动检查并后台下载更新,确认后安装并重启,或稍后从关于页面安装", + "Check and download updates in the background, then confirm installation and restart or install later from About", "控制 OpenBitFun 是否在登录系统后自动启动", "Choose whether OpenBitFun launches automatically after system sign-in", "控制 OpenBitFun 运行期间是否阻止电脑自动睡眠", diff --git a/docs/interactive-capabilities/capabilities/setting.application.general.md b/docs/interactive-capabilities/capabilities/setting.application.general.md index d53615f660..405590448f 100644 --- a/docs/interactive-capabilities/capabilities/setting.application.general.md +++ b/docs/interactive-capabilities/capabilities/setting.application.general.md @@ -19,8 +19,8 @@ Manage app-level preferences for startup, updates, close behavior, notifications - **Agent 可直接控制 / Direct Agent control** · 启用或停用自动检查更新 - Enable or disable automatic update checks -- **Agent 可定位入口,需交互完成 / Agent opens; interaction required** · 手动检查、下载并安装可用更新,然后按需重启 - - Check for, download, and install an available update manually, then restart when needed +- **Agent 可定位入口,需交互完成 / Agent opens; interaction required** · 手动检查并后台下载更新,确认后安装并重启,或稍后从关于页面安装 + - Check and download updates in the background, then confirm installation and restart or install later from About - **Agent 可直接控制 / Direct Agent control** · 控制 OpenBitFun 是否在登录系统后自动启动 - Choose whether OpenBitFun launches automatically after system sign-in - **Agent 可直接控制 / Direct Agent control** · 控制 OpenBitFun 运行期间是否阻止电脑自动睡眠 diff --git a/docs/interactive-capabilities/technical/product-control-open-audit.json b/docs/interactive-capabilities/technical/product-control-open-audit.json index 8e3dafa2f8..ff540e156f 100644 --- a/docs/interactive-capabilities/technical/product-control-open-audit.json +++ b/docs/interactive-capabilities/technical/product-control-open-audit.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "c21d28e130de07890a2ebd6793cf78905c7749f577d8b8e547d9fe12a6e26a95", + "catalogDigest": "9973af8307e72206bb5c4af5dfb3249fef2def6a27925a13aca9a25ac62ef8a3", "count": 211, "reasonCounts": { "externalAuth": 4, @@ -2471,19 +2471,20 @@ { "capabilityId": "setting.application.general", "itemId": "manual-update", - "titleZh": "手动检查、下载并安装可用更新,然后按需重启", - "titleEn": "Check for, download, and install an available update manually, then restart when needed", + "titleZh": "手动检查并后台下载更新,确认后安装并重启,或稍后从关于页面安装", + "titleEn": "Check and download updates in the background, then confirm installation and restart or install later from About", "reasonCode": "unstructuredInteraction", - "reasonZh": "“手动检查、下载并安装可用更新,然后按需重启”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;Agent 会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Check for, download, and install an available update manually, then restart when needed” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user.", + "reasonZh": "“手动检查并后台下载更新,确认后安装并重启,或稍后从关于页面安装”需要下载后由用户确认安装与重启;Agent 会打开精确入口,并把后续交互保留在用户可见界面。", + "reasonEn": "“Check and download updates in the background, then confirm installation and restart or install later from About” requires user confirmation after downloading, before installation and restart; the Agent opens the exact entry and keeps the remaining interaction visible to the user.", "presentationTarget": { "kind": "settings", "pageId": "application.general" }, "evidence": [ "command:check_for_updates", - "command:install_update", - "command:restart_app", + "command:download_update", + "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 b87eb0ffd0..106e854522 100644 --- a/docs/interactive-capabilities/technical/tauri-command-map.json +++ b/docs/interactive-capabilities/technical/tauri-command-map.json @@ -1,13 +1,13 @@ { "schemaVersion": 2, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "c21d28e130de07890a2ebd6793cf78905c7749f577d8b8e547d9fe12a6e26a95", - "commandCount": 662, + "catalogDigest": "9973af8307e72206bb5c4af5dfb3249fef2def6a27925a13aca9a25ac62ef8a3", + "commandCount": 665, "coverage": { - "commandCount": 662, - "documentedCommandCount": 631, - "implementationCommandCount": 31, - "implementationDigest": "859a3a3cb1e4e27ca38cd9003cef14e8758483ce85417a2c22c6ab92df9dd2c3" + "commandCount": 665, + "documentedCommandCount": 632, + "implementationCommandCount": 33, + "implementationDigest": "35539d9c1510287cb47f4a68fe35859b78f93bb06cd66b86e58d878a48d8c509" }, "commands": [ { @@ -2290,6 +2290,22 @@ "signature": "fn download_skill_market( _state: State<'_, AppState>, request: SkillMarketDownloadRequest, ) -> Result", "remoteWorkspacePolicy": "LegacyUnaudited" }, + { + "id": "download_update", + "moduleId": "update", + "capabilityId": "setting.application.general", + "capabilityIds": [ + "setting.application.general" + ], + "documentedItemIds": [ + "setting.application.general:manual-update" + ], + "visibility": "documented", + "rustPath": "api::update_api::download_update", + "sourceFile": "src/apps/desktop/src/api/update_api.rs", + "signature": "fn download_update( app: AppHandle, request: PendingUpdateRequest, ) -> Result", + "remoteWorkspacePolicy": "WorkspaceAgnostic" + }, { "id": "editor_ai_cancel", "moduleId": "editor_ai", @@ -3509,6 +3525,22 @@ "signature": "fn get_pending_announcements( state: State<'_, AppState>, ) -> Result, String>", "remoteWorkspacePolicy": "WorkspaceAgnostic" }, + { + "id": "get_pending_update", + "moduleId": "update", + "capabilityId": "setting.application.general", + "capabilityIds": [ + "setting.application.general" + ], + "documentedItemIds": [ + "setting.application.general:manual-update" + ], + "visibility": "documented", + "rustPath": "api::update_api::get_pending_update", + "sourceFile": "src/apps/desktop/src/api/update_api.rs", + "signature": "fn get_pending_update( app: AppHandle, request: PendingUpdateRequest, ) -> Result, String>", + "remoteWorkspacePolicy": "WorkspaceAgnostic" + }, { "id": "get_prevent_sleep_enabled", "moduleId": "sleep_prevention", @@ -4780,8 +4812,8 @@ "remoteWorkspacePolicy": "LegacyUnaudited" }, { - "id": "install_update", - "moduleId": "system", + "id": "install_pending_update", + "moduleId": "update", "capabilityId": "setting.application.general", "capabilityIds": [ "setting.application.general" @@ -4790,6 +4822,20 @@ "setting.application.general:manual-update" ], "visibility": "documented", + "rustPath": "api::update_api::install_pending_update", + "sourceFile": "src/apps/desktop/src/api/update_api.rs", + "signature": "fn install_pending_update( app: AppHandle, request: InstallPendingUpdateRequest, ) -> Result<(), String>", + "remoteWorkspacePolicy": "WorkspaceAgnostic" + }, + { + "id": "install_update", + "moduleId": "system", + "capabilityId": "setting.application.general", + "capabilityIds": [ + "setting.application.general" + ], + "documentedItemIds": [], + "visibility": "implementation", "rustPath": "install_update", "sourceFile": "src/apps/desktop/src/api/system_api.rs", "signature": "fn install_update(app: AppHandle, request: InstallUpdateRequest) -> Result<(), String>", @@ -7790,10 +7836,8 @@ "capabilityIds": [ "setting.application.general" ], - "documentedItemIds": [ - "setting.application.general:manual-update" - ], - "visibility": "documented", + "documentedItemIds": [], + "visibility": "implementation", "rustPath": "restart_app", "sourceFile": "src/apps/desktop/src/api/system_api.rs", "signature": "fn restart_app(app: AppHandle, request: RestartAppRequest) -> Result<(), String>", diff --git a/src/apps/desktop/AGENTS.md b/src/apps/desktop/AGENTS.md index 33b5443ec5..3513bd7d12 100644 --- a/src/apps/desktop/AGENTS.md +++ b/src/apps/desktop/AGENTS.md @@ -93,6 +93,11 @@ The `devtools` Cargo feature exists for debugging UI/UX in the desktop app. When cargo check -p openbitfun-desktop && cargo test -p openbitfun-desktop ``` +For staged application-update cache and signature behavior, use +`cargo test -p openbitfun-desktop --lib api::update_api::tests`. +After changing updater command registration, also run +`cargo test -p openbitfun-desktop --lib remote_workspace_policy`. + If the change affects startup, WebDriver, browser/computer-use, or packaged behavior, also run: ```bash diff --git a/src/apps/desktop/Cargo.toml b/src/apps/desktop/Cargo.toml index 09260a3426..0ed3b6b8f7 100644 --- a/src/apps/desktop/Cargo.toml +++ b/src/apps/desktop/Cargo.toml @@ -81,6 +81,7 @@ axum = { workspace = true } tower-http = { workspace = true, features = ["fs"] } sha1 = { workspace = true } sha2 = { workspace = true } +minisign-verify = { workspace = true } screenshots = { workspace = true } enigo = { workspace = true } image = { workspace = true, features = ["jpeg", "png"] } diff --git a/src/apps/desktop/README.md b/src/apps/desktop/README.md new file mode 100644 index 0000000000..00cc8f7c3e --- /dev/null +++ b/src/apps/desktop/README.md @@ -0,0 +1,27 @@ +# OpenBitFun Desktop + +For development commands, see [AGENTS.md](AGENTS.md) and the repository +[contribution guide](../../../CONTRIBUTING.md). + +## Application updates + +Choose **Background download** in the new-version dialog to download and verify +an update while continuing to use OpenBitFun. Downloading does not install the +update or restart the application. + +When the download finishes, OpenBitFun offers **Install and restart** or +**Later**. Installation restarts OpenBitFun on this device and interrupts its +active sessions. Choosing Later, or closing the dialog, keeps the downloaded +update. Open **About → Install and restart** whenever you are ready; the same +confirmation appears before installation. + +Downloaded updates remain available after closing and reopening OpenBitFun. +After reopening, the current updater requires access to the update server to +restore installer metadata, but does not download the package again. If this +step or installation fails, the pending update remains available to retry. +Use **Download again** in the error dialog if the cached package is damaged. + +Application updates always belong to the local desktop, including while viewing +a peer device or a remote workspace. They do not install software on the peer or +cancel independently running detached jobs on another host. Connections through +the restarting desktop are interrupted. diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index 664d055106..38bb045244 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -56,6 +56,7 @@ pub mod system_api; pub mod terminal_api; pub mod token_usage_api; pub mod tool_api; +pub mod update_api; pub mod workspace_activation; pub mod worktree_api; diff --git a/src/apps/desktop/src/api/system_api.rs b/src/apps/desktop/src/api/system_api.rs index bc03cdf021..e289b78719 100644 --- a/src/apps/desktop/src/api/system_api.rs +++ b/src/apps/desktop/src/api/system_api.rs @@ -205,7 +205,9 @@ async fn probe_endpoint_throughput(client: &reqwest::Client, url: &str) -> u64 { /// Build an updater whose endpoints are ordered by measured throughput. /// Falls back to the bundled configuration if the builder rejects them. -async fn ranked_updater(app: &AppHandle) -> Result { +pub(super) async fn ranked_updater( + app: &AppHandle, +) -> Result { let endpoints = updater_endpoints_by_policy().await; let builder = app.updater_builder(); let builder = match builder.endpoints(endpoints) { @@ -218,7 +220,9 @@ async fn ranked_updater(app: &AppHandle) -> Result Re let progress = Arc::new(Mutex::new((0u64, None::))); let progress_chunk = Arc::clone(&progress); let app_chunk = app_handle.clone(); - update - .download_and_install( + let bytes = update + .download( move |chunk_len, content_len| { let (downloaded, total) = { let mut g = progress_chunk @@ -353,7 +357,10 @@ pub async fn install_update(app: AppHandle, request: InstallUpdateRequest) -> Re }, ) .await - .map_err(|e| e.to_string()) + .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())? } #[derive(Debug, Deserialize)] diff --git a/src/apps/desktop/src/api/update_api.rs b/src/apps/desktop/src/api/update_api.rs new file mode 100644 index 0000000000..38734aaaa2 --- /dev/null +++ b/src/apps/desktop/src/api/update_api.rs @@ -0,0 +1,369 @@ +//! Desktop-owned staged application updates. Download never starts an installer. + +use base64::Engine; +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}; + +const PROGRESS_EVENT: &str = "openbitfun-update-progress"; +const RECORD_NAME: &str = "pending.json"; + +// Serialize downloads and installs at the host, including calls from multiple windows. +static UPDATE: OnceLock>> = OnceLock::new(); + +#[derive(Debug, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct PendingUpdateRequest {} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InstallPendingUpdateRequest { + pub version: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PendingUpdateResponse { + pub version: String, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PendingUpdateRecord { + version: String, + platform: String, + signature: String, + sha256: String, +} + +impl PendingUpdateRecord { + fn response(&self) -> PendingUpdateResponse { + PendingUpdateResponse { + version: self.version.clone(), + } + } + + fn applies_to(&self, current: &semver::Version, platform: &str) -> Result { + let version = semver::Version::parse(&self.version).map_err(|e| e.to_string())?; + Ok(version > *current && self.platform == platform) + } + + fn package_name(&self) -> Result { + if self.sha256.len() != 64 || !self.sha256.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err("Invalid pending update checksum".into()); + } + Ok(format!("{}.package", self.sha256)) + } +} + +fn platform() -> String { + format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH) +} + +fn cache_dir(app: &AppHandle) -> Result { + app.path() + .app_cache_dir() + .map(|p| p.join("app-updates")) + .map_err(|e| e.to_string()) +} + +fn read_record( + dir: &Path, + current: &semver::Version, + platform: &str, +) -> Result, String> { + let bytes = match std::fs::read(dir.join(RECORD_NAME)) { + Ok(bytes) => bytes, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(format!("Cannot read pending update: {e}")), + }; + // Never remove an unreadable record as a recovery mechanism. + let record: PendingUpdateRecord = serde_json::from_slice(&bytes) + .map_err(|e| format!("Cannot read pending update metadata: {e}"))?; + if !record.applies_to(current, platform)? { + return Ok(None); + } + let package = dir.join(record.package_name()?); + if !package.is_file() { + return Err("Pending update package is missing; download the update again".into()); + } + Ok(Some(record)) +} + +fn atomic_write(dir: &Path, name: &str, bytes: &[u8]) -> Result<(), String> { + std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; + let mut file = tempfile::NamedTempFile::new_in(dir).map_err(|e| e.to_string())?; + file.write_all(bytes).map_err(|e| e.to_string())?; + file.as_file().sync_all().map_err(|e| e.to_string())?; + file.persist(dir.join(name)).map_err(|e| e.to_string())?; + Ok(()) +} + +fn save_package(dir: &Path, record: &PendingUpdateRecord, bytes: &[u8]) -> Result<(), String> { + // Commit the metadata last. A partial download can never become installable. + atomic_write(dir, &record.package_name()?, bytes)?; + atomic_write( + dir, + RECORD_NAME, + &serde_json::to_vec(record).map_err(|e| e.to_string())?, + ) +} + +fn verify_package(bytes: &[u8], record: &PendingUpdateRecord, pubkey: &str) -> Result<(), String> { + if format!("{:x}", Sha256::digest(bytes)) != record.sha256 { + return Err( + "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. + let decode = |value: &str| -> Result { + let bytes = base64::engine::general_purpose::STANDARD + .decode(value.trim()) + .map_err(|e| format!("Update signature decoding failed: {e}"))?; + String::from_utf8(bytes).map_err(|e| format!("Update signature decoding failed: {e}")) + }; + let public_key = minisign_verify::PublicKey::decode(&decode(pubkey)?) + .map_err(|e| format!("Update signature public key is invalid: {e}"))?; + let signature = minisign_verify::Signature::decode(&decode(&record.signature)?) + .map_err(|e| format!("Update signature is invalid: {e}"))?; + public_key + .verify(bytes, &signature, true) + .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(); + }) +} + +#[tauri::command] +pub async fn get_pending_update( + app: AppHandle, + request: PendingUpdateRequest, +) -> Result, String> { + let _ = request; + 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())) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn download_update( + app: AppHandle, + request: PendingUpdateRequest, +) -> Result { + let _ = request; + let mut state = 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())?; + 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 record = PendingUpdateRecord { + version: update.version.clone(), + platform: platform(), + signature: update.signature.clone(), + sha256: format!("{:x}", Sha256::digest(&bytes)), + }; + 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) +} + +#[tauri::command] +pub async fn install_pending_update( + app: AppHandle, + request: InstallPendingUpdateRequest, +) -> Result<(), String> { + let mut state = 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 current = app.package_info().version.clone(); + let pubkey = app + .config() + .plugins + .0 + .get("updater") + .and_then(|v| v.get("pubkey")) + .and_then(|v| v.as_str()) + .ok_or_else(|| "Update signature public key is unavailable".to_string())? + .to_owned(); + let (record, bytes) = tokio::task::spawn_blocking(move || { + let record = read_record(&dir, ¤t, &platform())? + .ok_or_else(|| "No downloaded update is available".to_string())?; + if record.version != request.version { + return Err( + "The downloaded update has changed; reopen About before installing".to_string(), + ); + } + let bytes = std::fs::read(dir.join(record.package_name()?)).map_err(|e| e.to_string())?; + verify_package(&bytes, &record, &pubkey)?; + Ok::<_, String>((record, bytes)) + }) + .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 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. + super::system_api::restart_app(app, Default::default()).await +} + +#[cfg(test)] +mod tests { + use super::*; + + fn record(bytes: &[u8]) -> PendingUpdateRecord { + PendingUpdateRecord { + version: "2.0.0".into(), + platform: "test".into(), + signature: "invalid".into(), + sha256: format!("{:x}", Sha256::digest(bytes)), + } + } + + #[test] + fn staged_update_survives_reload_and_is_not_offered_after_upgrade() { + let dir = tempfile::tempdir().unwrap(); + let record = record(b"package"); + save_package(dir.path(), &record, b"package").unwrap(); + assert_eq!( + read_record(dir.path(), &"1.0.0".parse().unwrap(), "test") + .unwrap() + .unwrap() + .version, + "2.0.0" + ); + assert!(read_record(dir.path(), &"2.0.0".parse().unwrap(), "test") + .unwrap() + .is_none()); + assert!(read_record(dir.path(), &"1.0.0".parse().unwrap(), "other") + .unwrap() + .is_none()); + } + + #[test] + fn corrupt_metadata_is_preserved_and_invalid_package_paths_are_rejected() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join(RECORD_NAME), b"invalid").unwrap(); + assert!(read_record(dir.path(), &"1.0.0".parse().unwrap(), "test").is_err()); + assert_eq!( + std::fs::read(dir.path().join(RECORD_NAME)).unwrap(), + b"invalid" + ); + let mut record = record(b"package"); + record.sha256 = "../outside".into(); + assert!(record.package_name().is_err()); + } + + #[test] + fn cached_bytes_cannot_bypass_signature_verification() { + assert!(verify_package(b"tampered", &record(b"package"), "invalid").is_err()); + assert!(verify_package(b"package", &record(b"package"), "invalid").is_err()); + } + + #[test] + fn explicit_redownload_atomically_replaces_the_pending_package() { + let dir = tempfile::tempdir().unwrap(); + let first = record(b"first"); + save_package(dir.path(), &first, b"first").unwrap(); + // Retrying the same download must also work on Windows. + save_package(dir.path(), &first, b"first").unwrap(); + let mut replacement = record(b"replacement"); + replacement.version = "2.1.0".into(); + save_package(dir.path(), &replacement, b"replacement").unwrap(); + let restored = read_record(dir.path(), &"1.0.0".parse().unwrap(), "test") + .unwrap() + .unwrap(); + assert_eq!(restored.version, "2.1.0"); + assert_eq!( + std::fs::read(dir.path().join(restored.package_name().unwrap())).unwrap(), + b"replacement" + ); + } + + #[test] + fn signed_cache_verifies_after_reload_and_rejects_tampering_even_with_a_new_checksum() { + // Public fixture shared with the release-verification contract tests. + let key = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IERENTQzQUM5RUY0NTIzRTMKUldUakkwWHZ5VHBVM1NOMXJWMHhLVlljSDBOY2x4YlpxVHA2clN1NEJPMWcyY2Qvd2U4VUR2b3AK"; + let signature = "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVUakkwWHZ5VHBVM2RVVFdoR3FNZDltSWNUeEQ1K2ZnNWRUSnYxWk5lUkZzd0h0MkdzSUhUSlV6a0haUTdNZm1aemM5QVBQWW50UWgvaWpFcEp1Zkp4SERWdnhIc1g2YUFrPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4NDg2NTU4CWZpbGU6Lm9wZW5iaXRmdW4tbWluaXNpZ24tZml4dHVyZS50eHQKa1QxdDQ3bWtLVlhaZUdFSjR4R0V5R1Z3REVnUlI0RGJqbHFoZkVHdkdLSlFyTGJ5Z05JRTI5V3dwdXRkSFpZckUrK0RaUVVJYUJod1dzcmVydHZnQXc9PQo="; + let bytes = b"hello-openbitfun\n"; + let mut record = record(bytes); + record.signature = signature.into(); + let dir = tempfile::tempdir().unwrap(); + save_package(dir.path(), &record, bytes).unwrap(); + let mut restored = read_record(dir.path(), &"1.0.0".parse().unwrap(), "test") + .unwrap() + .unwrap(); + verify_package(bytes, &restored, key).unwrap(); + restored.sha256 = format!("{:x}", Sha256::digest(b"tampered")); + assert!(verify_package(b"tampered", &restored, key).is_err()); + } + + #[test] + fn record_accepts_additive_fields() { + let mut json = serde_json::to_value(record(b"package")).unwrap(); + json["futureField"] = true.into(); + let decoded: PendingUpdateRecord = serde_json::from_value(json).unwrap(); + let roundtrip: PendingUpdateRecord = + serde_json::from_slice(&serde_json::to_vec(&decoded).unwrap()).unwrap(); + assert_eq!(roundtrip.version, "2.0.0"); + } +} diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index b89d437f58..86be8e24fd 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1828,6 +1828,9 @@ pub async fn run() { get_app_version, check_for_updates, install_update, + api::update_api::download_update, + api::update_api::get_pending_update, + api::update_api::install_pending_update, api::system_api::open_html_file_in_browser, restart_app, send_system_notification, diff --git a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json index 16f3126b2c..0a0ceff153 100644 --- a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json +++ b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json @@ -4,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "c21d28e130de07890a2ebd6793cf78905c7749f577d8b8e547d9fe12a6e26a95", + "digest": "9973af8307e72206bb5c4af5dfb3249fef2def6a27925a13aca9a25ac62ef8a3", "ownerDigest": "c0e5c187cf62bc6ed06196ce8520b3eb427bf268cf24659b72d2552fb1d99c54", "searchAcceptance": [ { @@ -24851,13 +24851,13 @@ }, { "id": "manual-update", - "titleZh": "手动检查、下载并安装可用更新,然后按需重启", - "titleEn": "Check for, download, and install an available update manually, then restart when needed", + "titleZh": "手动检查并后台下载更新,确认后安装并重启,或稍后从关于页面安装", + "titleEn": "Check and download updates in the background, then confirm installation and restart or install later from About", "control": { "kind": "open", "reasonCode": "unstructuredInteraction", - "reasonZh": "“手动检查、下载并安装可用更新,然后按需重启”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;Agent 会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Check for, download, and install an available update manually, then restart when needed” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "reasonZh": "“手动检查并后台下载更新,确认后安装并重启,或稍后从关于页面安装”需要下载后由用户确认安装与重启;Agent 会打开精确入口,并把后续交互保留在用户可见界面。", + "reasonEn": "“Check and download updates in the background, then confirm installation and restart or install later from About” requires user confirmation after downloading, before installation and restart; the Agent opens the exact entry and keeps the remaining interaction visible to the user." } }, { @@ -25091,8 +25091,8 @@ "Completion, permission, and startup-tip notifications", "启用或停用自动检查更新", "Enable or disable automatic update checks", - "手动检查、下载并安装可用更新,然后按需重启", - "Check for, download, and install an available update manually, then restart when needed", + "手动检查并后台下载更新,确认后安装并重启,或稍后从关于页面安装", + "Check and download updates in the background, then confirm installation and restart or install later from About", "控制 OpenBitFun 是否在登录系统后自动启动", "Choose whether OpenBitFun launches automatically after system sign-in", "控制 OpenBitFun 运行期间是否阻止电脑自动睡眠", 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 33e886f462..814d75153a 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:4428afcddfc4d58f", + "digest": "fnv1a64:90117ec8e98d6d48", "retiredCommandPrefixes": [ { "prefix": "lsp_", @@ -1887,6 +1887,18 @@ "reason": "the CLI peer host has no handler for this command" } }, + { + "id": "download_update", + "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": "editor_ai_cancel", "surface": "tauri_command", @@ -2786,6 +2798,18 @@ "reason": "the controller keeps this command; peer hosts refuse it before dispatch" } }, + { + "id": "get_pending_update", + "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": "get_prevent_sleep_enabled", "surface": "tauri_command", @@ -3747,6 +3771,18 @@ "reason": "the CLI peer host has no handler for this command" } }, + { + "id": "install_pending_update", + "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": "install_update", "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 2d35cd85f6..8c99e932fc 100644 --- a/src/crates/contracts/product-domains/src/remote_surface/table.rs +++ b/src/crates/contracts/product-domains/src/remote_surface/table.rs @@ -240,6 +240,7 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ host_invoke_only("dispatch_target_workspace_sync", Agnostic, HostControlPlane, HANDLED), host_invoke_only("dispatch_target_workspace_sync_chunk", Agnostic, HostControlPlane, HANDLED), op("download_skill_market", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), + op("download_update", Agnostic, ControllerLocal, REFUSED), op("editor_ai_cancel", Unaudited, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), op("editor_ai_stream", Unaudited, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), op("ensure_assistant_bootstrap", Unsupported, Proxied, CLI_NOT_IMPLEMENTED), @@ -316,6 +317,7 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("get_operation_diff", Routed, Proxied, CLI_NOT_IMPLEMENTED), op("get_operation_summary", Routed, Proxied, CLI_NOT_IMPLEMENTED), op("get_pending_announcements", Agnostic, ControllerLocal, REFUSED), + op("get_pending_update", Agnostic, ControllerLocal, REFUSED), op("get_prevent_sleep_enabled", LocalOnly, ControllerLocal, REFUSED), op("get_primary_assistant_workspace", Agnostic, Proxied, CLI_NOT_IMPLEMENTED), op("get_project_permission_rules", Routed, Proxied, CLI_NOT_IMPLEMENTED), @@ -397,6 +399,7 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("initialize_tray_after_startup", LocalOnly, ControllerLocal, REFUSED), op("initialize_workspace_startup_state", Routed, Proxied, HANDLED), op("install_acp_client_cli", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), + op("install_pending_update", Agnostic, ControllerLocal, REFUSED), op("install_update", Agnostic, ControllerLocal, REFUSED), op("interrupt_dialog_turn", LocalOnly, Proxied, CLI_NOT_IMPLEMENTED), op("list_agent_companion_pets", Unaudited, ControllerLocal, REFUSED), diff --git a/src/shared/interactive-capabilities/catalog.json b/src/shared/interactive-capabilities/catalog.json index 30f50fce37..185eed8ca2 100644 --- a/src/shared/interactive-capabilities/catalog.json +++ b/src/shared/interactive-capabilities/catalog.json @@ -182,6 +182,9 @@ } ], "implementationOnlyCommands": { + "legacyUpdateCompatibility": [ + "install_update" + ], "commandDiscovery": [ "check_command_exists", "check_commands_exist" @@ -217,6 +220,7 @@ ], "platformWindowLifecycle": [ "hide_main_window_after_close_request", + "restart_app", "set_macos_edit_menu_mode", "set_main_window_transient_geometry", "startup_window_control" @@ -5192,18 +5196,19 @@ }, { "id": "manual-update", - "titleZh": "手动检查、下载并安装可用更新,然后按需重启", - "titleEn": "Check for, download, and install an available update manually, then restart when needed", + "titleZh": "手动检查并后台下载更新,确认后安装并重启,或稍后从关于页面安装", + "titleEn": "Check and download updates in the background, then confirm installation and restart or install later from About", "control": { "kind": "open", "reasonCode": "unstructuredInteraction", - "reasonZh": "“手动检查、下载并安装可用更新,然后按需重启”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;Agent 会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Check for, download, and install an available update manually, then restart when needed” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "reasonZh": "“手动检查并后台下载更新,确认后安装并重启,或稍后从关于页面安装”需要下载后由用户确认安装与重启;Agent 会打开精确入口,并把后续交互保留在用户可见界面。", + "reasonEn": "“Check and download updates in the background, then confirm installation and restart or install later from About” requires user confirmation after downloading, before installation and restart; the Agent opens the exact entry and keeps the remaining interaction visible to the user." }, "evidence": [ "command:check_for_updates", - "command:install_update", - "command:restart_app", + "command:download_update", + "command:get_pending_update", + "command:install_pending_update", "command:get_app_version" ] }, @@ -9379,6 +9384,9 @@ "system": { "capabilityId": "setting.application.general" }, + "update": { + "capabilityId": "setting.application.general" + }, "miniapp_market": { "capabilityId": "feature.miniapps" }, diff --git a/src/web-ui/src/app/components/AboutDialog/AboutDialog.tsx b/src/web-ui/src/app/components/AboutDialog/AboutDialog.tsx index effb18b53c..1f0577c99d 100644 --- a/src/web-ui/src/app/components/AboutDialog/AboutDialog.tsx +++ b/src/web-ui/src/app/components/AboutDialog/AboutDialog.tsx @@ -60,6 +60,9 @@ export const AboutDialog: React.FC = ({ const updateProgress = useUpdateInstallStore(state => state.progress); const updateError = useUpdateInstallStore(state => state.error); const startUpdateInstall = useUpdateInstallStore(state => state.startInstall); + const requestInstall = useUpdateInstallStore(state => state.requestInstall); + const updateVersion = useUpdateInstallStore(state => state.version); + const updateInitialized = useUpdateInstallStore(state => state.initialized); const aboutInfo = getAboutInfo(); const { version, license } = aboutInfo; @@ -101,6 +104,7 @@ export const AboutDialog: React.FC = ({ useEffect(() => { if (!isOpen || !nativeRuntime) return; + if (canCheckForAppUpdates()) void useUpdateInstallStore.getState().initialize(); let active = true; void systemAPI.getAppVersion() .then(currentVersion => { @@ -155,14 +159,10 @@ export const AboutDialog: React.FC = ({ void startUpdateInstall(); }, [startUpdateInstall]); - const onRestart = useCallback(async () => { - try { - await systemAPI.restartApp(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - useUpdateInstallStore.setState({ status: 'error', error: message }); - } - }, []); + const onRestart = useCallback(() => { + onClose(); + requestInstall(); + }, [onClose, requestInstall]); const copyToClipboard = async (text: string, itemId: string) => { try { @@ -175,7 +175,7 @@ export const AboutDialog: React.FC = ({ }; const updateState = `${manualCheckBusy ? 'checking' : ''} ${manualCheckStatus} ${updateStatus}`.trim(); - const updateBusy = manualCheckBusy || updateStatus === 'downloading' || updateStatus === 'installed'; + const updateBusy = !updateInitialized || manualCheckBusy || updateStatus === 'downloading' || updateStatus === 'ready' || updateStatus === 'installing'; return ( <> @@ -438,14 +438,14 @@ export const AboutDialog: React.FC = ({

) : null} - {updateStatus === 'installed' ? ( + {updateStatus === 'ready' || updateStatus === 'installing' ? (
-
) : null} diff --git a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json index 448472ae95..a635ba4c23 100644 --- a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json +++ b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json @@ -4,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "c21d28e130de07890a2ebd6793cf78905c7749f577d8b8e547d9fe12a6e26a95", + "digest": "9973af8307e72206bb5c4af5dfb3249fef2def6a27925a13aca9a25ac62ef8a3", "ownerDigest": "c0e5c187cf62bc6ed06196ce8520b3eb427bf268cf24659b72d2552fb1d99c54", "searchAcceptance": [ { @@ -24810,13 +24810,13 @@ }, { "id": "manual-update", - "titleZh": "手动检查、下载并安装可用更新,然后按需重启", - "titleEn": "Check for, download, and install an available update manually, then restart when needed", + "titleZh": "手动检查并后台下载更新,确认后安装并重启,或稍后从关于页面安装", + "titleEn": "Check and download updates in the background, then confirm installation and restart or install later from About", "control": { "kind": "open", "reasonCode": "unstructuredInteraction", - "reasonZh": "“手动检查、下载并安装可用更新,然后按需重启”由多个实时状态相关步骤组成,目前没有一个能确定完成整个流程的单一结构化 Command;Agent 会打开精确入口,并把后续交互保留在用户可见界面。", - "reasonEn": "“Check for, download, and install an available update manually, then restart when needed” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." + "reasonZh": "“手动检查并后台下载更新,确认后安装并重启,或稍后从关于页面安装”需要下载后由用户确认安装与重启;Agent 会打开精确入口,并把后续交互保留在用户可见界面。", + "reasonEn": "“Check and download updates in the background, then confirm installation and restart or install later from About” requires user confirmation after downloading, before installation and restart; the Agent opens the exact entry and keeps the remaining interaction visible to the user." } }, { @@ -25020,8 +25020,8 @@ "Completion, permission, and startup-tip notifications", "启用或停用自动检查更新", "Enable or disable automatic update checks", - "手动检查、下载并安装可用更新,然后按需重启", - "Check for, download, and install an available update manually, then restart when needed", + "手动检查并后台下载更新,确认后安装并重启,或稍后从关于页面安装", + "Check and download updates in the background, then confirm installation and restart or install later from About", "控制 OpenBitFun 是否在登录系统后自动启动", "Choose whether OpenBitFun launches automatically after system sign-in", "控制 OpenBitFun 运行期间是否阻止电脑自动睡眠", diff --git a/src/web-ui/src/infrastructure/api/generated/productControl.ts b/src/web-ui/src/infrastructure/api/generated/productControl.ts index 438c985268..59a760dce6 100644 --- a/src/web-ui/src/infrastructure/api/generated/productControl.ts +++ b/src/web-ui/src/infrastructure/api/generated/productControl.ts @@ -1,5 +1,5 @@ // Generated by scripts/generate-interactive-capabilities.mjs; do not edit. -export const PRODUCT_CONTROL_GRAPH_DIGEST = "c21d28e130de07890a2ebd6793cf78905c7749f577d8b8e547d9fe12a6e26a95" as const; +export const PRODUCT_CONTROL_GRAPH_DIGEST = "9973af8307e72206bb5c4af5dfb3249fef2def6a27925a13aca9a25ac62ef8a3" as const; export type ProductControlCapabilityId = "feature.ai-assistant" | "feature.agents" | "feature.personal-assistants" | "feature.projects" | "feature.files-editor" | "feature.terminal" | "feature.git" | "feature.code-review" | "feature.browser" | "feature.computer-use" | "feature.skills" | "feature.miniapps" | "feature.canvas" | "feature.tasks-automation" | "feature.insights" | "feature.ecosystem-compatibility" | "feature.remote-workspaces" | "feature.remote-connect" | "feature.detached-dispatch" | "feature.pages" | "feature.voice-input" | "feature.desktop-pet" | "setting.application.general" | "setting.application.appearance" | "setting.application.pet" | "setting.application.input" | "setting.application.shortcuts" | "setting.application.development" | "setting.ai.models" | "setting.ai.memory" | "setting.workspace.session" | "setting.workspace.worktrees" | "setting.tools.execution" | "setting.application.terminal" | "setting.tools.desktop-control" | "setting.tools.browser-control" | "setting.tools.automation" | "setting.tools.web-search" | "setting.tools.mcp" | "setting.tools.acp" | "setting.data.usage" | "setting.data.archived" | "setting.data.diagnostics"; diff --git a/src/web-ui/src/infrastructure/api/generated/remoteSurface.test.ts b/src/web-ui/src/infrastructure/api/generated/remoteSurface.test.ts index 7c446db791..62e510a061 100644 --- a/src/web-ui/src/infrastructure/api/generated/remoteSurface.test.ts +++ b/src/web-ui/src/infrastructure/api/generated/remoteSurface.test.ts @@ -73,6 +73,9 @@ describe('remote surface generated bindings', () => { 'dispatch_submit', 'mark_openbitfun_control_surface_ready', 'show_main_window', + 'download_update', + 'get_pending_update', + 'install_pending_update', ]) { expect(PEER_CONTROLLER_LOCAL_COMMANDS.has(command), command).toBe(true); } diff --git a/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts b/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts index 8839abb09d..1b3b3553d9 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:4428afcddfc4d58f" as const; +export const REMOTE_SURFACE_REGISTRY_DIGEST = "fnv1a64:90117ec8e98d6d48" as const; /** * Registered Tauri commands the Peer Device controller keeps on the controller @@ -76,6 +76,7 @@ export const PEER_CONTROLLER_LOCAL_COMMANDS: ReadonlySet = new Set([ "dispatch_submit", "dispatch_sync_model_config", "dispatch_sync_result", + "download_update", "frontend_update_candidate_failed", "frontend_update_candidate_ready", "generate_insights", @@ -83,6 +84,7 @@ export const PEER_CONTROLLER_LOCAL_COMMANDS: ReadonlySet = new Set([ "get_frontend_update_status", "get_latest_insights", "get_pending_announcements", + "get_pending_update", "get_prevent_sleep_enabled", "has_insights_data", "hide_agent_companion_desktop_pet", @@ -96,6 +98,7 @@ export const PEER_CONTROLLER_LOCAL_COMMANDS: ReadonlySet = new Set([ "i18n_set_language", "import_agent_companion_pet_package", "initialize_tray_after_startup", + "install_pending_update", "install_update", "list_agent_companion_pets", "load_insights_report", 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 d668e192fc..3b4e4aa513 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 @@ -67,6 +67,21 @@ describe('SystemAPI', () => { }); }); + it('allows a background download to outlive the default request timeout without replaying it', async () => { + invokeMock.mockResolvedValueOnce({ version: '2.0.0' }); + await expect(systemAPI.downloadUpdate()).resolves.toEqual({ version: '2.0.0' }); + expect(invokeMock).toHaveBeenCalledWith('download_update', { request: {} }, { + timeout: 3600000, retries: 0, + }); + }); + + it('installs only the version the user confirmed and disables automatic mutation retries', async () => { + await systemAPI.installPendingUpdate('2.0.0'); + expect(invokeMock).toHaveBeenCalledWith('install_pending_update', { + request: { version: '2.0.0' }, + }, { timeout: 120000, retries: 0 }); + }); + it('sends the requested app-wide state', async () => { invokeMock.mockResolvedValueOnce(undefined); 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 1b3f7bfc2f..62f68e3658 100644 --- a/src/web-ui/src/infrastructure/api/service-api/SystemAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/SystemAPI.ts @@ -19,6 +19,10 @@ export interface CheckForUpdatesResponse { releaseDate: string | null; } +export interface PendingUpdateResponse { + version: string; +} + /** Matches `toggle_main_window_fullscreen` / desktop `ToggleMainWindowFullscreenResponse`. */ export interface ToggleMainWindowFullscreenResponse { isFullscreen: boolean; @@ -76,6 +80,37 @@ export class SystemAPI { } } + /** Download and verify without starting the installer. */ + async downloadUpdate(): Promise { + try { + return await api.invoke('download_update', { request: {} }, { + timeout: 60 * 60 * 1000, + retries: 0, + }); + } catch (error) { + throw createTauriCommandError('download_update', error); + } + } + + async getPendingUpdate(): Promise { + try { + return await api.invoke('get_pending_update', { request: {} }); + } catch (error) { + throw createTauriCommandError('get_pending_update', error); + } + } + + async installPendingUpdate(version: string): Promise { + try { + await api.invoke('install_pending_update', { request: { version } }, { + timeout: 120000, + retries: 0, + }); + } catch (error) { + throw createTauriCommandError('install_pending_update', error); + } + } + /** Desktop only: restart the app after an update has been installed. */ async restartApp(): Promise { try { diff --git a/src/web-ui/src/infrastructure/update/DailyAppUpdateGate.tsx b/src/web-ui/src/infrastructure/update/DailyAppUpdateGate.tsx index 19c157d7db..4d095540f1 100644 --- a/src/web-ui/src/infrastructure/update/DailyAppUpdateGate.tsx +++ b/src/web-ui/src/infrastructure/update/DailyAppUpdateGate.tsx @@ -13,6 +13,8 @@ import { import { UpdateAvailableDialog } from './UpdateAvailableDialog'; import { UpdateInstallProgressModal } from './UpdateInstallProgressModal'; import { useUpdateInstallStore } from './updateInstallStore'; +import { useI18n } from '@/infrastructure/i18n'; +import { notificationService } from '@/shared/notification-system'; const log = createLogger('DailyAppUpdate'); @@ -21,6 +23,7 @@ const log = createLogger('DailyAppUpdate'); * Renders update dialogs; mount once near the app root (e.g. inside AppLayout). */ export function DailyAppUpdateGate(): ReactElement | null { + const { t } = useI18n('common'); const [dailyOpen, setDailyOpen] = useState(false); const [dailyData, setDailyData] = useState(null); const dailyCheckTimerRef = useRef(null); @@ -29,7 +32,10 @@ export function DailyAppUpdateGate(): ReactElement | null { const updateError = useUpdateInstallStore(state => state.error); const startUpdateInstall = useUpdateInstallStore(state => state.startInstall); const clearUpdateError = useUpdateInstallStore(state => state.clearError); - const clearUpdateInstalled = useUpdateInstallStore(state => state.clearInstalled); + const promptOpen = useUpdateInstallStore(state => state.promptOpen); + const updateVersion = useUpdateInstallStore(state => state.version); + const deferInstall = useUpdateInstallStore(state => state.deferInstall); + const confirmInstall = useUpdateInstallStore(state => state.confirmInstall); useEffect(() => { if (!canCheckForAppUpdates()) { @@ -37,6 +43,8 @@ export function DailyAppUpdateGate(): ReactElement | null { } let cancelled = false; const runDailyCheck = async () => { + await useUpdateInstallStore.getState().initialize(); + if (cancelled || useUpdateInstallStore.getState().status !== 'idle') return; let autoUpdate = true; try { const v = await configManager.getConfig('app.auto_update'); @@ -57,7 +65,7 @@ export function DailyAppUpdateGate(): ReactElement | null { return; } const res = await systemAPI.checkForUpdates(); - if (cancelled) { + if (cancelled || useUpdateInstallStore.getState().status !== 'idle') { return; } if (!res.updateAvailable || !res.latestVersion) { @@ -130,17 +138,10 @@ export function DailyAppUpdateGate(): ReactElement | null { }, [clearUpdateError]); const onCloseInstalled = useCallback(() => { - clearUpdateInstalled(); - }, [clearUpdateInstalled]); - - const onRestart = useCallback(async () => { - try { - await systemAPI.restartApp(); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - useUpdateInstallStore.setState({ status: 'error', error: msg }); - } - }, []); + if (useUpdateInstallStore.getState().status !== 'ready') return; + deferInstall(); + notificationService.info(t('update.deferredMessage')); + }, [deferInstall, t]); if (!isTauriRuntime()) { return null; @@ -157,13 +158,16 @@ export function DailyAppUpdateGate(): ReactElement | null { onInstall={onInstall} /> void confirmInstall()} + onDownloadAgain={() => void startUpdateInstall(true)} /> ); diff --git a/src/web-ui/src/infrastructure/update/UpdateInstallProgressModal.test.tsx b/src/web-ui/src/infrastructure/update/UpdateInstallProgressModal.test.tsx new file mode 100644 index 0000000000..e4b80c33b5 --- /dev/null +++ b/src/web-ui/src/infrastructure/update/UpdateInstallProgressModal.test.tsx @@ -0,0 +1,82 @@ +// @vitest-environment jsdom + +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { UpdateInstallProgressModal } from './UpdateInstallProgressModal'; +import common from '@/locales/en-US/common.json'; + +vi.mock('@/infrastructure/i18n', () => ({ + useI18n: () => ({ t: (key: string, args?: Record) => { + const value = key.split('.').reduce((value, part) => (value as Record)?.[part], common); + return String(value ?? key).replace(/\{\{(\w+)\}\}/g, (_, name) => args?.[name] ?? ''); + } }), +})); + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; +let root: Root; +let container: HTMLDivElement; +beforeEach(() => { + vi.stubGlobal('ResizeObserver', class { + observe() {} + unobserve() {} + disconnect() {} + }); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); +}); +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +const buttons = () => Array.from(document.querySelectorAll('button')); +const button = (text: string) => buttons().find(button => button.textContent?.includes(text))!; + +it('shows the downloaded version and interruption notice before allowing installation', async () => { + const install = vi.fn(); + const defer = vi.fn(); + await act(async () => root.render()); + expect(document.body.textContent).toContain('Version 2.0.0 is downloaded'); + expect(document.body.textContent).toContain('interrupt its active sessions'); + expect(install).not.toHaveBeenCalled(); + await act(async () => button(common.update.restartLater).click()); + expect(defer).toHaveBeenCalledOnce(); + expect(install).not.toHaveBeenCalled(); + await act(async () => button(common.update.installAndRestart).click()); + expect(install).toHaveBeenCalledOnce(); +}); + +it('keeps retry and defer actions available after an install error', async () => { + const download = vi.fn(); + await act(async () => root.render()); + expect(button(common.update.installAndRestart).disabled).toBe(false); + expect(button(common.update.restartLater).disabled).toBe(false); + await act(async () => button(common.update.downloadAgain).click()); + expect(download).toHaveBeenCalledOnce(); +}); + +it('prevents dismissing or submitting the dialog again while installation is in progress', async () => { + const defer = vi.fn(); + const install = vi.fn(); + await act(async () => root.render()); + expect(button(common.update.restartLater).disabled).toBe(true); + expect(button(common.update.installing).disabled).toBe(true); + await act(async () => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + button(common.update.installing).click(); + }); + expect(defer).not.toHaveBeenCalled(); + expect(install).not.toHaveBeenCalled(); +}); diff --git a/src/web-ui/src/infrastructure/update/UpdateInstallProgressModal.tsx b/src/web-ui/src/infrastructure/update/UpdateInstallProgressModal.tsx index a9236fdc38..0dd9b50a99 100644 --- a/src/web-ui/src/infrastructure/update/UpdateInstallProgressModal.tsx +++ b/src/web-ui/src/infrastructure/update/UpdateInstallProgressModal.tsx @@ -22,20 +22,26 @@ export interface UpdateInstallProgressModalProps { isOpen: boolean; error: string | null; installed?: boolean; + installing?: boolean; + version?: string | null; progress: UpdateDownloadProgressPayload; onCloseError?: () => void; onCloseInstalled?: () => void; onRestart?: () => void; + onDownloadAgain?: () => void; } export const UpdateInstallProgressModal: React.FC = ({ isOpen, error, installed, + installing, + version, progress, onCloseError, onCloseInstalled, - onRestart + onRestart, + onDownloadAgain }) => { const { t } = useI18n('common'); const { downloaded, total } = progress; @@ -47,21 +53,23 @@ export const UpdateInstallProgressModal: React.FC {}; - if (error) { - onClose = onCloseError ?? (() => {}); + if (installing) { + onClose = () => {}; } else if (installed) { onClose = onCloseInstalled ?? (() => {}); + } else if (error) { + onClose = onCloseError ?? (() => {}); } let body: React.ReactNode = null; - if (errorMessage) { + if (errorMessage && !installed) { body = (
+

{t('update.installWarning')}

+ {errorMessage ? : null}
- + ) : null} + -
@@ -138,7 +153,7 @@ export const UpdateInstallProgressModal: React.FC {title} - {!!error || !!installed && } + {!installing && (!!error || !!installed) && }
({ download: vi.fn(), install: vi.fn(), listen: vi.fn(), unlisten: vi.fn() })); +vi.mock('@/infrastructure/api', () => ({ systemAPI: { downloadUpdate: mocks.download, installUpdate: mocks.install } })); +vi.mock('@tauri-apps/api/event', () => ({ listen: mocks.listen })); +vi.mock('@/shared/utils/logger', () => ({ createLogger: () => ({ error: vi.fn() }) })); + +it('subscribes before downloading, returns the prepared version and never installs', async () => { + mocks.listen.mockImplementation(async (_name, callback) => { + callback({ payload: { downloaded: 12, total: null } }); + return mocks.unlisten; + }); + mocks.download.mockResolvedValue({ version: '2.0.0' }); + const progress = vi.fn(); + await expect(installUpdateWithProgress(progress)).resolves.toEqual({ version: '2.0.0' }); + expect(progress).toHaveBeenCalledWith({ downloaded: 12, total: null }); + expect(mocks.unlisten).toHaveBeenCalledOnce(); + expect(mocks.install).not.toHaveBeenCalled(); +}); + +it('cleans up its progress listener when signature verification fails', async () => { + mocks.unlisten.mockClear(); + mocks.listen.mockResolvedValue(mocks.unlisten); + mocks.download.mockRejectedValue(new Error('signature invalid')); + await expect(installUpdateWithProgress(vi.fn())).rejects.toThrow('signature invalid'); + expect(mocks.unlisten).toHaveBeenCalledOnce(); +}); diff --git a/src/web-ui/src/infrastructure/update/installUpdateWithProgress.ts b/src/web-ui/src/infrastructure/update/installUpdateWithProgress.ts index d1e7728c2a..c2a13a4b75 100644 --- a/src/web-ui/src/infrastructure/update/installUpdateWithProgress.ts +++ b/src/web-ui/src/infrastructure/update/installUpdateWithProgress.ts @@ -11,12 +11,11 @@ export interface UpdateDownloadProgressPayload { } /** - * Subscribes to Rust-emitted download progress, then runs `install_update`. - * Unsubscribes when the install promise settles. + * Downloads and verifies only. Installation requires a separate confirmation. */ export async function installUpdateWithProgress( onProgress: (p: UpdateDownloadProgressPayload) => void -): Promise { +): Promise { const { listen } = await import('@tauri-apps/api/event'); const unlisten = await listen( UPDATE_PROGRESS_EVENT, @@ -29,9 +28,9 @@ export async function installUpdateWithProgress( } ); try { - await systemAPI.installUpdate(); + return await systemAPI.downloadUpdate(); } catch (error) { - log.error('install_update failed', error); + log.error('Update download failed', error); throw error; } finally { unlisten(); diff --git a/src/web-ui/src/infrastructure/update/updateInstallStore.test.ts b/src/web-ui/src/infrastructure/update/updateInstallStore.test.ts new file mode 100644 index 0000000000..9705ac75b0 --- /dev/null +++ b/src/web-ui/src/infrastructure/update/updateInstallStore.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useUpdateInstallStore } from './updateInstallStore'; + +const mocks = vi.hoisted(() => ({ pending: vi.fn(), download: vi.fn(), install: vi.fn() })); +vi.mock('@/infrastructure/api', () => ({ systemAPI: { + getPendingUpdate: mocks.pending, + installPendingUpdate: mocks.install, +} })); +vi.mock('./installUpdateWithProgress', () => ({ installUpdateWithProgress: mocks.download })); +vi.mock('@/shared/utils/logger', () => ({ createLogger: () => ({ error: vi.fn() }) })); + +const state = () => useUpdateInstallStore.getState(); + +beforeEach(() => { + vi.resetAllMocks(); + mocks.pending.mockResolvedValue(null); + mocks.download.mockResolvedValue({ version: '2.0.0' }); + mocks.install.mockResolvedValue(undefined); + useUpdateInstallStore.setState({ + status: 'idle', progress: { downloaded: 0, total: null }, error: null, + startedAt: null, version: null, promptOpen: false, initialized: false, + }); +}); + +describe('staged app update', () => { + it('downloads in the background, then requests confirmation without installing', async () => { + mocks.download.mockImplementation(async (progress) => { + expect(state().status).toBe('downloading'); + expect(state().promptOpen).toBe(false); + progress({ downloaded: 100, total: 100 }); + return { version: '2.0.0' }; + }); + await state().startInstall(); + expect(state()).toMatchObject({ status: 'ready', version: '2.0.0', promptOpen: true }); + expect(mocks.install).not.toHaveBeenCalled(); + }); + + it('defers without discarding the package and installs from About without downloading again', async () => { + await state().startInstall(); + state().deferInstall(); + expect(state()).toMatchObject({ status: 'ready', version: '2.0.0', promptOpen: false }); + await state().confirmInstall(); + expect(mocks.install).not.toHaveBeenCalled(); + state().requestInstall(); + await state().confirmInstall(); + expect(mocks.install).toHaveBeenCalledWith('2.0.0'); + expect(mocks.download).toHaveBeenCalledTimes(1); + expect(state().status).toBe('installing'); + }); + + it('restores a downloaded update after restart without forcing a new prompt', async () => { + mocks.pending.mockResolvedValue({ version: '2.0.0' }); + await state().initialize(); + await state().startInstall(); + expect(state()).toMatchObject({ status: 'ready', version: '2.0.0', promptOpen: false }); + expect(mocks.download).not.toHaveBeenCalled(); + expect(mocks.install).not.toHaveBeenCalled(); + }); + + it('does not mark a failed download or signature check ready', async () => { + mocks.download.mockRejectedValue(new Error('signature invalid')); + await state().startInstall(); + expect(state()).toMatchObject({ status: 'error', version: null, promptOpen: false }); + await state().confirmInstall(); + expect(mocks.install).not.toHaveBeenCalled(); + }); + + it('retains the pending version after an install failure and permits retry', async () => { + await state().startInstall(); + mocks.install.mockRejectedValueOnce(new Error('installer unavailable')); + await state().confirmInstall(); + expect(state()).toMatchObject({ status: 'ready', version: '2.0.0', promptOpen: true, error: 'installer unavailable' }); + await state().confirmInstall(); + expect(mocks.install).toHaveBeenCalledTimes(2); + expect(mocks.download).toHaveBeenCalledTimes(1); + }); + + it('coalesces startup reads and prevents duplicate downloads and installs', async () => { + let finish!: (value: { version: string }) => void; + mocks.download.mockImplementation(() => new Promise(resolve => { finish = resolve; })); + const first = state().startInstall(); + const second = state().startInstall(); + await vi.waitFor(() => expect(mocks.download).toHaveBeenCalledTimes(1)); + finish({ version: '2.0.0' }); + await Promise.all([first, second]); + expect(mocks.pending).toHaveBeenCalledTimes(1); + await Promise.all([state().confirmInstall(), state().confirmInstall()]); + expect(mocks.install).toHaveBeenCalledTimes(1); + state().deferInstall(); + expect(state().status).toBe('installing'); + }); + + it('allows an explicit replacement download after a cached package fails installation', async () => { + await state().startInstall(); + mocks.install.mockRejectedValueOnce(new Error('package corrupt')); + await state().confirmInstall(); + mocks.download.mockResolvedValueOnce({ version: '2.1.0' }); + await state().startInstall(true); + expect(state()).toMatchObject({ status: 'ready', version: '2.1.0', error: null, promptOpen: true }); + expect(mocks.download).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/web-ui/src/infrastructure/update/updateInstallStore.ts b/src/web-ui/src/infrastructure/update/updateInstallStore.ts index 207e1c1ed5..1012b8bbfc 100644 --- a/src/web-ui/src/infrastructure/update/updateInstallStore.ts +++ b/src/web-ui/src/infrastructure/update/updateInstallStore.ts @@ -1,75 +1,89 @@ import { create } from 'zustand'; import { createLogger } from '@/shared/utils/logger'; -import { - installUpdateWithProgress, - type UpdateDownloadProgressPayload -} from './installUpdateWithProgress'; +import { systemAPI } from '@/infrastructure/api'; +import { installUpdateWithProgress, type UpdateDownloadProgressPayload } from './installUpdateWithProgress'; const log = createLogger('UpdateInstallStore'); -export type UpdateInstallStatus = 'idle' | 'downloading' | 'installed' | 'error'; +export type UpdateInstallStatus = 'idle' | 'downloading' | 'ready' | 'installing' | 'error'; interface UpdateInstallState { status: UpdateInstallStatus; progress: UpdateDownloadProgressPayload; error: string | null; startedAt: number | null; - startInstall: () => Promise; + version: string | null; + promptOpen: boolean; + initialized: boolean; + initialize: () => Promise; + startInstall: (replacePending?: boolean) => Promise; + requestInstall: () => void; + confirmInstall: () => Promise; + deferInstall: () => void; clearError: () => void; - clearInstalled: () => void; } -const initialProgress: UpdateDownloadProgressPayload = { - downloaded: 0, - total: null -}; +const initialProgress: UpdateDownloadProgressPayload = { downloaded: 0, total: null }; +let initialization: Promise | null = null; export const useUpdateInstallStore = create((set, get) => ({ - status: 'idle', - progress: initialProgress, - error: null, - startedAt: null, + status: 'idle', progress: initialProgress, error: null, startedAt: null, + version: null, promptOpen: false, initialized: false, - startInstall: async () => { - const status = get().status; - if (status === 'downloading' || status === 'installed') { - return; + initialize: async () => { + if (get().initialized) return; + if (initialization) return initialization; + initialization = (async () => { + try { + const pending = await systemAPI.getPendingUpdate(); + if (pending) set({ status: 'ready', version: pending.version }); + } catch (error) { + log.error('Failed to restore pending update', error); + set({ status: 'error', error: String(error) }); + } finally { + set({ initialized: true }); + } + })(); + try { await initialization; } finally { initialization = null; } + }, + + // Both daily and manual prompts use this download-only preparation step. + startInstall: async (replacePending = false) => { + await get().initialize(); + if (['downloading', 'installing'].includes(get().status)) return; + if (get().status === 'ready' && !replacePending) return; + set({ status: 'downloading', progress: initialProgress, error: null, startedAt: Date.now(), promptOpen: false }); + try { + const pending = await installUpdateWithProgress(progress => set({ progress })); + set({ status: 'ready', version: pending.version, promptOpen: true }); + } catch (error) { + log.error('Update download failed', error); + set({ status: 'error', error: error instanceof Error ? error.message : String(error) }); } + }, - set({ - status: 'downloading', - progress: initialProgress, - error: null, - startedAt: Date.now() - }); + requestInstall: () => { + if (get().status === 'ready') set({ promptOpen: true, error: null }); + }, + confirmInstall: async () => { + const { status, version, promptOpen } = get(); + if (status !== 'ready' || !version || !promptOpen) return; + set({ status: 'installing', error: null }); try { - await installUpdateWithProgress(progress => { - set({ progress }); - }); - set({ status: 'installed', error: null }); + await systemAPI.installPendingUpdate(version); + // Successful installation restarts the host. Keep the action locked until exit. } catch (error) { - const message = error instanceof Error ? error.message : String(error); - log.error('Background update install failed', error); - set({ status: 'error', error: message }); + log.error('Update installation failed', error); + set({ status: 'ready', error: error instanceof Error ? error.message : String(error) }); } }, - clearError: () => { - set({ - status: 'idle', - error: null, - progress: initialProgress, - startedAt: null - }); + deferInstall: () => { + if (get().status === 'ready') set({ promptOpen: false, error: null }); }, - clearInstalled: () => { - set({ - status: 'idle', - error: null, - progress: initialProgress, - startedAt: null - }); - } + clearError: () => { + set({ status: get().version ? 'ready' : 'idle', error: null, promptOpen: false }); + }, })); diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index cf518b075c..b159cf26fa 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -1087,12 +1087,18 @@ "backgroundInstall": "Download in background", "downloadingTitle": "Downloading update", "downloadFailedTitle": "Update could not be installed", - "installedTitle": "Update installed", + "installedTitle": "Update ready", + "readyVersion": "Version {{version}} is downloaded and ready to install.", + "installWarning": "Installing will restart OpenBitFun on this device and interrupt its active sessions.", + "installAndRestart": "Install and restart", + "installing": "Installing…", + "deferredMessage": "The update is saved. Open About and select Install and restart whenever you are ready.", + "downloadAgain": "Download again", "installedMessage": "The update has been installed. Restart OpenBitFun to start using the new version.", "progressPercent": "{{percent}}%", "progressUnknown": "Downloading…", "backgroundDownloading": "Downloading in background", - "backgroundDownloadHint": "You can keep using OpenBitFun. Restart will be offered after installation finishes.", + "backgroundDownloadHint": "You can keep using OpenBitFun. When the download finishes, you can choose whether to install and restart.", "restartHint": "The app will need to restart after installation completes.", "restartNow": "Restart now", "restartLater": "Later", diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index c2ad3aafdd..e11922babe 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -1087,12 +1087,18 @@ "backgroundInstall": "后台下载", "downloadingTitle": "正在下载更新", "downloadFailedTitle": "无法完成更新", - "installedTitle": "更新已安装", + "installedTitle": "更新已准备就绪", + "readyVersion": "版本 {{version}} 已下载,可以安装并重启。", + "installWarning": "安装将重启本设备上的 OpenBitFun,并中断此设备上的活动会话。", + "installAndRestart": "安装并重启", + "installing": "正在安装…", + "deferredMessage": "更新已保留,可随时前往「关于」点击「安装并重启」。", + "downloadAgain": "重新下载", "installedMessage": "更新已安装。重启 OpenBitFun 后即可使用新版本。", "progressPercent": "{{percent}}%", "progressUnknown": "正在下载…", "backgroundDownloading": "正在后台下载", - "backgroundDownloadHint": "下载完成后会提示重启,期间可以继续使用 OpenBitFun。", + "backgroundDownloadHint": "下载完成后会询问是否安装并重启,期间可以继续使用 OpenBitFun。", "restartHint": "安装完成后需要重启应用。", "restartNow": "立即重启", "restartLater": "稍后", diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index f2e07b2b56..4355b7c96c 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -1087,12 +1087,18 @@ "backgroundInstall": "背景下載", "downloadingTitle": "正在下載更新", "downloadFailedTitle": "無法完成更新", - "installedTitle": "更新已安裝", + "installedTitle": "更新已準備就緒", + "readyVersion": "版本 {{version}} 已下載,可以安裝並重啟。", + "installWarning": "安裝將重啟本裝置上的 OpenBitFun,並中斷此裝置上的活動會話。", + "installAndRestart": "安裝並重啟", + "installing": "正在安裝…", + "deferredMessage": "更新已保留,可隨時前往「關於」點擊「安裝並重啟」。", + "downloadAgain": "重新下載", "installedMessage": "更新已安裝。重啟 OpenBitFun 後即可使用新版本。", "progressPercent": "{{percent}}%", "progressUnknown": "正在下載…", "backgroundDownloading": "正在背景下載", - "backgroundDownloadHint": "下載完成後會提示重啟,期間可以繼續使用 OpenBitFun。", + "backgroundDownloadHint": "下載完成後會詢問是否安裝並重啟,期間可以繼續使用 OpenBitFun。", "restartHint": "安裝完成後需要重啟應用。", "restartNow": "立即重啟", "restartLater": "稍後",