From e43c7605446284d31f09034989f4a986319cc99d Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:35:08 +0800 Subject: [PATCH 1/2] fix(desktop): restore local workspace after normal reopen Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- apps/desktop/loopx-control-plane/README.md | 28 ++ .../loopx-control-plane/src-tauri/src/lib.rs | 18 +- .../src-tauri/src/workspace_handoff.rs | 38 ++- .../src-tauri/src/workspace_location.rs | 310 ++++++++++++++++++ 4 files changed, 389 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/loopx-control-plane/src-tauri/src/workspace_location.rs diff --git a/apps/desktop/loopx-control-plane/README.md b/apps/desktop/loopx-control-plane/README.md index be5bc299ab..cb34a8b457 100644 --- a/apps/desktop/loopx-control-plane/README.md +++ b/apps/desktop/loopx-control-plane/README.md @@ -153,6 +153,7 @@ cargo run --example webcontent_recovery -- --baseline ``` It reads no Goals or accounts and never targets an installed LoopX process. + The fault injector uses a private WebKit process-id getter only in this example; production recovery uses the public Tauri termination hook. This controlled regression does not establish long-duration reliability or @@ -188,6 +189,33 @@ rechecked at most every 30 seconds; an explicit recovery action wakes the supervisor immediately. It returns to the loading shape as soon as the snapshot leaves the terminal phase (for example while an explicit repair runs). +### Returning After A Normal Close + +The packaged App remembers the selected local Goal, view, Agent and Todo when +its main window closes or the App quits. Reopening uses the newly allocated +loopback origin and returns to that selection without another navigation step. +Selecting **LoopX 管家** before closing instead returns to Manager next time. +This restores a reading position; it never submits a message or starts work. + +The optional `workspace-location.json` in the App's private data directory +contains only those selection identifiers. It stores no origin, raw URL, prompt, +credentials or report/session query. Missing, corrupt or obsolete preferences +fall back to the ordinary workspace; unknown views fall back to the frontend's +default. Boot and foreign pages cannot overwrite a saved local selection. +Remote status sources and host-granted workspace contexts are not restored: +they reopen at local Manager, so their Goal identifiers cannot be replayed +against the local source. Vite development navigation is unchanged. Explicit +runtime repair retains its existing root handoff; WebContent crash recovery +continues to retain the current in-process route. + +For native qualification, use a separately identified packaged App with the +real backend: select a local Goal and Agent page, quit normally, reopen, and +check the same selection on the new port. Also check window-close, an explicit +return to Manager and a corrupt preference in that disposable App profile. +Retain Session IDs and active-Turn readbacks before and after; navigation is +not evidence that work was resumed or accepted. Cross-platform and sustained +use require their own qualification. + ## Known Issues ### Fresh Mac without a usable Python stays on the boot screen diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/lib.rs b/apps/desktop/loopx-control-plane/src-tauri/src/lib.rs index e3e1659e60..8c7392bc67 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/lib.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/lib.rs @@ -7,6 +7,7 @@ mod service_endpoints; mod services; mod update_backup; mod workspace_handoff; +mod workspace_location; use services::ServiceSet; use std::sync::{ @@ -15,7 +16,7 @@ use std::sync::{ }; use tauri::{ ipc::CapabilityBuilder, webview::PageLoadEvent, AppHandle, Manager, RunEvent, Url, WebviewUrl, - WebviewWindowBuilder, + WebviewWindowBuilder, WindowEvent, }; use tauri_plugin_notification::NotificationExt; use workspace_handoff::{NavigationRetry, WorkspaceHandoff}; @@ -135,6 +136,11 @@ pub fn run() { .build(), ) .plugin(tauri_plugin_notification::init()) + .on_window_event(|window, event| { + if window.label() == "main" && matches!(event, WindowEvent::CloseRequested { .. }) { + workspace_location::remember_current(window.app_handle()); + } + }) .setup(move |app| { let origin: Url = web_origin.parse()?; app.add_capability( @@ -155,7 +161,7 @@ pub fn run() { }; let handoff = Arc::new(Mutex::new(WorkspaceHandoff::new( boot_url(app.handle()), - origin.clone(), + workspace_location::launch_destination(app.handle(), &origin), retry, ))); app.manage(Arc::clone(&handoff)); @@ -220,7 +226,10 @@ pub fn run() { } let healed = started.healed; *services_for_setup.lock().expect("service state lock") = Some(started); - handoff.lock().expect("workspace handoff lock").reconnect(); + handoff + .lock() + .expect("workspace handoff lock") + .services_ready(); WorkspaceHandoff::reconcile(&handoff, &handle); if healed { let _ = handle @@ -279,8 +288,9 @@ pub fn run() { let app = builder .build(tauri::generate_context!()) .expect("failed to build LoopX desktop shell"); - app.run(move |_app, event| { + app.run(move |app, event| { if matches!(event, RunEvent::Exit | RunEvent::ExitRequested { .. }) { + workspace_location::remember_current(app); shutting_down.store(true, Ordering::Release); if let Ok(mut guard) = services.lock() { if let Some(current) = guard.as_mut() { diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/workspace_handoff.rs b/apps/desktop/loopx-control-plane/src-tauri/src/workspace_handoff.rs index fd0c4b125c..8d71dfa630 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/workspace_handoff.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/workspace_handoff.rs @@ -16,6 +16,7 @@ pub(crate) enum NavigationRetry { pub(crate) struct WorkspaceHandoff { boot_url: Url, workspace_url: Url, + launch_workspace_url: Option, last_workspace_url: Option, retry: NavigationRetry, pending: bool, @@ -25,9 +26,13 @@ pub(crate) struct WorkspaceHandoff { impl WorkspaceHandoff { const RETRY_INTERVAL: Duration = Duration::from_secs(2); pub(crate) fn new(boot_url: Url, workspace_url: Url, retry: NavigationRetry) -> Self { + let mut root = workspace_url.clone(); + root.set_query(None); + root.set_fragment(None); Self { boot_url, - workspace_url, + workspace_url: root, + launch_workspace_url: Some(workspace_url), last_workspace_url: None, retry, pending: false, @@ -36,10 +41,21 @@ impl WorkspaceHandoff { } pub(crate) fn reconnect(&mut self) { // Explicit runtime repair retains its established workspace-root handoff. + self.launch_workspace_url = None; self.last_workspace_url = None; self.pending = true; self.last_attempt = None; } + pub(crate) fn services_ready(&mut self) { + // A normal launch consumes the saved selection once. Explicit runtime + // repair still returns to the root; it must not replay an old selection. + let launch = self.launch_workspace_url.take(); + self.reconnect(); + self.last_workspace_url = launch; + } + pub(crate) fn origin(&self) -> &Url { + &self.workspace_url + } pub(crate) fn page_reached(&mut self, current: &Url) { if current.origin() == self.workspace_url.origin() { self.last_workspace_url = Some(current.clone()); @@ -128,6 +144,26 @@ pub(crate) fn content_lost(webview: &tauri::Webview) { mod tests { use super::*; #[test] + fn normal_launch_resumes_once_but_explicit_repair_keeps_its_root_handoff() { + let launch: Url = "http://127.0.0.1:49123/chat/?goalId=example&view=tasks" + .parse() + .unwrap(); + let root: Url = "http://127.0.0.1:49123/chat/".parse().unwrap(); + let mut h = WorkspaceHandoff::new( + "tauri://localhost/".parse().unwrap(), + launch.clone(), + NavigationRetry::UntilNativeCommit, + ); + assert!(!h.needs_navigation(Instant::now())); + h.services_ready(); + assert_eq!(h.destination(), &launch); + h.page_reached(&launch); + h.reconnect(); + assert_eq!(h.destination(), &root); + h.services_ready(); + assert_eq!(h.destination(), &root); + } + #[test] fn failed_workspace_load_retries_until_a_native_commit() { let boot: Url = "tauri://localhost/".parse().unwrap(); let target: Url = "http://127.0.0.1:8767/chat/".parse().unwrap(); diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/workspace_location.rs b/apps/desktop/loopx-control-plane/src-tauri/src/workspace_location.rs new file mode 100644 index 0000000000..a70f70bf55 --- /dev/null +++ b/apps/desktop/loopx-control-plane/src-tauri/src/workspace_location.rs @@ -0,0 +1,310 @@ +//! Optional native presentation preference. The frontend still owns routing; +//! this adapter only carries local selection across changing loopback ports. +use serde_json::{json, Value}; +use std::{ + collections::BTreeMap, + fs, + io::{Read, Write}, + path::Path, +}; +use tauri::{AppHandle, Manager, Url}; + +#[derive(Debug, Default, PartialEq)] +struct LocalWorkspace { + goal_id: String, + view: Option, + work_agent: Option, + todo: Option, +} + +// Match the existing Goal instance ID and frontend Agent/Todo search boundary. +// These are selection identifiers, never paths, commands or execution grants. +fn selection_id(value: &str) -> Option { + (!value.is_empty() + && value.len() <= 200 + && value + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"._:-".contains(&c))) + .then(|| value.to_owned()) +} + +impl LocalWorkspace { + fn selections(goal: &str, view: &str, agent: &str, todo: &str) -> Option { + if goal.is_empty() { + return Some(Self::default()); + } + Some(Self { + goal_id: selection_id(goal)?, + // Existing local presentation vocabulary, owned by dashboard's + // router.tsx. Unknown/retired views use the frontend's default. + view: matches!(view, "conversation" | "overview" | "tasks" | "files") + .then(|| view.to_owned()), + work_agent: selection_id(agent), + todo: selection_id(todo), + }) + } + + fn capture(current: &Url, origin: &Url) -> Option { + if current.origin() != origin.origin() + || current.path() != "/chat/" + || !current.username().is_empty() + || current.password().is_some() + { + // Boot/error pages and foreign navigation must not erase a good + // local preference when the workspace has not committed yet. + return None; + } + let mut query = BTreeMap::new(); + for (key, value) in current.query_pairs().into_owned() { + // Do not reinterpret an ambiguous source/selection differently + // from the frontend's search parser. + if query.insert(key, value).is_some() { + return None; + } + } + let get = |key: &str| query.get(key).map(String::as_str).unwrap_or(""); + if !get("workspace").is_empty() || !local_status(get("statusUrl"), origin) { + // A remote source's Goal/Agent must not be replayed against local + // authority. Reopening that unsupported context returns Manager. + return Some(Self::default()); + } + Self::selections(get("goalId"), get("view"), get("workAgent"), get("todo")) + } + + fn destination(&self, origin: &Url) -> Url { + let mut destination = origin.clone(); + destination.set_query(None); + destination.set_fragment(None); + let mut query = destination.query_pairs_mut(); + query.append_pair("goalId", &self.goal_id); + query.append_pair("statusUrl", "/status.json"); + for (key, value) in [ + ("view", &self.view), + ("workAgent", &self.work_agent), + ("todo", &self.todo), + ] { + if let Some(value) = value { + query.append_pair(key, value); + } + } + drop(query); + destination + } + + fn from_value(value: &Value) -> Option { + if value["version"] != 1 { + return None; + } + Self::selections( + value["goal_id"].as_str()?, + value["view"].as_str().unwrap_or(""), + value["work_agent"].as_str().unwrap_or(""), + value["todo"].as_str().unwrap_or(""), + ) + } + + fn value(&self) -> Value { + json!({"version": 1, "goal_id": self.goal_id, "view": self.view, + "work_agent": self.work_agent, "todo": self.todo}) + } +} + +fn local_status(value: &str, origin: &Url) -> bool { + if value.is_empty() { + return true; + } + origin.join(value).is_ok_and(|url| { + url.origin() == origin.origin() + && url.path() == "/status.json" + && url.query().is_none() + && url.fragment().is_none() + && url.username().is_empty() + && url.password().is_none() + }) +} + +fn read(path: &Path) -> Option { + let mut bytes = Vec::new(); + fs::File::open(path) + .ok()? + .take(4097) + .read_to_end(&mut bytes) + .ok()?; + if bytes.len() > 4096 { + return None; + } + LocalWorkspace::from_value(&serde_json::from_slice::(&bytes).ok()?) +} + +fn write(path: &Path, selection: &LocalWorkspace) -> std::io::Result<()> { + if read(path).as_ref() == Some(selection) { + return Ok(()); + } + let directory = path + .parent() + .ok_or_else(|| std::io::Error::other("missing preference directory"))?; + fs::create_dir_all(directory)?; + let mut file = tempfile::NamedTempFile::new_in(directory)?; + file.write_all(selection.value().to_string().as_bytes())?; + file.as_file().sync_all()?; + file.persist(path)?; + Ok(()) +} + +pub(crate) fn launch_destination(app: &AppHandle, origin: &Url) -> Url { + // Development keeps its existing Vite handoff. An optional preference can + // never block startup, change runtime selection or choose a foreign origin. + if origin.path() == "/chat/" { + if let Some(selection) = app + .path() + .app_local_data_dir() + .ok() + .and_then(|directory| read(&directory.join("workspace-location.json"))) + { + return selection.destination(origin); + } + } + origin.clone() +} + +pub(crate) fn remember_current(app: &AppHandle) { + let Some(handoff) = app + .try_state::>>( + ) + else { + return; + }; + // Read the native WebView once at close/quit, including SPA history changes. + // No page script, per-navigation disk writes, URL polling or new IPC grant. + let Some(current) = app + .get_webview_window("main") + .and_then(|window| window.url().ok()) + else { + return; + }; + let origin = handoff + .lock() + .expect("workspace handoff lock") + .origin() + .clone(); + let Some(selection) = LocalWorkspace::capture(¤t, &origin) else { + return; + }; + let result = app + .path() + .app_local_data_dir() + .ok() + .map(|directory| write(&directory.join("workspace-location.json"), &selection)); + if !matches!(result, Some(Ok(()))) { + eprintln!("LoopX could not save the local workspace selection; startup remains available"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn origin(port: u16) -> Url { + format!("http://127.0.0.1:{port}/chat/").parse().unwrap() + } + + #[test] + fn ordinary_reopen_keeps_selection_across_ports_without_replaying_other_inputs() { + let old = origin(49123); + let page = old.join("?goalId=example&view=conversation&workAgent=worker&todo=todo_123&statusUrl=%2Fstatus.json&token=secret&prompt=run&reportSessionId=report#secret").unwrap(); + let selection = LocalWorkspace::capture(&page, &old).unwrap(); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("app/workspace-location.json"); + write(&path, &selection).unwrap(); + let reopened = read(&path).unwrap().destination(&origin(59234)); + assert_eq!(reopened.as_str(), "http://127.0.0.1:59234/chat/?goalId=example&statusUrl=%2Fstatus.json&view=conversation&workAgent=worker&todo=todo_123"); + let stored = fs::read_to_string(&path).unwrap(); + for excluded in ["secret", "49123", "prompt", "report", "token", "http"] { + assert!(!stored.contains(excluded), "{excluded}"); + } + } + + #[test] + fn boot_foreign_and_non_workspace_pages_do_not_replace_the_local_selection() { + let local = origin(49123); + for page in [ + "tauri://localhost/", + "about:blank", + "https://example.org/chat/?goalId=foreign", + "http://127.0.0.1:49124/chat/?goalId=other", + "http://127.0.0.1:49123/diagnostics", + "http://user:secret@127.0.0.1:49123/chat/?goalId=example", + "http://127.0.0.1:49123/chat/?goalId=first&goalId=second", + "http://127.0.0.1:49123/chat/?goalId=remote&statusUrl=https%3A%2F%2Fexample.org%2Fstatus.json&statusUrl=%2Fstatus.json", + ] { + assert_eq!( + LocalWorkspace::capture(&page.parse().unwrap(), &local), + None, + "{page}" + ); + } + } + + #[test] + fn remote_source_and_explicit_manager_return_do_not_replay_a_goal_against_local_authority() { + let local = origin(49123); + for query in [ + "?goalId=remote&statusUrl=https%3A%2F%2Fexample.org%2Fstatus.json", + "?goalId=remote&statusUrl=http%3A%2F%2F127.0.0.1%3A8766%2Fstatus.json", + "?goalId=remote&statusUrl=%2Fstatus.json%3Ftoken%3Dsecret", + "?goalId=remote&workspace=remoteHost", + "?goalId=&workAgent=worker&view=tasks", + ] { + assert_eq!( + LocalWorkspace::capture(&local.join(query).unwrap(), &local), + Some(LocalWorkspace::default()), + "{query}" + ); + } + assert!(local_status("http://127.0.0.1:49123/status.json", &local)); + assert!(local_status("/status.json", &local)); + } + + #[test] + fn missing_corrupt_obsolete_and_oversized_preferences_cannot_block_launch() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("workspace-location.json"); + assert_eq!(read(&path), None); + for bytes in [ + "broken".into(), + "{}".into(), + "{\"version\":2,\"goal_id\":\"example\"}".into(), + "x".repeat(4097), + ] { + fs::write(&path, bytes).unwrap(); + assert_eq!(read(&path), None); + } + let valid = LocalWorkspace::selections("example", "tasks", "worker", "todo_123").unwrap(); + write(&path, &valid).unwrap(); + assert_eq!(read(&path), Some(valid)); + // A directory/unwritable destination remains a contained optional error. + assert!(write(directory.path(), &LocalWorkspace::default()).is_err()); + } + + #[test] + fn invalid_or_retired_selections_never_produce_a_router_error() { + let value = json!({"version": 1, "goal_id": "example", "view": "retired-view", "work_agent": "../worker", "todo": "x".repeat(201), "url": "https://example.org"}); + let selection = LocalWorkspace::from_value(&value).unwrap(); + assert_eq!( + selection.destination(&origin(49123)).as_str(), + "http://127.0.0.1:49123/chat/?goalId=example&statusUrl=%2Fstatus.json" + ); + for goal in ["../example", "goal with whitespace", "secret\nvalue"] { + assert_eq!(LocalWorkspace::selections(goal, "overview", "", ""), None); + } + for view in ["overview", "conversation", "tasks", "files"] { + assert_eq!( + LocalWorkspace::selections("example", view, "", "") + .unwrap() + .view + .as_deref(), + Some(view) + ); + } + } +} From 6df7d25a2e6f9ca3025034b2691a49923344a81a Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 17 Oct 2026 05:07:13 +0800 Subject: [PATCH 2/2] test(desktop): construct reopen regression query structurally Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../src-tauri/src/workspace_location.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/workspace_location.rs b/apps/desktop/loopx-control-plane/src-tauri/src/workspace_location.rs index a70f70bf55..775b4ab890 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/workspace_location.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/workspace_location.rs @@ -211,7 +211,18 @@ mod tests { #[test] fn ordinary_reopen_keeps_selection_across_ports_without_replaying_other_inputs() { let old = origin(49123); - let page = old.join("?goalId=example&view=conversation&workAgent=worker&todo=todo_123&statusUrl=%2Fstatus.json&token=secret&prompt=run&reportSessionId=report#secret").unwrap(); + let mut page = old.clone(); + page.query_pairs_mut().extend_pairs([ + ("goalId", "example"), + ("view", "conversation"), + ("workAgent", "worker"), + ("todo", "todo_123"), + ("statusUrl", "/status.json"), + ("token", "secret"), + ("prompt", "run"), + ("reportSessionId", "report"), + ]); + page.set_fragment(Some("secret")); let selection = LocalWorkspace::capture(&page, &old).unwrap(); let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("app/workspace-location.json");