Skip to content
Open
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
42 changes: 34 additions & 8 deletions apps/desktop/loopx-control-plane/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,41 @@ failed journal retirement or unknown installation failure still requires recover
## Updates And Recovery

From a terminal, `loopx upgrade` coordinates the owning CLI installer and this
App's signed updater, restarts the App and waits for native service readiness.
App's signed updater. On writable macOS installations that advertise native
preparation support, the CLI first runs the installed App's windowless updater.
The existing workspace stays open while the native owner checks, downloads and
verifies the signed archive. A matching verified background cache is reused.
Preparation failure leaves the current window running. Only after preparation
succeeds does the CLI quit/reopen the App and wait for qualified services.

The handoff pins that prepared version in a request-scoped disposable cache.
Restart verifies its bytes again and binds the version to the sealed App bundle;
it never downloads or chases a moving channel feed. Missing, mismatched or corrupt
bytes fail explicitly. The replacement process acknowledges the frozen target,
then reports both service identities. A failed check cannot become success just
because the workspace is healthy. Older Apps and administrator-protected install
locations retain the existing interactive native update/authorization path until
an App with preparation support is installed or the installation is writable.

Archive users can select Main with `loopx upgrade --ref main`; `--dry-run` is
read-only. The terminal passes only a bounded request id and an official channel
to the App. A private request receipt contains versions, source revisions,
update phase and the qualified service runtime identity; it contains no Goal
content or credentials. An update check failure remains a failed terminal
upgrade even when the workspace can still open. Normal launches without that
request perform their network check after local services are ready; an explicit
terminal upgrade retains its thirty-second interactive check budget.
read-only. The terminal passes a bounded request id, official channel and prepared
version, never an arbitrary download URL or installation path. Receipts contain
versions, source revisions, phase and qualified runtime identity, without Goal
content or credentials. Normal launch/background behavior is unchanged.

macOS 可写安装目录下,`loopx upgrade` 先通过已安装 App 的无窗口原生更新器检查、
下载并验签,保持原工作区可用;已有匹配的验签缓存会复用。准备成功后才退出、重开
App,并核验服务身份。准备失败不关闭窗口。切换阶段固定本次版本、重新验签并核对
密封包版本,不在启动时追逐更新源;缓存丢失、损坏或版本不符明确失败。旧 App 及
需要管理员授权的目录保留原交互式升级路径。普通启动与后台更新行为不变。

This separates usable-workspace time from transfer time, following the same
preparation-before-restart principle visible in
[VS Code's Downloaded/Ready and quit-and-install transitions](https://github.com/microsoft/vscode/blob/4ebcba98631cbacfbf469338653b5d98f9c392b1/src/vs/platform/update/electron-main/updateService.darwin.ts#L177)
and [Zed's asynchronous download/install and restart-path preparation](https://github.com/zed-industries/zed/blob/48741fd17d0dec28be6ad664b0a06dc7202086be/crates/auto_update/src/auto_update.rs#L785).
Their packaging differs from LoopX's independently owned CLI runtime; this does
not combine package owners, remove integrity checks, or claim faster networks.

The existing update panel shows the same native maintenance phases and recovery
actions. Main is the latest complete signed build, which can lag Git HEAD.

Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/loopx-control-plane/src-tauri/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>LoopXUpgradePreparation</key><integer>1</integer>
</dict></plist>
9 changes: 8 additions & 1 deletion apps/desktop/loopx-control-plane/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,13 @@ fn workspace_navigation(
pub fn run() {
#[cfg(target_os = "macos")]
service_lifetime::run_if_requested();
let context = tauri::generate_context!();

#[cfg(target_os = "macos")]
if std::env::args().any(|arg| arg == "--loopx-upgrade-prepare") {
maintenance::prepare_terminal_upgrade(context);
return;
}

// Release builds load the versioned LoopX Chat workspace that ships inside
// the installed `loopx` release, so `loopx update` refreshes the frontend
Expand Down Expand Up @@ -365,7 +372,7 @@ pub fn run() {
let builder = builder.on_web_content_process_terminate(workspace_handoff::content_lost);

let app = builder
.build(tauri::generate_context!())
.build(context)
.expect("failed to build LoopX desktop shell");
app.run(move |app, event| {
if matches!(event, RunEvent::Exit | RunEvent::ExitRequested { .. }) {
Expand Down
111 changes: 111 additions & 0 deletions apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1442,6 +1442,38 @@ fn automatic_app_update(app: &AppHandle) {
if state.automatic_update_checked.swap(true, Ordering::AcqRel) {
return;
}
#[cfg(target_os = "macos")]
if let Some(request) = request.filter(|request| request.target.is_some()) {
let result = tauri::async_runtime::block_on(state.run_automatic_update(
channel,
async {
// Both no-update and replacement processes use this frozen
// candidate. A cache miss never falls back to network at boot.
let prepared = request.prepared_target(
&app.package_info().version.to_string(),
updater_public_key(app)?,
)?;
if prepared.is_none() {
state.publish("up_to_date", json!({"channel":channel}));
}
Ok(prepared)
},
|prepared| async move {
let target = prepared.version;
let version = target.clone();
install_verified_app(app, target, move || {
crate::macos_update::install_prepared(&version, &prepared.bytes)
})
.await?;
Ok(())
},
|| app.restart(),
));
if let Err(error) = result {
eprintln!("LoopX prepared terminal upgrade failed: {error}");
}
return;
}
// A CLI-requested upgrade is an explicit operation, not an ordinary open.
// Preserve its requested channel and exact service-ready return contract.
let result: Result<Option<()>, String> =
Expand Down Expand Up @@ -1473,6 +1505,85 @@ fn automatic_app_update(app: &AppHandle) {
// of the installed App. Diagnostics retain the failed check.
}

#[cfg(target_os = "macos")]
async fn prepare_terminal_candidate(
update: Option<Update>,
current: &str,
channel: &str,
key: &str,
automatic_root: &std::path::Path,
request_root: &std::path::Path,
) -> Result<String, String> {
let Some(update) = update else {
return Ok(current.into());
};
let cached = crate::prepared_update::load(automatic_root, current, channel, key)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Main 准备失败会删除原窗口的 Stable 更新缓存。 Stable App 已有经签名验证的更新缓存。运行 loopx upgrade --ref main,新 Main feed 返回更高版本,但下载的包未通过签名校验。 原窗口尚未退出,准备 helper 已删除原来的 Stable 缓存。独立对照中同样的 Main 包验签失败,Stable 缓存可继续读回。后续离线普通启动失去原本可用的已准备更新。 让准备 helper 用只读方式尝试复用匹配的缓存。保留同一个签名、版本和渠道校验 owner,把退役留给原来的消费路径。 预置一个已验签的 Stable 缓存,通过本地 Main feed 下载篡改包。断言签名失败、没有退出或交接,并用 Stable 渠道再次成功读回同一份已验签字节。

.ok()
.flatten()
.filter(|cached| cached.version == update.version && cached.signature == update.signature);
let bytes = if let Some(cached) = cached {
cached.bytes
} else {
download_update(&update, |_, _| {}).await?
};
crate::prepared_update::save(
request_root,
channel,
&update.version,
&update.signature,
&bytes,
)?;
Ok(update.version)
}

// The installed App remains the signature/channel owner. This bounded helper
// has no window, single-instance callback or service lifecycle: the CLI waits
// for preparation before asking the existing window to quit.
#[cfg(target_os = "macos")]
pub fn prepare_terminal_upgrade(context: tauri::Context<tauri::Wry>) {
tauri::Builder::default()
.plugin(tauri_plugin_updater::Builder::new().build())
.setup(|app| {
app.set_activation_policy(tauri::ActivationPolicy::Prohibited);
let outcome = tauri::async_runtime::block_on(async {
let handle = app.handle();
let request = crate::upgrade_request::UpgradeRequest::from_args(
&std::env::args().collect::<Vec<_>>(),
handle
.path()
.app_local_data_dir()
.map_err(|_| "update_unavailable")?,
handle.package_info().version.to_string(),
Value::Null,
)
.ok_or("invalid_update_action")?;
let channel = &request.channel;
let update =
find_update(handle, channel, endpoint(channel)?, Duration::from_secs(30))
.await?;
let target = prepare_terminal_candidate(
update,
&handle.package_info().version.to_string(),
channel,
updater_public_key(handle)?,
&prepared_directory(handle)?,
&request.preparation_directory(),
)
.await?;
Ok::<_, String>(json!({"ok":true,"channel":channel,"target":target}))
});
let code = if outcome.is_ok() { 0 } else { 1 };
println!(
"{}",
outcome.unwrap_or_else(|code| json!({"ok":false,"code":code}))
);
app.handle().exit(code);
Ok(())
})
.run(context)
.expect("native upgrade preparation");
}

pub fn start_background_update(app: &AppHandle) {
let state = app.state::<Maintenance>();
if cfg!(dev)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -479,3 +479,135 @@ fn released_archive_survives_slow_native_transport_and_cache_reopen() {
);
eprintln!("released signed archive: {} bytes, {:.3}s including verified cache reopen; no installation", bytes.len(), start.elapsed().as_secs_f64());
}

#[cfg(target_os = "macos")]
#[test]
fn terminal_preparation_reuses_verified_bytes_and_freezes_an_offline_handoff() {
let app = app(KEY);
let server = Server::new();
let root = tempfile::tempdir().unwrap();
let automatic = root.path().join("automatic");
let request = crate::upgrade_request::UpgradeRequest::from_args(
&[
format!("--loopx-upgrade-request={}", "a".repeat(32)),
"--loopx-upgrade-channel=stable".into(),
"--loopx-upgrade-target=1.1.0".into(),
],
root.path().into(),
"0.1.0".into(),
Value::Null,
)
.unwrap();
tauri::async_runtime::block_on(async {
let update = find_update(
app.handle(),
"stable",
server.feed("fast"),
Duration::from_secs(3),
)
.await
.unwrap();
assert_eq!(
prepare_terminal_candidate(
update.clone(),
"0.1.0",
"stable",
KEY,
&automatic,
&request.preparation_directory()
)
.await
.unwrap(),
"1.1.0"
);
assert_eq!(server.requests.load(Ordering::Acquire), 2);
assert_eq!(
request
.prepared_target("0.1.0", KEY)
.unwrap()
.unwrap()
.bytes,
BODY
);
crate::prepared_update::save(&automatic, "stable", "1.1.0", SIGNATURE, BODY).unwrap();
prepare_terminal_candidate(
update,
"0.1.0",
"stable",
KEY,
&automatic,
&request.preparation_directory(),
)
.await
.unwrap();
assert_eq!(
server.requests.load(Ordering::Acquire),
2,
"verified automatic cache avoids a second archive transfer"
);
});
drop(server); // No network is available at the replacement boundary.
assert_eq!(
request
.prepared_target("0.1.0", KEY)
.unwrap()
.unwrap()
.bytes,
BODY
);
// Another window's background cache cannot replace this request's bytes.
crate::prepared_update::clear(&automatic).unwrap();
assert!(request.prepared_target("0.1.0", "invalid").is_err());
crate::prepared_update::save(
&request.preparation_directory(),
"stable",
"1.2.0",
SIGNATURE,
BODY,
)
.unwrap();
assert!(request.prepared_target("0.1.0", KEY).is_err());
crate::prepared_update::clear(&request.preparation_directory()).unwrap();
assert!(request.prepared_target("0.1.0", KEY).is_err());
assert!(request.prepared_target("1.1.0", KEY).unwrap().is_none());
}

#[cfg(target_os = "macos")]
#[test]
fn terminal_preparation_rejects_bad_download_before_any_handoff() {
let app = app(KEY);
let server = Server::new();
let root = tempfile::tempdir().unwrap();
tauri::async_runtime::block_on(async {
let update = find_update(
app.handle(),
"stable",
server.feed("tampered"),
Duration::from_secs(3),
)
.await
.unwrap();
let request = root.path().join("request");
assert_eq!(
prepare_terminal_candidate(
update,
"0.1.0",
"stable",
KEY,
&root.path().join("automatic"),
&request
)
.await
.unwrap_err(),
"update_signature_invalid"
);
assert!(!request.exists());
assert_eq!(
prepare_terminal_candidate(None, "1.1.0", "stable", KEY, root.path(), &request)
.await
.unwrap(),
"1.1.0"
);
assert!(!request.exists());
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ use std::{

pub(crate) struct UpgradeRequest {
pub channel: String,
#[cfg(target_os = "macos")]
pub target: Option<String>,
id: String,
path: PathBuf,
version: String,
Expand Down Expand Up @@ -39,8 +41,16 @@ impl UpgradeRequest {
if !matches!(channel, "main" | "stable") {
return None;
}
let target = args
.iter()
.find_map(|s| s.strip_prefix("--loopx-upgrade-target="));
if target.is_some_and(|value| value.len() > 128 || semver::Version::parse(value).is_err()) {
return None;
}
Some(Self {
channel: channel.to_string(),
#[cfg(target_os = "macos")]
target: target.map(str::to_owned),
id: id.to_string(),
path: directory.join("upgrades").join(format!("{id}.json")),
version,
Expand All @@ -51,6 +61,31 @@ impl UpgradeRequest {
})
}

#[cfg(target_os = "macos")]
pub fn preparation_directory(&self) -> PathBuf {
self.path.with_extension("prepared")
}

#[cfg(target_os = "macos")]
pub fn prepared_target(
&self,
current: &str,
key: &str,
) -> Result<Option<crate::prepared_update::PreparedUpdate>, String> {
let target = self
.target
.as_deref()
.ok_or("update_download_or_signature_failed")?;
if target == current {
let _ = crate::prepared_update::clear(&self.preparation_directory());
return Ok(None);
}
crate::prepared_update::load(&self.preparation_directory(), current, &self.channel, key)?
.filter(|prepared| prepared.version == target)
.map(Some)
.ok_or_else(|| "update_download_or_signature_failed".into())
}

pub fn services_ready(&self, identity: Option<&Value>) {
*self.runtime.lock().unwrap() = identity.cloned();
self.publish("ready", &json!({}));
Expand Down
Loading
Loading