diff --git a/apps/desktop/loopx-control-plane/README.md b/apps/desktop/loopx-control-plane/README.md index 139ac93ed8..0d1298f955 100644 --- a/apps/desktop/loopx-control-plane/README.md +++ b/apps/desktop/loopx-control-plane/README.md @@ -60,6 +60,11 @@ continue beyond thirty seconds; stalled headers or body reads remain bounded. The archive client limits apply only after feed discovery; the feed keeps its existing check deadline (45 seconds manually, 30 seconds in the background). Signature verification still precedes cache publication or installation. +The existing recovery and workspace update panels distinguish a download timeout, +an interrupted network request, an unavailable package and a rejected signature. +They keep the coarse cause and retry action without exposing transport URLs or +signature diagnostics. Unclassified failures retain the general download/verification +message; a failure never certifies that an update was installed. Progress and failures use the existing update panel; background IO does not hold the service supervisor or restart a running workspace. @@ -70,6 +75,10 @@ restart a running workspace. 才写入准备缓存或安装。现有更新面板显示进度与失败,后台下载不阻塞服务监督或重启 正在使用的工作区。 +启动恢复页与工作区更新面板分别提示下载超时、网络请求失败、安装包不可用和签名 +校验失败,只展示可操作的原因与重试入口,不暴露传输地址或签名内部错误。 +无法分类的失败仍保留通用提示,失败不会被显示为已安装。 + Startup automatically uses the newest qualified local runtime it can establish. It compares package versions; equal versions with source revisions use ancestry from the fixed official GitHub repository. Installation time and lexical SHA diff --git a/apps/desktop/loopx-control-plane/docs/assets/startup-download-timeout.png b/apps/desktop/loopx-control-plane/docs/assets/startup-download-timeout.png new file mode 100644 index 0000000000..5c6b5f0ff3 Binary files /dev/null and b/apps/desktop/loopx-control-plane/docs/assets/startup-download-timeout.png differ diff --git a/apps/desktop/loopx-control-plane/docs/assets/update-failure-before.png b/apps/desktop/loopx-control-plane/docs/assets/update-failure-before.png new file mode 100644 index 0000000000..f9a640f2b7 Binary files /dev/null and b/apps/desktop/loopx-control-plane/docs/assets/update-failure-before.png differ diff --git a/apps/desktop/loopx-control-plane/docs/assets/update-signature-failure-desktop.png b/apps/desktop/loopx-control-plane/docs/assets/update-signature-failure-desktop.png new file mode 100644 index 0000000000..752dc119c4 Binary files /dev/null and b/apps/desktop/loopx-control-plane/docs/assets/update-signature-failure-desktop.png differ diff --git a/apps/desktop/loopx-control-plane/docs/assets/update-timeout-mobile.png b/apps/desktop/loopx-control-plane/docs/assets/update-timeout-mobile.png new file mode 100644 index 0000000000..b4487dcbd5 Binary files /dev/null and b/apps/desktop/loopx-control-plane/docs/assets/update-timeout-mobile.png differ diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs b/apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs index 51c1c9b777..d7fbbcd6a6 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs @@ -402,6 +402,21 @@ fn check_error(error: tauri_plugin_updater::Error) -> &'static str { _ => "update_check_failed", } } + +fn download_error(error: tauri_plugin_updater::Error) -> &'static str { + use tauri_plugin_updater::Error; + match error { + Error::Reqwest(error) if error.is_timeout() => "update_download_timeout", + Error::Reqwest(_) => "update_network_failed", + // The pinned updater uses Network for a non-success archive response. + // Do not expose its URL/status message or mislabel it as a bad signature. + Error::Network(_) => "update_download_failed", + Error::Minisign(_) | Error::Base64(_) | Error::SignatureUtf8(_) => { + "update_signature_invalid" + } + _ => "update_download_or_signature_failed", + } +} // Environment telemetry for the recovery diagnostics: coarse, non-PII facts // that separate "fresh Mac without a usable Python" from OS-specific defects. // No paths, environment variables or process output beyond the probed version. @@ -704,7 +719,7 @@ async fn download_update( update .download(on_chunk, || {}) .await - .map_err(|_| "update_download_or_signature_failed".into()) + .map_err(|error| download_error(error).into()) } async fn find_update( diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/maintenance_transfer_tests.rs b/apps/desktop/loopx-control-plane/src-tauri/src/maintenance_transfer_tests.rs index 8348224bbb..345e62125e 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/maintenance_transfer_tests.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/maintenance_transfer_tests.rs @@ -63,6 +63,10 @@ impl Server { let text = String::from_utf8_lossy(&request[..size]); let path = text.split_whitespace().nth(1).unwrap_or(""); let mode = path.trim_start_matches('/').trim_start_matches("feed/"); + if !path.starts_with("/feed/") && mode == "not-found" { + let _ = stream.write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + return; + } if (!path.starts_with("/feed/") && mode == "headers") || mode == "feed-stall" { pause(&done, Duration::from_secs(40)); return; @@ -147,7 +151,12 @@ fn native_update_transport_checks_signatures_truncation_and_cache_readback() { let app = app(KEY); let server = Server::new(); tauri::async_runtime::block_on(async { - for mode in ["fast", "tampered", "truncated"] { + for (mode, expected_error) in [ + ("fast", None), + ("tampered", Some("update_signature_invalid")), + ("truncated", Some("update_network_failed")), + ("not-found", Some("update_download_failed")), + ] { let update = find_update( app.handle(), "stable", @@ -158,8 +167,8 @@ fn native_update_transport_checks_signatures_truncation_and_cache_readback() { .unwrap() .unwrap(); let result = download_update(&update, |_, _| {}).await; - if mode != "fast" { - assert!(result.is_err(), "invalid archive was accepted: {mode}"); + if let Some(expected) = expected_error { + assert_eq!(result.unwrap_err(), expected, "archive mode: {mode}"); continue; } let bytes = result.unwrap(); @@ -181,6 +190,44 @@ fn native_update_transport_checks_signatures_truncation_and_cache_readback() { }); } +#[test] +fn background_download_failures_keep_their_cause_without_blocking_local_services() { + let app = app(KEY); + let server = Server::new(); + tauri::async_runtime::block_on(async { + for (mode, expected) in [ + ("tampered", "update_signature_invalid"), + ("truncated", "update_network_failed"), + ("not-found", "update_download_failed"), + ] { + let state = Maintenance::default(); + state.publish("ready", json!({})); + let generation = state.admit_background_update().unwrap(); + let result = state + .run_background_update( + generation, + "stable", + find_update( + app.handle(), + "stable", + server.feed(mode), + Duration::from_secs(2), + ), + |update| async move { + download_update(&update, |_, _| {}).await?; + panic!("a rejected archive cannot be published to the cache"); + }, + ) + .await; + assert_eq!(result.unwrap_err(), expected); + assert_eq!(state.status_snapshot()["details"]["code"], expected); + assert_eq!(state.status_snapshot()["details"]["background"], true); + assert_eq!(state.recover_connected(|| 1), Some(1)); + assert!(state.pending.lock().unwrap().is_none()); + } + }); +} + #[test] fn native_feed_keeps_its_short_deadline() { let app = app(KEY); @@ -292,7 +339,10 @@ fn stalled_update_transfer_remains_bounded() { .unwrap() .unwrap(); let start = Instant::now(); - assert!(tauri::async_runtime::block_on(download_update(&update, |_, _| {})).is_err()); + assert_eq!( + tauri::async_runtime::block_on(download_update(&update, |_, _| {})).unwrap_err(), + "update_download_timeout" + ); assert!( start.elapsed() < Duration::from_secs(35), "stalled {mode} transfer was not bounded" diff --git a/apps/desktop/loopx-control-plane/static/boot.js b/apps/desktop/loopx-control-plane/static/boot.js index 3c7dbca071..4abade4a6e 100644 --- a/apps/desktop/loopx-control-plane/static/boot.js +++ b/apps/desktop/loopx-control-plane/static/boot.js @@ -112,7 +112,10 @@ const errors = { update_feed_invalid: "更新源格式异常。请稍后重新检查。", update_platform_unavailable: "此通道尚无适用于本机的更新包。", update_check_timeout: "检查更新超时。请稍后重试。", - update_network_failed: "无法连接更新服务器。请检查网络后重试。", + update_network_failed: "更新网络请求失败,尚未安装。请检查连接后重试。", + update_download_timeout: "更新包下载超时,尚未安装。请检查网络后重试。", + update_download_failed: "更新服务器未能提供安装包,尚未安装。请重新检查更新。", + update_signature_invalid: "更新包未通过签名校验,尚未安装。请重新检查更新。", update_download_or_signature_failed: "更新包下载或签名校验失败,尚未安装。请重新检查更新。", app_install_failed: "App 安装未能完成,本次更新未生效;已确认当前版本完好且运行时可用,可直接重启继续使用,或重新检查更新后再试。", app_install_incomplete: "App 安装中断,且无法确认当前版本是否完整,请勿直接重启。请在恢复与更新面板还原上一版本(或重新安装)后再试。", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/desktop-update.tsx b/apps/presentation/dashboard/src/features/personal-workspace/desktop-update.tsx index ff022d5fc3..726a9bf076 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/desktop-update.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/desktop-update.tsx @@ -22,7 +22,10 @@ const updateErrors: Record = { update_feed_invalid: ["更新源格式异常。请稍后重新检查。", "The update feed is invalid. Check again later."], update_platform_unavailable: ["此通道尚无适用于本机的更新包。", "This channel has no update package for this platform."], update_check_timeout: ["检查更新超时。请稍后重试。", "The update check timed out. Try again later."], - update_network_failed: ["无法连接更新服务器。请检查网络后重试。", "Cannot reach the update server. Check your connection and retry."], + update_network_failed: ["更新网络请求失败,尚未安装。请检查连接后重试。", "The update request failed; nothing was installed. Check your connection and retry."], + update_download_timeout: ["更新包下载超时,尚未安装。请检查网络后重试。", "The update download timed out; nothing was installed. Check your connection and retry."], + update_download_failed: ["更新服务器未能提供安装包,尚未安装。请重新检查更新。", "The update server did not provide the package; nothing was installed. Check for updates again."], + update_signature_invalid: ["更新包未通过签名校验,尚未安装。请重新检查更新。", "The update package failed signature verification; nothing was installed. Check for updates again."], update_download_or_signature_failed: ["更新包下载或签名校验失败,尚未安装。请重新检查更新。", "Download or signature verification failed; the update was not installed. Check for updates again."], }; function failedUpdate(error: unknown, fallback = "update_failed"): UpdateState { diff --git a/examples/desktop-recovery-diagnostics-test.mjs b/examples/desktop-recovery-diagnostics-test.mjs index 48f097f417..19c5fb2bab 100644 --- a/examples/desktop-recovery-diagnostics-test.mjs +++ b/examples/desktop-recovery-diagnostics-test.mjs @@ -42,6 +42,23 @@ test('installer failure is actionable and clipboard denial leaves selectable tex await elements.get('#copy-diagnostics').onclick(); assert.equal(elements.get('#diagnostics').selected, true); }); +test('download and verification failures retain distinct bounded recovery causes', () => { + for (const [code, meaning] of [ + ['update_download_timeout', /下载超时/], + ['update_network_failed', /网络请求失败/], + ['update_download_failed', /服务器未能提供安装包/], + ['update_signature_invalid', /未通过签名校验/], + ]) { + const {context, elements} = page(); + context.packet = {app_version:'1.2.4', state:{phase:'error',details:{code,url:'PRIVATE',signature:'PRIVATE'}}}; + runInNewContext('render(packet.state); renderDiagnostics(packet)', context); + assert.match(elements.get('#update-status').textContent, meaning); + assert.match(elements.get('#update-status').textContent, /尚未安装/); + const value = JSON.parse(elements.get('#diagnostics').value); + assert.equal(value.error_code, code); + assert.ok(!JSON.stringify(value).includes('PRIVATE')); + } +}); test('legacy pairing state reaches diagnostics without private detail', () => { const {context, elements} = page(); context.packet = { diff --git a/examples/desktop-update-browser-smoke.mjs b/examples/desktop-update-browser-smoke.mjs index 4a8e576dfd..11651fc4ba 100644 --- a/examples/desktop-update-browser-smoke.mjs +++ b/examples/desktop-update-browser-smoke.mjs @@ -227,6 +227,51 @@ try { checkUpToDate = false; fastInstallFailure = false; + nativeState = { phase: "error", details: { code: "update_download_or_signature_failed" } }; + const beforeGeneralFailure = calls.length; + await page.reload(); + await page.getByRole("button", { name: "更新需重试", exact: true }).click(); + await page.getByRole("status").filter({ hasText: /更新包下载或签名校验失败,尚未安装/ }).waitFor(); + assert.equal(calls.length, beforeGeneralFailure, "unclassified native failures remain readable without effects"); + await page.screenshot({ path: resolve(output, "update-generic-failure.png") }); + + // A read of a specific native transfer failure never retries installation. + // Only the owner's check action can discover a fresh signed candidate. + for (const [locale, retryLabel, applyLabel, code, message] of [ + ["zh-CN", "更新需重试", "更新并准备重启", "update_download_timeout", /更新包下载超时,尚未安装/], + ["zh-CN", "更新需重试", "更新并准备重启", "update_signature_invalid", /更新包未通过签名校验,尚未安装/], + ["zh-CN", "更新需重试", "更新并准备重启", "update_download_failed", /更新服务器未能提供安装包,尚未安装/], + ["en", "Retry update", "Install update", "update_download_timeout", /update download timed out; nothing was installed/], + ["en", "Retry update", "Install update", "update_signature_invalid", /update package failed signature verification; nothing was installed/], + ["en", "Retry update", "Install update", "update_network_failed", /update request failed; nothing was installed/], + ]) { + nativeState = { phase: "error", details: { code } }; + await page.evaluate((nextLocale) => localStorage.setItem("loopx-pw-locale", nextLocale), locale); + const beforeFailureRead = calls.length; + await page.reload(); + await page.getByRole("button", { name: retryLabel, exact: true }).click(); + await page.getByRole("status").filter({ hasText: message }).waitFor({ timeout: 2000 }); + assert.equal(calls.length, beforeFailureRead, "reading a failure must not retry or install"); + assert.equal(await page.getByRole("button", { name: applyLabel, exact: true }).count(), 0); + if (code === "update_signature_invalid" && locale === "zh-CN") { + await page.screenshot({ path: resolve(output, "update-signature-failure.png") }); + } + const checkLabel = locale === "zh-CN" ? "检查更新" : "Check for updates"; + await page.getByRole("button", { name: checkLabel, exact: true }).click(); + await page.getByRole("button", { name: applyLabel, exact: true }).waitFor(); + assert.deepEqual(calls.slice(beforeFailureRead).map((call) => call.args.action), ["check"]); + } + await page.evaluate(() => localStorage.setItem("loopx-pw-locale", "zh-CN")); + nativeState = { phase: "error", details: { code: "update_download_timeout" } }; + await page.setViewportSize({ width: 390, height: 844 }); + await page.reload(); + await page.getByRole("button", { name: "打开 Goal 导航" }).click(); + await page.getByRole("button", { name: "更新需重试", exact: true }).click(); + await page.getByRole("status").filter({ hasText: /更新包下载超时,尚未安装/ }).waitFor(); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)); + await page.screenshot({ path: resolve(output, "update-download-timeout-mobile.png") }); + await page.setViewportSize({ width: 1280, height: 900 }); + // Restore failures retain the native cause instead of suggesting a Repair // action that this independently managed runtime cannot perform. nativeRuntimeSelection = { explicit: true, bundled_repair_available: false }; @@ -316,7 +361,7 @@ try { nativeState = { phase: "idle", details: {} }; await page.reload(); await page.getByRole("button", { name: "更新 LoopX", exact: true }).click(); - await page.getByText(/App 启动时会检查并安装当前通道/).waitFor(); + await page.getByText(/先打开本地版本,更新在后台准备,下次打开时生效/).waitFor(); await page.getByText("高级选项", { exact: true }).click(); assert.equal(await page.getByRole("button", { name: "修复当前版本", exact: true }).isEnabled(), true, "legacy native status retains its available repair action"); @@ -439,7 +484,7 @@ try { // its error projection (fresh Mac without Python 3.11+: installer exit 2), // not the pre-fix permanent loading shape. The projection is derived by the // page from desktop_update_status itself. - environmentTelemetry = { os_version: "26.5", arch: "aarch64", runtime_executable_found: false, python3_found: false, python3_version: null }; + environmentTelemetry = { os_version: "26.5", arch: "aarch64", runtime_executable_found: false, python3_found: false, python3_version: null, node_found: null, node_version: null }; nativeState = { phase: "error", details: { code: "runtime_install_exit_2" } }; await page.reload(); await page.waitForFunction(() => document.querySelector("main").dataset.state === "error"); @@ -466,6 +511,20 @@ try { nativeState = { phase: "runtime_required", details: { code: "runtime_setup_required" } }; await page.waitForFunction(() => document.querySelector("main").dataset.state === "error" && document.querySelector("#status").innerText.includes("请修复当前版本,成功后重启")); environmentTelemetry = null; + const beforeBootFailures = calls.length; + for (const [code, message] of [ + ["update_download_timeout", /更新包下载超时,尚未安装/], + ["update_signature_invalid", /更新包未通过签名校验,尚未安装/], + ]) { + nativeState = { phase: "error", details: { code } }; + await page.locator("#update-status").filter({ hasText: message }).waitFor(); + assert.match(await page.locator("#status").innerText(), message); + assert.equal(JSON.parse(await page.locator("#diagnostics").inputValue()).error_code, code); + assert.equal(await page.locator("#update").innerText(), "检查更新 / Check for updates"); + assert.equal(await page.locator("#update").isEnabled(), true); + assert.equal(calls.length, beforeBootFailures, "boot failure readback cannot install or retry"); + if (code === "update_download_timeout") await page.screenshot({ path: resolve(output, "startup-download-timeout.png") }); + } // Production boot surface must expose native installation and service // stages even while recovery is collapsed; reload keeps native elapsed time. nativeState = { phase: "installing_runtime", details: {} };