Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/desktop/loopx-control-plane/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 16 additions & 1 deletion apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<R: tauri::Runtime>(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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",
Expand All @@ -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();
Expand All @@ -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);
Expand Down Expand Up @@ -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"
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/loopx-control-plane/static/boot.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 安装中断,且无法确认当前版本是否完整,请勿直接重启。请在恢复与更新面板还原上一版本(或重新安装)后再试。",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ const updateErrors: Record<string, [string, string]> = {
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 {
Expand Down
17 changes: 17 additions & 0 deletions examples/desktop-recovery-diagnostics-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
63 changes: 61 additions & 2 deletions examples/desktop-update-browser-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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");

Expand Down Expand Up @@ -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");
Expand All @@ -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: {} };
Expand Down
Loading