Skip to content

Commit 5706ef0

Browse files
authored
fix(desktop): distinguish update transport and signature failures (#6985)
Preserve safe native download causes through existing recovery surfaces. Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
1 parent 282f599 commit 5706ef0

11 files changed

Lines changed: 165 additions & 9 deletions

File tree

‎apps/desktop/loopx-control-plane/README.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@ continue beyond thirty seconds; stalled headers or body reads remain bounded.
6060
The archive client limits apply only after feed discovery; the feed keeps its
6161
existing check deadline (45 seconds manually, 30 seconds in the background).
6262
Signature verification still precedes cache publication or installation.
63+
The existing recovery and workspace update panels distinguish a download timeout,
64+
an interrupted network request, an unavailable package and a rejected signature.
65+
They keep the coarse cause and retry action without exposing transport URLs or
66+
signature diagnostics. Unclassified failures retain the general download/verification
67+
message; a failure never certifies that an update was installed.
6368
Progress and failures use the
6469
existing update panel; background IO does not hold the service supervisor or
6570
restart a running workspace.
@@ -70,6 +75,10 @@ restart a running workspace.
7075
才写入准备缓存或安装。现有更新面板显示进度与失败,后台下载不阻塞服务监督或重启
7176
正在使用的工作区。
7277

78+
启动恢复页与工作区更新面板分别提示下载超时、网络请求失败、安装包不可用和签名
79+
校验失败,只展示可操作的原因与重试入口,不暴露传输地址或签名内部错误。
80+
无法分类的失败仍保留通用提示,失败不会被显示为已安装。
81+
7382
Startup automatically uses the newest qualified local runtime it can establish.
7483
It compares package versions; equal versions with source revisions use ancestry
7584
from the fixed official GitHub repository. Installation time and lexical SHA
78.5 KB
Loading
99.7 KB
Loading
99 KB
Loading
45.9 KB
Loading

‎apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,21 @@ fn check_error(error: tauri_plugin_updater::Error) -> &'static str {
402402
_ => "update_check_failed",
403403
}
404404
}
405+
406+
fn download_error(error: tauri_plugin_updater::Error) -> &'static str {
407+
use tauri_plugin_updater::Error;
408+
match error {
409+
Error::Reqwest(error) if error.is_timeout() => "update_download_timeout",
410+
Error::Reqwest(_) => "update_network_failed",
411+
// The pinned updater uses Network for a non-success archive response.
412+
// Do not expose its URL/status message or mislabel it as a bad signature.
413+
Error::Network(_) => "update_download_failed",
414+
Error::Minisign(_) | Error::Base64(_) | Error::SignatureUtf8(_) => {
415+
"update_signature_invalid"
416+
}
417+
_ => "update_download_or_signature_failed",
418+
}
419+
}
405420
// Environment telemetry for the recovery diagnostics: coarse, non-PII facts
406421
// that separate "fresh Mac without a usable Python" from OS-specific defects.
407422
// No paths, environment variables or process output beyond the probed version.
@@ -704,7 +719,7 @@ async fn download_update(
704719
update
705720
.download(on_chunk, || {})
706721
.await
707-
.map_err(|_| "update_download_or_signature_failed".into())
722+
.map_err(|error| download_error(error).into())
708723
}
709724

710725
async fn find_update<R: tauri::Runtime>(

‎apps/desktop/loopx-control-plane/src-tauri/src/maintenance_transfer_tests.rs‎

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ impl Server {
6363
let text = String::from_utf8_lossy(&request[..size]);
6464
let path = text.split_whitespace().nth(1).unwrap_or("");
6565
let mode = path.trim_start_matches('/').trim_start_matches("feed/");
66+
if !path.starts_with("/feed/") && mode == "not-found" {
67+
let _ = stream.write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
68+
return;
69+
}
6670
if (!path.starts_with("/feed/") && mode == "headers") || mode == "feed-stall" {
6771
pause(&done, Duration::from_secs(40));
6872
return;
@@ -147,7 +151,12 @@ fn native_update_transport_checks_signatures_truncation_and_cache_readback() {
147151
let app = app(KEY);
148152
let server = Server::new();
149153
tauri::async_runtime::block_on(async {
150-
for mode in ["fast", "tampered", "truncated"] {
154+
for (mode, expected_error) in [
155+
("fast", None),
156+
("tampered", Some("update_signature_invalid")),
157+
("truncated", Some("update_network_failed")),
158+
("not-found", Some("update_download_failed")),
159+
] {
151160
let update = find_update(
152161
app.handle(),
153162
"stable",
@@ -158,8 +167,8 @@ fn native_update_transport_checks_signatures_truncation_and_cache_readback() {
158167
.unwrap()
159168
.unwrap();
160169
let result = download_update(&update, |_, _| {}).await;
161-
if mode != "fast" {
162-
assert!(result.is_err(), "invalid archive was accepted: {mode}");
170+
if let Some(expected) = expected_error {
171+
assert_eq!(result.unwrap_err(), expected, "archive mode: {mode}");
163172
continue;
164173
}
165174
let bytes = result.unwrap();
@@ -181,6 +190,44 @@ fn native_update_transport_checks_signatures_truncation_and_cache_readback() {
181190
});
182191
}
183192

193+
#[test]
194+
fn background_download_failures_keep_their_cause_without_blocking_local_services() {
195+
let app = app(KEY);
196+
let server = Server::new();
197+
tauri::async_runtime::block_on(async {
198+
for (mode, expected) in [
199+
("tampered", "update_signature_invalid"),
200+
("truncated", "update_network_failed"),
201+
("not-found", "update_download_failed"),
202+
] {
203+
let state = Maintenance::default();
204+
state.publish("ready", json!({}));
205+
let generation = state.admit_background_update().unwrap();
206+
let result = state
207+
.run_background_update(
208+
generation,
209+
"stable",
210+
find_update(
211+
app.handle(),
212+
"stable",
213+
server.feed(mode),
214+
Duration::from_secs(2),
215+
),
216+
|update| async move {
217+
download_update(&update, |_, _| {}).await?;
218+
panic!("a rejected archive cannot be published to the cache");
219+
},
220+
)
221+
.await;
222+
assert_eq!(result.unwrap_err(), expected);
223+
assert_eq!(state.status_snapshot()["details"]["code"], expected);
224+
assert_eq!(state.status_snapshot()["details"]["background"], true);
225+
assert_eq!(state.recover_connected(|| 1), Some(1));
226+
assert!(state.pending.lock().unwrap().is_none());
227+
}
228+
});
229+
}
230+
184231
#[test]
185232
fn native_feed_keeps_its_short_deadline() {
186233
let app = app(KEY);
@@ -292,7 +339,10 @@ fn stalled_update_transfer_remains_bounded() {
292339
.unwrap()
293340
.unwrap();
294341
let start = Instant::now();
295-
assert!(tauri::async_runtime::block_on(download_update(&update, |_, _| {})).is_err());
342+
assert_eq!(
343+
tauri::async_runtime::block_on(download_update(&update, |_, _| {})).unwrap_err(),
344+
"update_download_timeout"
345+
);
296346
assert!(
297347
start.elapsed() < Duration::from_secs(35),
298348
"stalled {mode} transfer was not bounded"

‎apps/desktop/loopx-control-plane/static/boot.js‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,10 @@ const errors = {
112112
update_feed_invalid: "更新源格式异常。请稍后重新检查。",
113113
update_platform_unavailable: "此通道尚无适用于本机的更新包。",
114114
update_check_timeout: "检查更新超时。请稍后重试。",
115-
update_network_failed: "无法连接更新服务器。请检查网络后重试。",
115+
update_network_failed: "更新网络请求失败,尚未安装。请检查连接后重试。",
116+
update_download_timeout: "更新包下载超时,尚未安装。请检查网络后重试。",
117+
update_download_failed: "更新服务器未能提供安装包,尚未安装。请重新检查更新。",
118+
update_signature_invalid: "更新包未通过签名校验,尚未安装。请重新检查更新。",
116119
update_download_or_signature_failed: "更新包下载或签名校验失败,尚未安装。请重新检查更新。",
117120
app_install_failed: "App 安装未能完成,本次更新未生效;已确认当前版本完好且运行时可用,可直接重启继续使用,或重新检查更新后再试。",
118121
app_install_incomplete: "App 安装中断,且无法确认当前版本是否完整,请勿直接重启。请在恢复与更新面板还原上一版本(或重新安装)后再试。",

‎apps/presentation/dashboard/src/features/personal-workspace/desktop-update.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@ const updateErrors: Record<string, [string, string]> = {
2222
update_feed_invalid: ["更新源格式异常。请稍后重新检查。", "The update feed is invalid. Check again later."],
2323
update_platform_unavailable: ["此通道尚无适用于本机的更新包。", "This channel has no update package for this platform."],
2424
update_check_timeout: ["检查更新超时。请稍后重试。", "The update check timed out. Try again later."],
25-
update_network_failed: ["无法连接更新服务器。请检查网络后重试。", "Cannot reach the update server. Check your connection and retry."],
25+
update_network_failed: ["更新网络请求失败,尚未安装。请检查连接后重试。", "The update request failed; nothing was installed. Check your connection and retry."],
26+
update_download_timeout: ["更新包下载超时,尚未安装。请检查网络后重试。", "The update download timed out; nothing was installed. Check your connection and retry."],
27+
update_download_failed: ["更新服务器未能提供安装包,尚未安装。请重新检查更新。", "The update server did not provide the package; nothing was installed. Check for updates again."],
28+
update_signature_invalid: ["更新包未通过签名校验,尚未安装。请重新检查更新。", "The update package failed signature verification; nothing was installed. Check for updates again."],
2629
update_download_or_signature_failed: ["更新包下载或签名校验失败,尚未安装。请重新检查更新。", "Download or signature verification failed; the update was not installed. Check for updates again."],
2730
};
2831
function failedUpdate(error: unknown, fallback = "update_failed"): UpdateState {

‎examples/desktop-recovery-diagnostics-test.mjs‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,23 @@ test('installer failure is actionable and clipboard denial leaves selectable tex
4242
await elements.get('#copy-diagnostics').onclick();
4343
assert.equal(elements.get('#diagnostics').selected, true);
4444
});
45+
test('download and verification failures retain distinct bounded recovery causes', () => {
46+
for (const [code, meaning] of [
47+
['update_download_timeout', /下载超时/],
48+
['update_network_failed', /网络请求失败/],
49+
['update_download_failed', /服务器未能提供安装包/],
50+
['update_signature_invalid', /未通过签名校验/],
51+
]) {
52+
const {context, elements} = page();
53+
context.packet = {app_version:'1.2.4', state:{phase:'error',details:{code,url:'PRIVATE',signature:'PRIVATE'}}};
54+
runInNewContext('render(packet.state); renderDiagnostics(packet)', context);
55+
assert.match(elements.get('#update-status').textContent, meaning);
56+
assert.match(elements.get('#update-status').textContent, /尚未安装/);
57+
const value = JSON.parse(elements.get('#diagnostics').value);
58+
assert.equal(value.error_code, code);
59+
assert.ok(!JSON.stringify(value).includes('PRIVATE'));
60+
}
61+
});
4562
test('legacy pairing state reaches diagnostics without private detail', () => {
4663
const {context, elements} = page();
4764
context.packet = {

0 commit comments

Comments
 (0)