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
2 changes: 1 addition & 1 deletion .github/workflows/python-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -507,7 +507,7 @@ jobs:
group: loopx-python-test-shard-${{ matrix.shard }}
queue: max
cancel-in-progress: false
# All four shards were still progressing at 83-94% when the 45m job
# All shards were still progressing at 83-94% when the 45m job
# deadline cancelled run 37916979857. Reserve setup/reporting headroom
# without changing test-level deadlines, collection or worker count.
timeout-minutes: 60
Expand Down
44 changes: 44 additions & 0 deletions apps/desktop/loopx-control-plane/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ previous runtime. Core's installation-only doctor qualifies non-editable wheel
fingerprints: a local reuse fence, not publisher or source attestation. Older
CLIs without that readback can use the automatic App-owned fallback.

Wheel qualification has a separate ten-second subprocess deadline because it
starts Core, checks the TypeScript runtime and hashes package contents. The
one-second environment/version probes remain unchanged. A slow valid wheel can
therefore qualify without asking the user to repair it; invalid or timed-out
qualification still fails closed, and timed-out process groups are terminated
and reaped. This does not speed up doctor or certify every cold-start workload.

A launch-time `LOOPX_BIN` remains the developer's explicit pin. Development mode
also accepts a source runtime without promoting an installation. A previously
saved path is a discovery candidate, not a permanent version pin. Corrupt
Expand Down Expand Up @@ -305,6 +312,43 @@ cd ..
npm run build
```

On macOS, also qualify a released artifact using the opt-in checks below
(run from `src-tauri`, replacing the paths with downloaded release artifacts).
Keep the updater archive's `.sig` file beside it:

```bash
LOOPX_TEST_APP="/path/to/LoopX.app" cargo test --lib \
real_release_backup_restores_damage_and_rejects_tampering -- --ignored
LOOPX_TEST_SIGNED_ARCHIVE="/path/to/LoopX.app.tar.gz" cargo test \
--test signed_bundle -- --ignored
```

The first check copies the signed App into temporary directories, then exercises
the production restore path after removing its executable, a sealed runtime
resource, or the entire target bundle. It also rejects a tampered backup before
journaling or replacement and checks that the source artifact remains intact.
The second verifies the updater archive with the shipped public key and rejects
modified bytes. Neither check launches the App or writes to an installed App or
user data. These qualify the native recovery and signature boundaries; they do
not replace packaged UI checks of download failure, restart, or runtime handoff.

### Recover An Interrupted App Update

If replacement leaves the App incomplete and a backup is available, the update
panel exposes **Restore previous version** directly. Restore it, then choose
**Restart to finish**. This removes two navigation disclosures from the failure
path; choosing a healthy installation's rollback remains under Advanced options.
The native updater still verifies the backup before replacement. An unavailable
or invalid backup reports its cause without recommending an unavailable runtime
repair. Restoring the App does not overwrite a separately managed CLI.

| Previous recovery panel | Direct recovery action |
| --- | --- |
| ![Recovery hidden in Advanced options](docs/recovery-before.png) | ![Restore action beside the incomplete-installation message](docs/recovery-after.png) |

These packaged-panel captures use synthetic native status for presentation
comparison; they do not certify an installed update or an executing task's recovery.

`npm run build` produces the configured platform bundles under
`src-tauri/target/release/bundle/`. The release workflow builds macOS `.dmg`
and `.app.zip` artifacts on macOS, Windows `.msi` and `.exe` artifacts on
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.
Original file line number Diff line number Diff line change
Expand Up @@ -815,6 +815,11 @@ pub(crate) fn python3_environment() -> (bool, Option<String>) {
}

const VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(1);
// Installation qualification boots Core, checks the TypeScript runtime and
// hashes the installed package. It is not a version-banner query: a valid cold
// wheel can exceed one second. Keep that work bounded without weakening Core's
// qualification or extending the lightweight environment probes.
const INSTALLATION_PROBE_TIMEOUT: Duration = Duration::from_secs(10);

pub(crate) fn timed_output(command: Command) -> Option<std::process::Output> {
timed_output_with_timeout(command, VERSION_PROBE_TIMEOUT)
Expand Down Expand Up @@ -986,7 +991,7 @@ fn runtime_identity_for_executable_with_path(
let mut command = Command::new(canonical);
configure_runtime_environment(&mut command);
command.args(["--format", "json", "doctor", "--installation-only"]);
let output = timed_output(command)?;
let output = timed_output_with_timeout(command, INSTALLATION_PROBE_TIMEOUT)?;
if !output.status.success() {
return None;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,69 @@
use super::*;

#[cfg(unix)]
fn installation_probe_fixture(body: &str) -> (tempfile::TempDir, PathBuf) {
use std::os::unix::fs::PermissionsExt;
let root = tempfile::tempdir().expect("fixture directory");
let bin = root.path().join("bin");
fs::create_dir(&bin).expect("fixture bin");
let executable = bin.join("loopx");
fs::write(
&executable,
format!("#!/bin/sh\n[ \"$*\" = \"--format json doctor --installation-only\" ] || exit 2\n{body}\n"),
)
.expect("fixture executable");
fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap();
(root, executable)
}

#[test]
#[cfg(unix)]
fn installation_probe_allows_cold_qualification_beyond_version_budget() {
let expected = serde_json::json!({
"schema_version": "loopx_runtime_identity_v1",
"package_version": "1.4.1", "release_id": null, "source_revision": null,
"package_fingerprint": format!("sha256:{}", "a".repeat(64)),
});
let payload = serde_json::json!({
"ok": true, "scope": "installation_only", "service_runtime_identity": expected,
});
let (_root, executable) =
installation_probe_fixture(&format!("sleep 1.2\nprintf '%s\\n' '{payload}'"));
assert_eq!(
runtime_identity_for_executable(executable.to_str().unwrap()),
Some(expected),
"slow, fully qualified wheels must not be treated as missing runtimes"
);
let mut banner = Command::new("sh");
banner.args(["-c", "sleep 2; printf 'Python 3.12.0\\n'"]);
assert!(
timed_output(banner).is_none(),
"version probes keep their short bound"
);
}

#[test]
#[cfg(unix)]
fn installation_probe_still_bounds_and_reaps_hung_qualification() {
let (root, executable) = installation_probe_fixture("echo $$ > \"$0.pid\"\nexec sleep 30");
let started = Instant::now();
assert!(runtime_identity_for_executable(executable.to_str().unwrap()).is_none());
assert!(
started.elapsed() < Duration::from_secs(13),
"qualification must remain bounded"
);
let pid = fs::read_to_string(root.path().join("bin/loopx.pid")).unwrap();
assert!(
!Command::new("kill")
.args(["-0", pid.trim()])
.stderr(Stdio::null())
.status()
.unwrap()
.success(),
"timed out probe must be reaped"
);
}

#[test]
fn status_readiness_is_explicit_and_fails_closed() {
assert_eq!(ServiceKind::Status.probe_path(), "/?readiness=1");
Expand Down
68 changes: 62 additions & 6 deletions apps/desktop/loopx-control-plane/src-tauri/src/update_backup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -623,15 +623,71 @@ mod tests {
}

#[test]
#[cfg(target_os = "macos")]
#[ignore = "requires LOOPX_TEST_APP from a signed macOS build"]
fn real_backup_copy_retains_signature() {
fn real_release_backup_restores_damage_and_rejects_tampering() {
use std::os::unix::fs::MetadataExt;

let source = std::env::var("LOOPX_TEST_APP").unwrap();
let source = Path::new(&source);
let executable_name = declared_bundle_executable(source).unwrap();
let resource = "Contents/Resources/runtime/identity.json";
let identity = fs::read(source.join(resource)).unwrap();
let dir = tempfile::tempdir().unwrap();
let copied = dir.path().join("LoopX.app");
copy(Path::new(&source), &copied).unwrap();
assert!(copied
.join("Contents/Resources/runtime/identity.json")
.is_file());
let temporary_root = dir.path().canonicalize().unwrap();
let assert_temporary = |path: &Path| {
assert!(path.canonicalize().unwrap().starts_with(&temporary_root));
};
let previous = dir.path().join("previous");
let backup = previous.join("LoopX.app");
let target = dir.path().join("LoopX.app");
let executable = target.join("Contents/MacOS").join(executable_name);
let journal = dir.path().join("pending-rollback");
copy(source, &backup).unwrap();
copy(source, &target).unwrap();
assert!(installed_bundle_verifies(&executable));

// Only disposable copies are damaged. Exercise the same locator,
// verified copy and atomic replacement used by the recovery button
// with a shipped bundle, including its actual sealed runtime resource.
for damaged in [&executable, &target.join(resource), &target] {
assert_temporary(damaged);
if damaged.is_dir() {
fs::remove_dir_all(damaged).unwrap();
} else {
fs::remove_file(damaged).unwrap();
}
assert!(!installed_bundle_verifies(&executable));
restore_verified_backup(&executable, &previous, || {
// The journal callback must precede replacement: the damaged
// target must still be observable here.
assert!(!installed_bundle_verifies(&executable));
fs::write(&journal, "rollback").map_err(|error| error.to_string())
})
.unwrap();
assert_eq!(fs::read_to_string(&journal).unwrap(), "rollback");
assert!(installed_bundle_verifies(&executable));
assert_eq!(fs::read(target.join(resource)).unwrap(), identity);
assert!(signature_verifies(&backup));
fs::remove_file(&journal).unwrap();
}

// An invalid release backup must fail before journaling or swapping,
// leaving the exact installed directory and its signature intact.
let installed_inode = fs::metadata(&target).unwrap().ino();
assert_temporary(&backup.join(resource));
fs::write(backup.join(resource), "tampered").unwrap();
assert_eq!(
restore_verified_backup(&executable, &previous, || {
panic!("a tampered backup must never reach the commit callback")
}),
Err("backup_failed".into())
);
assert_eq!(fs::metadata(&target).unwrap().ino(), installed_inode);
assert!(installed_bundle_verifies(&executable));
assert!(!journal.exists());
assert_eq!(fs::read(source.join(resource)).unwrap(), identity);
assert!(signature_verifies(source));
}

#[test]
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/loopx-control-plane/static/boot.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,9 @@ const errors = {
app_install_failed: "App 安装未能完成,本次更新未生效;已确认当前版本完好且运行时可用,可直接重启继续使用,或重新检查更新后再试。",
app_install_incomplete: "App 安装中断,且无法确认当前版本是否完整,请勿直接重启。请在恢复与更新面板还原上一版本(或重新安装)后再试。",
runtime_pairing_required: "旧版本未能自动选择可用组件。请检查 App 更新,或通过恢复入口重新连接。",
backup_failed: "无法备份当前版本,更新已停止。请检查磁盘空间后重试。",
backup_unavailable: "没有可验证的 App 备份,未执行恢复。请重新安装完整 App;当前窗口仍可保留。",
backup_failed: "App 备份无法保存或未通过完整性校验,替换已停止。请检查磁盘空间与权限,或重新安装完整 App。",
rollback_failed: "恢复 App 未完成,请保持窗口打开。可重试恢复;若仍失败,请重新安装完整 App。",
};
function codeText(code, phase) {
if (runtimeExplicit && ["runtime_identity_unavailable", "runtime_selection_explicit"].includes(code)) {
Expand Down
Loading