From 4da3c9ae524bb81c455d306c97899a694acb9482 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sun, 27 Sep 2026 01:01:22 -0300 Subject: [PATCH 01/16] refactor(browser): define stable device-local profile identity --- crates/ui/src/browser/linux/mod.rs | 10 ++- crates/ui/src/browser/macos.rs | 4 + crates/ui/src/browser/mod.rs | 13 +++- crates/ui/src/browser/profile.rs | 117 +++++++++++++++++++++++++++++ crates/ui/src/shell.rs | 17 ++--- 5 files changed, 149 insertions(+), 12 deletions(-) create mode 100644 crates/ui/src/browser/profile.rs diff --git a/crates/ui/src/browser/linux/mod.rs b/crates/ui/src/browser/linux/mod.rs index 7a9023bd0a..92ebc6a344 100644 --- a/crates/ui/src/browser/linux/mod.rs +++ b/crates/ui/src/browser/linux/mod.rs @@ -25,7 +25,10 @@ pub enum NativeEvent { } #[derive(Clone, Default)] -pub struct BrowserData(Arc>>); +pub struct BrowserData { + current: Arc>>, + profile: Option, +} struct Worker { child: Mutex, @@ -83,8 +86,11 @@ fn helper_path() -> Result { Ok(path) } impl BrowserData { + pub fn for_profile(profile: super::profile::BrowserProfile) -> Self { + Self { profile: Some(profile), ..Default::default() } + } fn worker(&self) -> Result, String> { - let mut current = self.0.lock().unwrap(); + let mut current = self.current.lock().unwrap(); if let Some(worker) = current.upgrade() { if worker .child diff --git a/crates/ui/src/browser/macos.rs b/crates/ui/src/browser/macos.rs index 4b4ad0b24f..8aaca1aa14 100644 --- a/crates/ui/src/browser/macos.rs +++ b/crates/ui/src/browser/macos.rs @@ -27,12 +27,16 @@ use wry::{WebView, WebViewBuilderExtMacos, WebViewExtMacOS}; #[derive(Default)] struct BrowserStore { + profile: Option, store: Option>, preview_hosts: std::collections::BTreeSet, } #[derive(Clone, Default)] pub(super) struct BrowserData(Rc>); impl BrowserData { + pub fn for_profile(profile: super::profile::BrowserProfile) -> Self { + Self(Rc::new(RefCell::new(BrowserStore { profile: Some(profile), ..Default::default() }))) + } fn configuration( &self, mtm: MainThreadMarker, diff --git a/crates/ui/src/browser/mod.rs b/crates/ui/src/browser/mod.rs index 6b4cb526a8..fa7c1ae6b4 100644 --- a/crates/ui/src/browser/mod.rs +++ b/crates/ui/src/browser/mod.rs @@ -8,6 +8,7 @@ use linux as native; #[cfg(target_os = "macos")] use macos as native; pub mod model; +pub(crate) mod profile; mod view; use crate::composer::{ComposerInput, ComposerInputEvent}; @@ -67,13 +68,23 @@ pub enum BrowserEvent { Close, } -/// A window/profile's ephemeral website data, allocated on first navigation. +/// A profile's website data, allocated on first navigation. Default contexts are +/// ephemeral, for unresolved identities and isolated fixtures. #[derive(Clone, Default)] pub struct BrowserContext { #[cfg(any(target_os = "macos", target_os = "linux"))] data: native::BrowserData, } +impl BrowserContext { + pub(crate) fn for_profile(profile: profile::BrowserProfile) -> Self { + #[cfg(any(target_os = "macos", target_os = "linux"))] + { Self { data: native::BrowserData::for_profile(profile) } } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { let _ = profile; Self::default() } + } +} + pub struct BrowserSurface { #[cfg(any(target_os = "macos", target_os = "linux"))] context: BrowserContext, diff --git a/crates/ui/src/browser/profile.rs b/crates/ui/src/browser/profile.rs new file mode 100644 index 0000000000..aec77914d5 --- /dev/null +++ b/crates/ui/src/browser/profile.rs @@ -0,0 +1,117 @@ +//! Stable device-local browser storage, independent of chats and repositories. +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; +use zeron_proto::{AuthState, WorkspaceScope}; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) struct BrowserProfile { + pub root: PathBuf, +} + +impl BrowserProfile { + pub fn for_workspace( + data_dir: &Path, + scope: Option, + auth: Option<&AuthState>, + local_device_id: Option<&str>, + ) -> Option { + let scope = scope?; + let scope_key = match scope { + WorkspaceScope::Local => "local", + WorkspaceScope::Synced => "synced", + WorkspaceScope::Development => "development", + }; + match scope { + WorkspaceScope::Local => Some(Self::new(data_dir, &[scope_key, local_device_id?])), + WorkspaceScope::Synced | WorkspaceScope::Development => { + let AuthState::SignedIn { user, org_id } = auth? else { + return None; + }; + Some(Self::new( + data_dir, + &[scope_key, &user.id, org_id.as_deref().unwrap_or("")], + )) + } + } + } + + fn new(data_dir: &Path, identity: &[&str]) -> Self { + let mut hash = Sha256::new(); + hash.update(b"zeron-browser-profile-v1"); + for part in identity { + hash.update((part.len() as u64).to_le_bytes()); + hash.update(part.as_bytes()); + } + // Resolve aliases so windows using the same installation share a store. + let data_dir = data_dir.canonicalize().unwrap_or_else(|_| { + std::path::absolute(data_dir).unwrap_or_else(|_| data_dir.to_path_buf()) + }); + Self { + root: data_dir + .join("browser") + .join(format!("{:x}", hash.finalize())), + } + } + + #[cfg(any(target_os = "macos", test))] + pub fn data_store_identifier(&self) -> [u8; 16] { + // Include the installation directory: fixtures and alternative data roots + // must never open a real user's WKWebsiteDataStore. + let mut hash = Sha256::new(); + hash.update(b"zeron-wkwebsite-data-store-v1"); + hash.update(self.root.as_os_str().as_encoded_bytes()); + let mut id: [u8; 16] = hash.finalize()[..16].try_into().unwrap(); + id[6] = (id[6] & 0x0f) | 0x80; // UUID v8, application-defined hash. + id[8] = (id[8] & 0x3f) | 0x80; + id + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn identity_is_stable_and_scoped_to_account_organization_and_installation() { + let root = tempfile::tempdir().unwrap(); + let make = |parts: &[&str]| BrowserProfile::new(root.path(), parts); + let a = make(&["synced", "user-a", "org-a"]); + assert_eq!(a, make(&["synced", "user-a", "org-a"])); + for parts in [ + ["synced", "user-b", "org-a"], + ["synced", "user-a", "org-b"], + ["development", "user-a", "org-a"], + ] { + let b = make(&parts); + assert_ne!(a.root, b.root); + assert_ne!(a.data_store_identifier(), b.data_store_identifier()); + } + let other = tempfile::tempdir().unwrap(); + let b = BrowserProfile::new(other.path(), &["synced", "user-a", "org-a"]); + assert_ne!(a.data_store_identifier(), b.data_store_identifier()); + assert_ne!(make(&["ab", "c"]), make(&["a", "bc"])); + assert!( + make(&["../../outside"]) + .root + .starts_with(root.path().join("browser")) + ); + } + + #[test] + fn unresolved_identity_does_not_select_a_shared_persistent_profile() { + let root = Path::new("/unused"); + assert!(BrowserProfile::for_workspace(root, None, None, None).is_none()); + assert!( + BrowserProfile::for_workspace(root, Some(WorkspaceScope::Local), None, None).is_none() + ); + assert!( + BrowserProfile::for_workspace(root, Some(WorkspaceScope::Synced), None, Some("device")) + .is_none() + ); + let local = |device| { + BrowserProfile::for_workspace(root, Some(WorkspaceScope::Local), None, Some(device)) + }; + assert_eq!(local("one"), local("one")); + assert_ne!(local("one"), local("two")); + } +} diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 54e0070761..a2437d84ab 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -1840,7 +1840,7 @@ pub struct Shell { browser_subs: std::collections::HashMap, browser_seq: u64, browser_context: crate::browser::BrowserContext, - browser_profile: Option, + browser_profile: Option, /// Ordered surface tabs per panel key (drag-reorderable; stale entries — /// closed terminals/diffs — are skipped at read time). right_tabs: std::collections::HashMap>, @@ -12256,21 +12256,20 @@ impl Render for Shell { let browser_profile = { let state = self.state.read(cx); - crate::links::workspace_locator( + crate::browser::profile::BrowserProfile::for_workspace( + &self.data_dir, state.workspace_scope, state.auth.as_ref(), state.local_device_id.as_deref(), ) }; if browser_profile.is_some() && browser_profile != self.browser_profile { - if self.browser_profile.is_some() { - for browser in self.browsers.values() { - browser.update(cx, |browser, cx| browser.close(cx)); - } - self.browsers.clear(); - self.browser_subs.clear(); - self.browser_context = crate::browser::BrowserContext::default(); + for browser in self.browsers.values() { + browser.update(cx, |browser, cx| browser.close(cx)); } + self.browsers.clear(); + self.browser_subs.clear(); + self.browser_context = crate::browser::BrowserContext::for_profile(browser_profile.clone().unwrap()); self.browser_profile = browser_profile; } let browser_active = matches!(gate, GatePhase::Ready) From 81d8d0b1c929ca271e1cc2191feac4aa68ffc55e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sun, 27 Sep 2026 01:02:26 -0300 Subject: [PATCH 02/16] fix(browser-linux): persist website data and cookies by profile --- crates/ui/src/browser/linux/helper.c | 57 +++++++++++++++++++++++++++- crates/ui/src/browser/linux/mod.rs | 44 ++++++++++++++++++++- 2 files changed, 98 insertions(+), 3 deletions(-) diff --git a/crates/ui/src/browser/linux/helper.c b/crates/ui/src/browser/linux/helper.c index 1ec1a3b8ee..7d731833e1 100644 --- a/crates/ui/src/browser/linux/helper.c +++ b/crates/ui/src/browser/linux/helper.c @@ -1,6 +1,9 @@ +#define _GNU_SOURCE // WebKitGTK runs in its own process. Only rendered pixels and explicit browser // commands cross the pipe; GPUI owns all visible windows and input routing. #include +#include +#include #include #include #include @@ -604,8 +607,58 @@ static gboolean read_commands(gint fd, GIOCondition condition, gpointer unused) } return G_SOURCE_CONTINUE; } +static gboolean storage_directory(const gchar *path) { + if (g_mkdir_with_parents(path, 0700) < 0) + return FALSE; + gchar *probe = g_build_filename(path, ".write-test-XXXXXX", NULL); + int fd = g_mkstemp(probe); + if (fd >= 0) { + close(fd); + unlink(probe); + } + g_free(probe); + return fd >= 0; +} + +static gboolean configure_storage(int argc, char **argv) { + if (argc == 1) { + // Explicitly unscoped contexts are used by fixtures/unresolved identity. + context = webkit_web_context_new_ephemeral(); + return TRUE; + } + if (argc != 3 || strcmp(argv[1], "--profile") || !g_path_is_absolute(argv[2])) { + const char *error = "Browser profile requires an absolute storage directory"; + send_packet('E', 0, error, strlen(error)); + return FALSE; + } + gchar *data = g_build_filename(argv[2], "data", NULL); + gchar *cache = g_build_filename(argv[2], "cache", NULL); + gchar *cookies = g_build_filename(argv[2], "cookies.sqlite", NULL); + gboolean writable = storage_directory(argv[2]) && storage_directory(data) && storage_directory(cache); + int fd = writable ? open(cookies, O_RDWR | O_CREAT | O_CLOEXEC, 0600) : -1; + if (fd < 0) { + gchar *error = g_strdup_printf("Could not open browser profile: %s", g_strerror(errno)); + send_packet('E', 0, error, strlen(error)); + g_free(error); + } else { + close(fd); + WebKitWebsiteDataManager *manager = webkit_website_data_manager_new( + "base-data-directory", data, "base-cache-directory", cache, NULL); + webkit_cookie_manager_set_persistent_storage( + webkit_website_data_manager_get_cookie_manager(manager), cookies, + WEBKIT_COOKIE_PERSISTENT_STORAGE_SQLITE); + context = webkit_web_context_new_with_website_data_manager(manager); + g_object_unref(manager); + } + g_free(data); + g_free(cache); + g_free(cookies); + return context != NULL; +} + int main(int argc, char **argv) { signal(SIGPIPE, SIG_IGN); + umask(0077); // Offscreen GTK surfaces need CPU-addressable frames, never native GL child windows. g_setenv("WEBKIT_DISABLE_DMABUF_RENDERER", "1", TRUE); g_setenv("GDK_SCALE", "1", TRUE); @@ -616,7 +669,9 @@ int main(int argc, char **argv) { } pages = g_hash_table_new_full(g_direct_hash, g_direct_equal, NULL, free_page); input = g_byte_array_new(); - context = webkit_web_context_new_ephemeral(); + if (!configure_storage(argc, argv)) + return 1; + send_packet('R', 0, "", 0); g_signal_connect(context, "download-started", G_CALLBACK(download), NULL); g_unix_fd_add(STDIN_FILENO, G_IO_IN | G_IO_HUP | G_IO_ERR, read_commands, NULL); g_timeout_add(16, render_frames, NULL); diff --git a/crates/ui/src/browser/linux/mod.rs b/crates/ui/src/browser/linux/mod.rs index 92ebc6a344..0216c68042 100644 --- a/crates/ui/src/browser/linux/mod.rs +++ b/crates/ui/src/browser/linux/mod.rs @@ -85,6 +85,22 @@ fn helper_path() -> Result { .map_err(|e| e.to_string())?; Ok(path) } +fn read_startup(stdout: &mut impl Read) -> Result<(), String> { + let mut header = [0u8; 9]; + stdout.read_exact(&mut header).map_err(|error| format!("Browser helper could not start: {error}. Check the WebKitGTK 4.1 runtime and display."))?; + let length = u32::from_le_bytes(header[5..9].try_into().unwrap()) as usize; + if length > 16384 || header[1..5] != [0; 4] { + return Err("Invalid browser startup response".into()); + } + let mut message = vec![0; length]; + stdout.read_exact(&mut message).map_err(|error| error.to_string())?; + match header[0] { + b'R' if message.is_empty() => Ok(()), + b'E' => Err(String::from_utf8_lossy(&message).into_owned()), + _ => Err("Invalid browser startup response".into()), + } +} + impl BrowserData { pub fn for_profile(profile: super::profile::BrowserProfile) -> Self { Self { profile: Some(profile), ..Default::default() } @@ -103,10 +119,34 @@ impl BrowserData { return Ok(worker); } } - let mut child = Command::new(helper_path()?).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::inherit()).spawn() + let mut command = Command::new(helper_path()?); + if let Some(profile) = &self.profile { + command.arg("--profile").arg(&profile.root); + } + let mut child = command.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::inherit()).spawn() .map_err(|e| format!("Could not start WebKitGTK: {e}. Install the WebKitGTK 4.1 runtime for your distribution."))?; let stdin = child.stdin.take().unwrap(); - let mut stdout = child.stdout.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + // Report startup/storage failures on the page instead of silently opening + // an ephemeral session. Bound the wait if the display/runtime hangs. + let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1); + std::thread::spawn(move || { + let mut stdout = stdout; + let result = read_startup(&mut stdout).map(|_| stdout); + let _ = ready_tx.send(result); + }); + let mut stdout = match ready_rx.recv_timeout(std::time::Duration::from_secs(10)) { + Ok(Ok(stdout)) => stdout, + result => { + let _ = child.kill(); + let _ = child.wait(); + return Err(match result { + Ok(Err(error)) => error, + Err(error) => format!("Browser startup did not complete: {error}"), + _ => unreachable!(), + }); + } + }; let routes: Arc>>> = Arc::default(); let reader_routes = routes.clone(); std::thread::Builder::new().name("browser-frames".into()).spawn(move || { From 6465e29e62824010417f7d3f2f85d8b1c9ef073e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sun, 27 Sep 2026 01:03:07 -0300 Subject: [PATCH 03/16] fix(browser-macos): persist isolated website stores on macOS 14 --- crates/ui/src/browser/macos.rs | 25 ++++++++++++++++++++++--- crates/ui/src/browser/view.rs | 6 ++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/crates/ui/src/browser/macos.rs b/crates/ui/src/browser/macos.rs index 8aaca1aa14..25f8d4f531 100644 --- a/crates/ui/src/browser/macos.rs +++ b/crates/ui/src/browser/macos.rs @@ -43,8 +43,15 @@ impl BrowserData { ) -> Retained { let mut data = self.0.borrow_mut(); if data.store.is_none() { - data.store = - Some(unsafe { objc2_web_kit::WKWebsiteDataStore::nonPersistentDataStore(mtm) }); + data.store = Some(unsafe { + match &data.profile { + Some(profile) if persistent_profiles_supported() => { + let identifier = objc2_foundation::NSUUID::from_bytes(profile.data_store_identifier()); + objc2_web_kit::WKWebsiteDataStore::dataStoreForIdentifier(&identifier, mtm) + } + _ => objc2_web_kit::WKWebsiteDataStore::nonPersistentDataStore(mtm), + } + }); if let Err(error) = configure_preview_proxy(data.store.as_ref().unwrap(), &data.preview_hosts) { @@ -82,6 +89,19 @@ impl BrowserData { } } +/// macOS 12/13 have only a single default persistent store. Do not silently +/// share it across accounts; retain isolated temporary stores on those systems. +fn persistent_profiles_supported() -> bool { + unsafe { msg_send![class!(WKWebsiteDataStore), respondsToSelector: sel!(dataStoreForIdentifier:)] } +} + +impl BrowserData { + pub(super) fn persistence_notice(&self) -> Option<&'static str> { + (self.0.borrow().profile.is_some() && !persistent_profiles_supported()) + .then_some("Logins are temporary on this macOS version. Use macOS 14 or later, or open this page in your default browser, to keep them.") + } +} + /// Network.framework objects are Objective-C OS objects. Resolve the newer API /// dynamically so older systems can still open ordinary browser tabs. Match /// only discovered/opened preview hostnames; other websites retain normal routing. @@ -302,7 +322,6 @@ impl NativePage { .with_webview_configuration(data.configuration(mtm)) .with_visible(false) .with_focused(false) - .with_incognito(true) .with_new_window_req_handler(move |url, _| { if allowed_navigation(&url) { let _ = new_tab.try_send(NativeEvent::NewTab(url)); diff --git a/crates/ui/src/browser/view.rs b/crates/ui/src/browser/view.rs index 47d268754b..aafade02fd 100644 --- a/crates/ui/src/browser/view.rs +++ b/crates/ui/src/browser/view.rs @@ -402,6 +402,10 @@ impl BrowserSurface { impl Render for BrowserSurface { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let theme = Theme::of(cx).clone(); + #[cfg(target_os = "macos")] + let persistence_notice = self.context.data.persistence_notice(); + #[cfg(not(target_os = "macos"))] + let persistence_notice: Option<&str> = None; let focused = self.address.focus_handle(cx).is_focused(window); let external = !cfg!(any(target_os = "macos", target_os = "linux")); let has_page = self.page.url.is_some(); @@ -646,6 +650,8 @@ impl Render for BrowserSurface { .on_action(cx.listener(|this, _: &super::Back, _, _| this.history(false))) .on_action(cx.listener(|this, _: &super::Forward, _, _| this.history(true))) .child(toolbar) + .when_some(persistence_notice, |el, message| el.child(div().px(px(12.0)).py(px(8.0)) + .text_size(crate::typography::ui_rems(11.0)).text_color(theme.text_muted).child(message.to_owned()))) .when_some(self.validation.clone(), |el, message| el.child(div().px(px(12.0)).py(px(8.0)).text_size(crate::typography::ui_rems(11.0)).text_color(theme.danger).child(message))) .when(remote_loopback, |el| el.child(div().px(px(12.0)).py(px(8.0)).border_b_1().border_color(theme.border) .text_size(crate::typography::ui_rems(11.0)).text_color(theme.text_muted) From 75bffb811727a7356e170830bbf2e0b16bb4c434 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sun, 27 Sep 2026 01:05:40 -0300 Subject: [PATCH 04/16] fix(browser): preserve profile lifecycle across tabs and windows --- crates/ui/src/browser/linux/helper.c | 43 +++++++++++++++++++++++++++- crates/ui/src/browser/linux/mod.rs | 43 ++++++++++++++++++++++------ crates/ui/src/browser/macos.rs | 15 +++++++++- crates/ui/src/shell.rs | 42 +++++++++++++++------------ 4 files changed, 115 insertions(+), 28 deletions(-) diff --git a/crates/ui/src/browser/linux/helper.c b/crates/ui/src/browser/linux/helper.c index 7d731833e1..c38826f046 100644 --- a/crates/ui/src/browser/linux/helper.c +++ b/crates/ui/src/browser/linux/helper.c @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -29,6 +30,8 @@ typedef struct { static GHashTable *pages; static WebKitWebContext *context; static GByteArray *input; +static int profile_lock = -1; +static gboolean shutting_down; static gboolean write_all(const void *data, size_t length) { const char *p = data; @@ -473,9 +476,32 @@ static void evaluated(GObject *web, GAsyncResult *result, gpointer data) { g_clear_object(&v); g_clear_error(&error); } +static void shutdown_ready(GObject *manager, GAsyncResult *result, gpointer unused) { + GError *error = NULL; + GList *cookies = webkit_cookie_manager_get_all_cookies_finish(WEBKIT_COOKIE_MANAGER(manager), result, &error); + g_list_free_full(cookies, (GDestroyNotify)soup_cookie_free); + g_clear_error(&error); + gtk_main_quit(); +} +static void begin_shutdown(void) { + if (shutting_down) + return; + shutting_down = TRUE; + g_hash_table_remove_all(pages); + // Let WebKit process the pending cookie operations before leaving its loop. + webkit_cookie_manager_get_all_cookies(webkit_web_context_get_cookie_manager(context), + NULL, shutdown_ready, NULL); +} + static void command(JsonObject *o) { guint id = number(o, "id"); const char *cmd = string(o, "cmd"); + if (!strcmp(cmd, "shutdown")) { + begin_shutdown(); + return; + } + if (shutting_down) + return; Page *p = g_hash_table_lookup(pages, GUINT_TO_POINTER(id)); if (!strcmp(cmd, "create")) { if (!p) @@ -582,7 +608,7 @@ static gboolean read_commands(gint fd, GIOCondition condition, gpointer unused) guint8 bytes[65536]; ssize_t n = read(fd, bytes, sizeof bytes); if (n <= 0) { - gtk_main_quit(); + begin_shutdown(); return G_SOURCE_REMOVE; } g_byte_array_append(input, bytes, n); @@ -631,6 +657,19 @@ static gboolean configure_storage(int argc, char **argv) { send_packet('E', 0, error, strlen(error)); return FALSE; } + if (!storage_directory(argv[2])) { + const char *error = "Could not create or write the browser profile directory"; + send_packet('E', 0, error, strlen(error)); + return FALSE; + } + gchar *lock_path = g_build_filename(argv[2], "profile.lock", NULL); + profile_lock = open(lock_path, O_RDWR | O_CREAT | O_CLOEXEC, 0600); + g_free(lock_path); + if (profile_lock < 0 || flock(profile_lock, LOCK_EX | LOCK_NB) < 0) { + const char *error = "Browser profile is in use by another process or cannot be locked. Close the other Zeron instance and reopen this tab."; + send_packet('E', 0, error, strlen(error)); + return FALSE; + } gchar *data = g_build_filename(argv[2], "data", NULL); gchar *cache = g_build_filename(argv[2], "cache", NULL); gchar *cookies = g_build_filename(argv[2], "cookies.sqlite", NULL); @@ -679,5 +718,7 @@ int main(int argc, char **argv) { g_hash_table_destroy(pages); g_byte_array_unref(input); g_object_unref(context); + if (profile_lock >= 0) + close(profile_lock); return 0; } diff --git a/crates/ui/src/browser/linux/mod.rs b/crates/ui/src/browser/linux/mod.rs index 0216c68042..962d0e4682 100644 --- a/crates/ui/src/browser/linux/mod.rs +++ b/crates/ui/src/browser/linux/mod.rs @@ -26,19 +26,35 @@ pub enum NativeEvent { #[derive(Clone, Default)] pub struct BrowserData { - current: Arc>>, + current: Arc>>>, profile: Option, } struct Worker { child: Mutex, - stdin: Mutex, + stdin: Mutex>, routes: Arc>>>, next_id: AtomicU32, } impl Drop for Worker { fn drop(&mut self) { + // EOF asks the helper to drain pending work. Closing the pipe cannot + // block on a hung helper, unlike writing another command to a full pipe. + if let Ok(stdin) = self.stdin.get_mut() { + stdin.take(); + } if let Ok(child) = self.child.get_mut() { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3); + loop { + match child.try_wait() { + Ok(Some(_)) => return, + Ok(None) if std::time::Instant::now() < deadline => { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + _ => break, + } + } + tracing::warn!("browser helper did not shut down in time; terminating it"); let _ = child.kill(); let _ = child.wait(); } @@ -103,11 +119,21 @@ fn read_startup(stdout: &mut impl Read) -> Result<(), String> { impl BrowserData { pub fn for_profile(profile: super::profile::BrowserProfile) -> Self { - Self { profile: Some(profile), ..Default::default() } + // One live helper per profile, shared across all windows in this process. + type SharedWorker = Mutex>>; + static PROFILES: std::sync::OnceLock>>> = std::sync::OnceLock::new(); + let mut profiles = PROFILES.get_or_init(Default::default).lock().unwrap(); + profiles.retain(|_, worker| worker.strong_count() > 0); + let current = profiles.get(&profile.root).and_then(Weak::upgrade).unwrap_or_else(|| { + let current = Arc::new(Mutex::new(None)); + profiles.insert(profile.root.clone(), Arc::downgrade(¤t)); + current + }); + Self { profile: Some(profile), current } } fn worker(&self) -> Result, String> { let mut current = self.current.lock().unwrap(); - if let Some(worker) = current.upgrade() { + if let Some(worker) = current.as_ref() { if worker .child .lock() @@ -116,7 +142,7 @@ impl BrowserData { .map_err(|e| e.to_string())? .is_none() { - return Ok(worker); + return Ok(worker.clone()); } } let mut command = Command::new(helper_path()?); @@ -209,11 +235,11 @@ impl BrowserData { }).map_err(|e| e.to_string())?; let worker = Arc::new(Worker { child: Mutex::new(child), - stdin: Mutex::new(stdin), + stdin: Mutex::new(Some(stdin)), routes, next_id: AtomicU32::new(1), }); - *current = Arc::downgrade(&worker); + *current = Some(worker.clone()); Ok(worker) } } @@ -221,7 +247,8 @@ impl Worker { fn send(&self, id: u32, mut command: Value) -> Result<(), String> { command["id"] = id.into(); let data = serde_json::to_vec(&command).map_err(|e| e.to_string())?; - let mut pipe = self.stdin.lock().unwrap(); + let mut stdin = self.stdin.lock().unwrap(); + let pipe = stdin.as_mut().ok_or("Browser helper is shutting down")?; pipe.write_all(&(data.len() as u32).to_le_bytes()) .and_then(|_| pipe.write_all(&data)) .map_err(|e| e.to_string()) diff --git a/crates/ui/src/browser/macos.rs b/crates/ui/src/browser/macos.rs index 25f8d4f531..7d601ac7cd 100644 --- a/crates/ui/src/browser/macos.rs +++ b/crates/ui/src/browser/macos.rs @@ -35,7 +35,20 @@ struct BrowserStore { pub(super) struct BrowserData(Rc>); impl BrowserData { pub fn for_profile(profile: super::profile::BrowserProfile) -> Self { - Self(Rc::new(RefCell::new(BrowserStore { profile: Some(profile), ..Default::default() }))) + thread_local! { + static PROFILES: RefCell>>> = RefCell::new(std::collections::HashMap::new()); + } + PROFILES.with(|profiles| { + let mut profiles = profiles.borrow_mut(); + profiles.retain(|_, store| store.strong_count() > 0); + if let Some(store) = profiles.get(&profile.root).and_then(std::rc::Weak::upgrade) { + return Self(store); + } + let root = profile.root.clone(); + let store = Rc::new(RefCell::new(BrowserStore { profile: Some(profile), ..Default::default() })); + profiles.insert(root, Rc::downgrade(&store)); + Self(store) + }) } fn configuration( &self, diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index a2437d84ab..3e1170b7b4 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -3458,6 +3458,28 @@ impl Shell { outcome } + fn sync_browser_profile(&mut self, cx: &mut Context) { + let browser_profile = { + let state = self.state.read(cx); + crate::browser::profile::BrowserProfile::for_workspace( + &self.data_dir, + state.workspace_scope, + state.auth.as_ref(), + state.local_device_id.as_deref(), + ) + }; + if browser_profile != self.browser_profile { + for browser in self.browsers.values() { + browser.update(cx, |browser, cx| browser.close(cx)); + } + self.browsers.clear(); + self.browser_subs.clear(); + self.browser_context = browser_profile.clone() + .map(crate::browser::BrowserContext::for_profile).unwrap_or_default(); + self.browser_profile = browser_profile; + } + } + /// Browser tabs are independent instances owned by the current session. fn add_browser_surface( &mut self, @@ -3468,6 +3490,7 @@ impl Shell { if self.active_chat.is_empty() { return; } + self.sync_browser_profile(cx); let key = self.panel_key(cx); let remote = { let state = self.state.read(cx); @@ -12254,24 +12277,7 @@ impl Render for Shell { .clone() .unwrap_or_else(|| self.state.read(cx).gate()); - let browser_profile = { - let state = self.state.read(cx); - crate::browser::profile::BrowserProfile::for_workspace( - &self.data_dir, - state.workspace_scope, - state.auth.as_ref(), - state.local_device_id.as_deref(), - ) - }; - if browser_profile.is_some() && browser_profile != self.browser_profile { - for browser in self.browsers.values() { - browser.update(cx, |browser, cx| browser.close(cx)); - } - self.browsers.clear(); - self.browser_subs.clear(); - self.browser_context = crate::browser::BrowserContext::for_profile(browser_profile.clone().unwrap()); - self.browser_profile = browser_profile; - } + self.sync_browser_profile(cx); let browser_active = matches!(gate, GatePhase::Ready) && !restart_required && matches!(self.route, Route::Chat) From 46ba2fc617718eedb9ecd697cd221af087d23077 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sun, 27 Sep 2026 01:15:42 -0300 Subject: [PATCH 05/16] test(browser): verify persistent storage across app launches and profiles --- .github/workflows/macos.yml | 5 + .github/workflows/ui-tests.yml | 7 +- crates/ui/examples/browser-fixture.rs | 15 +- crates/ui/examples/browser-fixture/storage.rs | 99 ++++++++ crates/ui/src/browser/linux/helper.c | 8 +- crates/ui/src/browser/linux/mod.rs | 76 +++++- crates/ui/src/browser/macos.rs | 14 +- crates/ui/src/browser/mod.rs | 57 ++++- crates/ui/src/browser/profile.rs | 49 ++++ crates/ui/src/shell.rs | 69 +++++- scripts/test-browser-storage.py | 222 ++++++++++++++++++ 11 files changed, 597 insertions(+), 24 deletions(-) create mode 100644 crates/ui/examples/browser-fixture/storage.rs create mode 100644 scripts/test-browser-storage.py diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index 0892abadb9..565985bf18 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -9,6 +9,7 @@ on: - 'crates/**' - 'edge/**' - 'scripts/run-macos-browser-fixture.sh' + - 'scripts/test-browser-storage.py' - 'scripts/ci/run-macos-fixture.sh' - 'dist/macos/Info.plist' - 'apps/ios/**' @@ -22,6 +23,7 @@ on: - 'crates/**' - 'edge/**' - 'scripts/run-macos-browser-fixture.sh' + - 'scripts/test-browser-storage.py' - 'scripts/ci/run-macos-fixture.sh' - 'dist/macos/Info.plist' - 'apps/ios/**' @@ -130,6 +132,9 @@ jobs: run: | scripts/ci/run-macos-fixture.sh target/debug/examples/browser-fixture "$RUNNER_TEMP/browser-captures" test -f "$RUNNER_TEMP/browser-captures/result.txt" + - name: Persistent browser profiles across app launches + timeout-minutes: 5 + run: python3 scripts/test-browser-storage.py --fixture target/debug/examples/browser-fixture - name: Upload browser screenshots if: always() uses: actions/upload-artifact@v4 diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index 199d33e8a9..0b8c5ba21b 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -12,6 +12,7 @@ on: - 'scripts/*cursor-sdk*' - '.github/workflows/cursor-*.yml' - 'scripts/test-linux-browser.sh' + - 'scripts/test-browser-storage.py' - '.github/workflows/ui-tests.yml' push: branches: [main] @@ -25,6 +26,7 @@ on: - 'scripts/*cursor-sdk*' - '.github/workflows/cursor-*.yml' - 'scripts/test-linux-browser.sh' + - 'scripts/test-browser-storage.py' - '.github/workflows/ui-tests.yml' workflow_dispatch: @@ -84,10 +86,13 @@ jobs: libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev libx11-dev libxcb1-dev libx11-xcb-dev libfontconfig1-dev libfreetype-dev libasound2-dev libvulkan-dev pkg-config cmake - libwebkit2gtk-4.1-dev libjson-glib-dev xvfb xdotool openbox + libwebkit2gtk-4.1-dev libjson-glib-dev xvfb xauth xdotool openbox weston ffmpeg imagemagick fonts-noto-cjk - name: Build browser fixture run: cargo build --locked -p zeron-ui --example browser-fixture --features browser-fixture + - name: Persistent browser profiles across app launches + timeout-minutes: 5 + run: xvfb-run -a python3 scripts/test-browser-storage.py --fixture target/debug/examples/browser-fixture - name: X11 and Wayland browser regressions timeout-minutes: 5 env: diff --git a/crates/ui/examples/browser-fixture.rs b/crates/ui/examples/browser-fixture.rs index 94fe48619f..936b00c23c 100644 --- a/crates/ui/examples/browser-fixture.rs +++ b/crates/ui/examples/browser-fixture.rs @@ -1,3 +1,5 @@ +#[path = "browser-fixture/storage.rs"] +mod storage; #[path = "browser-fixture/transcript_links.rs"] mod transcript_links; #[cfg(target_os = "linux")] @@ -130,7 +132,11 @@ fn main() -> anyhow::Result<()> { ); std::fs::create_dir_all(&output)?; let temp = tempfile::tempdir()?; - let data = temp.path().to_path_buf(); + let data = std::env::var_os("ZERON_BROWSER_STORAGE_ROOT") + .map(PathBuf::from).unwrap_or_else(|| temp.path().to_path_buf()); + let storage_root = data.clone(); + let storage_profile = std::env::var("ZERON_BROWSER_STORAGE_PROFILE").unwrap_or_else(|_| "local".into()); + let storage_url = std::env::var("ZERON_BROWSER_STORAGE_URL").ok(); let listener = std::net::TcpListener::bind("127.0.0.1:0")?; let _origin = format!("http://{}", listener.local_addr()?); std::thread::spawn(move || { @@ -178,7 +184,7 @@ fn main() -> anyhow::Result<()> { let mut s = state::AppState::new(); s.connection = zeron_proto::view::ConnectionStatus::Ready; s.workspace_scope = Some(zeron_proto::WorkspaceScope::Local); - s.local_device_id = Some("local".into()); + s.local_device_id = Some(storage_profile.clone()); s.devices = vec![serde_json::from_value(serde_json::json!({"id":"local","name":"This device","platform":std::env::consts::OS,"lastSeenAt":null})).unwrap()]; s.selected_chat = Some("browser-fixture".into()); s.selected_space = Some("project".into()); s.auto_selected = true; s.chats_synced = true; s.spaces_synced = true; @@ -198,6 +204,9 @@ fn main() -> anyhow::Result<()> { cx.spawn(async move |cx| { let run: anyhow::Result<()> = async { pause(cx, 1200).await; + if let Some(url) = &storage_url { + return storage::exercise(window, state.clone(), &storage_root, &storage_profile, url, cx).await; + } if std::env::var_os("ZERON_TRANSCRIPT_LINK_FIXTURE_ONLY").is_some() { return transcript_links::exercise(window, state.clone(), &_origin, &output, cx).await; } @@ -252,7 +261,7 @@ fn main() -> anyhow::Result<()> { second.read_with(cx, |b, _| b.fixture_eval("document.title = document.cookie.includes('browserfixture=shared') ? 'Shared login' : 'Missing cookie'")); let deadline = std::time::Instant::now() + Duration::from_secs(5); while !second.read_with(cx, |b, _| b.page.title == "Shared login") { - anyhow::ensure!(std::time::Instant::now() < deadline, "tabs did not share ephemeral website data"); pause(cx, 50).await; + anyhow::ensure!(std::time::Instant::now() < deadline, "tabs did not share website data"); pause(cx, 50).await; } anyhow::ensure!(first.read_with(cx, |b, _| b.page.title == "Updated title"), "second tab replaced first tab state"); } diff --git a/crates/ui/examples/browser-fixture/storage.rs b/crates/ui/examples/browser-fixture/storage.rs new file mode 100644 index 0000000000..ca043cae6b --- /dev/null +++ b/crates/ui/examples/browser-fixture/storage.rs @@ -0,0 +1,99 @@ +//! Invoked repeatedly by test-browser-storage.py with one stable HTTP origin. +use gpui::{AsyncApp, Entity, WindowHandle}; +use std::{ + path::Path, + time::{Duration, Instant}, +}; +use zeron_ui::{ + browser::{BrowserContext, BrowserSurface}, + shell::Shell, +}; + +async fn loaded(browser: &Entity, cx: &mut AsyncApp) -> anyhow::Result<()> { + let deadline = Instant::now() + Duration::from_secs(30); + loop { + let page = browser.read_with(cx, |browser, _| browser.page.clone()); + anyhow::ensure!( + page.error.is_none(), + "storage page failed: {:?}", + page.error + ); + anyhow::ensure!(!page.title.starts_with("storage:fail:"), "{}", page.title); + if page.title == "storage:pass" { + return Ok(()); + } + anyhow::ensure!( + Instant::now() < deadline, + "storage page timed out: {:?}", + page + ); + super::pause(cx, 50).await; + } +} + +pub async fn exercise( + window: WindowHandle, + state: Entity, + root: &Path, + profile: &str, + url: &str, + cx: &mut AsyncApp, +) -> anyhow::Result<()> { + if url == "cleanup" { + let (tx, rx) = tokio::sync::oneshot::channel(); + cx.update(|_| { + BrowserContext::fixture_remove_profile(root, profile, move |result| { + let _ = tx.send(result); + }) + }); + rx.await?.map_err(anyhow::Error::msg)?; + return Ok(()); + } + anyhow::ensure!( + BrowserContext::fixture_persistence_supported(), + "persistent profiles require macOS 14+ or Linux" + ); + let (id, browser) = window.update(cx, |shell, window, cx| { + shell.fixture_open_browser(Some(url.to_owned()), window, cx) + })?; + loaded(&browser, cx).await?; + window.update(cx, |shell, window, cx| { + shell.fixture_close_browser(id, window, cx) + })?; + drop(browser); + if url.ends_with("/write") { + super::pause(cx, 2200).await; // Wait for the intentionally short-lived cookie. + let url = format!("{}/read", url.strip_suffix("/write").unwrap()); + let (_, browser) = window.update(cx, |shell, window, cx| { + shell.fixture_open_browser(Some(url.clone()), window, cx) + })?; + loaded(&browser, cx).await?; + // Switch with a live page. The shell must close it before opening the + // other identity, then recover the first identity's durable state. + state.update(cx, |state, cx| { + state.local_device_id = Some(format!("{profile}-other")); + cx.notify(); + }); + let empty = format!("{}/empty", url.strip_suffix("/read").unwrap()); + let (_, other) = window.update(cx, |shell, window, cx| { + shell.fixture_open_browser(Some(empty), window, cx) + })?; + anyhow::ensure!( + !browser.read_with(cx, |b, _| b.fixture_native_visible()), + "old profile remained visible" + ); + loaded(&other, cx).await?; + state.update(cx, |state, cx| { + state.local_device_id = Some(profile.to_owned()); + cx.notify(); + }); + let (id, restored) = window.update(cx, |shell, window, cx| { + shell.fixture_open_browser(Some(url.clone()), window, cx) + })?; + loaded(&restored, cx).await?; + window.update(cx, |shell, window, cx| { + shell.fixture_close_browser(id, window, cx) + })?; + } + Ok(()) +} diff --git a/crates/ui/src/browser/linux/helper.c b/crates/ui/src/browser/linux/helper.c index c38826f046..e57fbcfa10 100644 --- a/crates/ui/src/browser/linux/helper.c +++ b/crates/ui/src/browser/linux/helper.c @@ -478,7 +478,7 @@ static void evaluated(GObject *web, GAsyncResult *result, gpointer data) { } static void shutdown_ready(GObject *manager, GAsyncResult *result, gpointer unused) { GError *error = NULL; - GList *cookies = webkit_cookie_manager_get_all_cookies_finish(WEBKIT_COOKIE_MANAGER(manager), result, &error); + GList *cookies = webkit_cookie_manager_get_cookies_finish(WEBKIT_COOKIE_MANAGER(manager), result, &error); g_list_free_full(cookies, (GDestroyNotify)soup_cookie_free); g_clear_error(&error); gtk_main_quit(); @@ -489,8 +489,10 @@ static void begin_shutdown(void) { shutting_down = TRUE; g_hash_table_remove_all(pages); // Let WebKit process the pending cookie operations before leaving its loop. - webkit_cookie_manager_get_all_cookies(webkit_web_context_get_cookie_manager(context), - NULL, shutdown_ready, NULL); + // get_cookies is available on older WebKitGTK 4.1 runtimes too (the + // get_all_cookies API would require WebKitGTK 2.42). + webkit_cookie_manager_get_cookies(webkit_web_context_get_cookie_manager(context), + "http://localhost/", NULL, shutdown_ready, NULL); } static void command(JsonObject *o) { diff --git a/crates/ui/src/browser/linux/mod.rs b/crates/ui/src/browser/linux/mod.rs index 962d0e4682..f463f4b68b 100644 --- a/crates/ui/src/browser/linux/mod.rs +++ b/crates/ui/src/browser/linux/mod.rs @@ -103,13 +103,19 @@ fn helper_path() -> Result { } fn read_startup(stdout: &mut impl Read) -> Result<(), String> { let mut header = [0u8; 9]; - stdout.read_exact(&mut header).map_err(|error| format!("Browser helper could not start: {error}. Check the WebKitGTK 4.1 runtime and display."))?; + stdout.read_exact(&mut header).map_err(|error| { + format!( + "Browser helper could not start: {error}. Check the WebKitGTK 4.1 runtime and display." + ) + })?; let length = u32::from_le_bytes(header[5..9].try_into().unwrap()) as usize; if length > 16384 || header[1..5] != [0; 4] { return Err("Invalid browser startup response".into()); } let mut message = vec![0; length]; - stdout.read_exact(&mut message).map_err(|error| error.to_string())?; + stdout + .read_exact(&mut message) + .map_err(|error| error.to_string())?; match header[0] { b'R' if message.is_empty() => Ok(()), b'E' => Err(String::from_utf8_lossy(&message).into_owned()), @@ -121,15 +127,23 @@ impl BrowserData { pub fn for_profile(profile: super::profile::BrowserProfile) -> Self { // One live helper per profile, shared across all windows in this process. type SharedWorker = Mutex>>; - static PROFILES: std::sync::OnceLock>>> = std::sync::OnceLock::new(); + static PROFILES: std::sync::OnceLock< + Mutex>>, + > = std::sync::OnceLock::new(); let mut profiles = PROFILES.get_or_init(Default::default).lock().unwrap(); profiles.retain(|_, worker| worker.strong_count() > 0); - let current = profiles.get(&profile.root).and_then(Weak::upgrade).unwrap_or_else(|| { - let current = Arc::new(Mutex::new(None)); - profiles.insert(profile.root.clone(), Arc::downgrade(¤t)); - current - }); - Self { profile: Some(profile), current } + let current = profiles + .get(&profile.root) + .and_then(Weak::upgrade) + .unwrap_or_else(|| { + let current = Arc::new(Mutex::new(None)); + profiles.insert(profile.root.clone(), Arc::downgrade(¤t)); + current + }); + Self { + profile: Some(profile), + current, + } } fn worker(&self) -> Result, String> { let mut current = self.current.lock().unwrap(); @@ -747,3 +761,47 @@ impl super::BrowserSurface { )) } } + +#[cfg(test)] +mod storage_tests { + use super::*; + + #[test] + fn startup_surfaces_storage_errors_and_rejects_invalid_packets() { + assert!(read_startup(&mut &b"R\0\0\0\0\0\0\0\0"[..]).is_ok()); + let mut packet = b"E\0\0\0\0".to_vec(); + packet.extend_from_slice(&6u32.to_le_bytes()); + packet.extend_from_slice(b"locked"); + assert_eq!(read_startup(&mut packet.as_slice()), Err("locked".into())); + assert!(read_startup(&mut &b"R\0"[..]).is_err()); + assert!(read_startup(&mut &b"R\0\0\0\0\xff\xff\xff\xff"[..]).is_err()); + } + + #[test] + #[ignore = "requires a real WebKitGTK runtime and display (xvfb-run)"] + fn profile_contexts_share_a_live_helper_and_release_its_lock_on_shutdown() { + let root = tempfile::tempdir().unwrap(); + let profile = super::super::profile::BrowserProfile::for_workspace( + root.path(), + Some(zeron_proto::WorkspaceScope::Local), + None, + Some("device"), + ) + .unwrap(); + let first = BrowserData::for_profile(profile.clone()); + let second = BrowserData::for_profile(profile.clone()); + let pid = first.worker().unwrap().child.lock().unwrap().id(); + // No page owns the worker. The profile keeps it alive between tabs. + assert_eq!(pid, first.worker().unwrap().child.lock().unwrap().id()); + assert_eq!(pid, second.worker().unwrap().child.lock().unwrap().id()); + drop(first); + assert_eq!(pid, second.worker().unwrap().child.lock().unwrap().id()); + drop(second); + // Immediate reopening must acquire the released process lock. + let reopened = BrowserData::for_profile(profile); + let worker = reopened.worker().unwrap(); + assert_ne!(pid, worker.child.lock().unwrap().id()); + drop(worker); + drop(reopened); + } +} diff --git a/crates/ui/src/browser/macos.rs b/crates/ui/src/browser/macos.rs index 7d601ac7cd..ebd9f2e835 100644 --- a/crates/ui/src/browser/macos.rs +++ b/crates/ui/src/browser/macos.rs @@ -45,7 +45,10 @@ impl BrowserData { return Self(store); } let root = profile.root.clone(); - let store = Rc::new(RefCell::new(BrowserStore { profile: Some(profile), ..Default::default() })); + let store = Rc::new(RefCell::new(BrowserStore { + profile: Some(profile), + ..Default::default() + })); profiles.insert(root, Rc::downgrade(&store)); Self(store) }) @@ -59,7 +62,8 @@ impl BrowserData { data.store = Some(unsafe { match &data.profile { Some(profile) if persistent_profiles_supported() => { - let identifier = objc2_foundation::NSUUID::from_bytes(profile.data_store_identifier()); + let identifier = + objc2_foundation::NSUUID::from_bytes(profile.data_store_identifier()); objc2_web_kit::WKWebsiteDataStore::dataStoreForIdentifier(&identifier, mtm) } _ => objc2_web_kit::WKWebsiteDataStore::nonPersistentDataStore(mtm), @@ -104,8 +108,10 @@ impl BrowserData { /// macOS 12/13 have only a single default persistent store. Do not silently /// share it across accounts; retain isolated temporary stores on those systems. -fn persistent_profiles_supported() -> bool { - unsafe { msg_send![class!(WKWebsiteDataStore), respondsToSelector: sel!(dataStoreForIdentifier:)] } +pub(super) fn persistent_profiles_supported() -> bool { + unsafe { + msg_send![class!(WKWebsiteDataStore), respondsToSelector: sel!(dataStoreForIdentifier:)] + } } impl BrowserData { diff --git a/crates/ui/src/browser/mod.rs b/crates/ui/src/browser/mod.rs index fa7c1ae6b4..10b083a76b 100644 --- a/crates/ui/src/browser/mod.rs +++ b/crates/ui/src/browser/mod.rs @@ -79,9 +79,16 @@ pub struct BrowserContext { impl BrowserContext { pub(crate) fn for_profile(profile: profile::BrowserProfile) -> Self { #[cfg(any(target_os = "macos", target_os = "linux"))] - { Self { data: native::BrowserData::for_profile(profile) } } + { + Self { + data: native::BrowserData::for_profile(profile), + } + } #[cfg(not(any(target_os = "macos", target_os = "linux")))] - { let _ = profile; Self::default() } + { + let _ = profile; + Self::default() + } } } @@ -569,6 +576,52 @@ impl BrowserSurface { } } +#[cfg(feature = "browser-fixture")] +impl BrowserContext { + pub fn fixture_persistence_supported() -> bool { + #[cfg(target_os = "macos")] + { + native::persistent_profiles_supported() + } + #[cfg(not(target_os = "macos"))] + { + cfg!(target_os = "linux") + } + } + + /// Named WK stores live outside the fixture's temporary data directory. + /// Remove only the store derived from this fixture installation and identity. + pub fn fixture_remove_profile( + data_dir: &std::path::Path, + device: &str, + completion: impl FnOnce(Result<(), String>) + Send + 'static, + ) { + #[cfg(target_os = "macos")] + { + use wry::WebViewExtMacOS; + if native::persistent_profiles_supported() { + let profile = profile::BrowserProfile::for_workspace( + data_dir, + Some(zeron_proto::WorkspaceScope::Local), + None, + Some(device), + ) + .unwrap(); + wry::WebView::remove_data_store(&profile.data_store_identifier(), move |result| { + completion(result.map_err(|error| error.to_string())); + }); + } else { + completion(Ok(())); + } + } + #[cfg(not(target_os = "macos"))] + { + let _ = (data_dir, device); + completion(Ok(())); + } + } +} + #[cfg(feature = "browser-fixture")] impl BrowserSurface { pub fn fixture_history(&mut self, forward: bool) { diff --git a/crates/ui/src/browser/profile.rs b/crates/ui/src/browser/profile.rs index aec77914d5..387ce992ba 100644 --- a/crates/ui/src/browser/profile.rs +++ b/crates/ui/src/browser/profile.rs @@ -97,6 +97,55 @@ mod tests { ); } + #[test] + fn authenticated_profiles_follow_user_and_organization_not_device() { + let root = tempfile::tempdir().unwrap(); + let make = |user: &str, org: Option<&str>, device: &str, scope| { + let auth = AuthState::SignedIn { + user: zeron_proto::UserProfile { + id: user.into(), + email: "fixture@example.test".into(), + name: None, + }, + org_id: org.map(str::to_owned), + }; + BrowserProfile::for_workspace(root.path(), Some(scope), Some(&auth), Some(device)) + .unwrap() + }; + let a = make("alice", Some("one"), "device-a", WorkspaceScope::Synced); + assert_eq!( + a, + make("alice", Some("one"), "device-b", WorkspaceScope::Synced) + ); + assert_ne!( + a, + make("bob", Some("one"), "device-a", WorkspaceScope::Synced) + ); + assert_ne!( + a, + make("alice", Some("two"), "device-a", WorkspaceScope::Synced) + ); + assert_ne!(a, make("alice", None, "device-a", WorkspaceScope::Synced)); + assert_ne!( + a, + make( + "alice", + Some("one"), + "device-a", + WorkspaceScope::Development + ) + ); + assert!( + BrowserProfile::for_workspace( + root.path(), + Some(WorkspaceScope::Synced), + Some(&AuthState::SignedOut), + Some("device-a") + ) + .is_none() + ); + } + #[test] fn unresolved_identity_does_not_select_a_shared_persistent_profile() { let root = Path::new("/unused"); diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 3e1170b7b4..23ad134558 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -3474,8 +3474,10 @@ impl Shell { } self.browsers.clear(); self.browser_subs.clear(); - self.browser_context = browser_profile.clone() - .map(crate::browser::BrowserContext::for_profile).unwrap_or_default(); + self.browser_context = browser_profile + .clone() + .map(crate::browser::BrowserContext::for_profile) + .unwrap_or_default(); self.browser_profile = browser_profile; } } @@ -15594,6 +15596,69 @@ mod exit_regressions { } } + #[gpui::test] + fn browser_profile_changes_clear_tabs_and_reselect_the_stable_identity( + cx: &mut TestAppContext, + ) { + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(Theme::default()); + crate::app_menus::init(cx); + }); + let window = cx.add_window(|_, cx| { + let state = cx.new(|_| AppState::new()); + Shell::new( + state, + EngineBootConfig { + data_dir: dir.path().into(), + ipc_port: 0, + edge_url: "http://127.0.0.1:1".into(), + edge_token: None, + org_id: None, + workos_client_id: None, + default_harness: zeron_proto::HarnessId::Mock, + }, + cx, + ) + }); + window + .update(cx, |shell, window, cx| { + shell.active_chat = "session".into(); + shell.state.update(cx, |state, _| { + state.workspace_scope = Some(zeron_proto::WorkspaceScope::Local); + state.local_device_id = Some("device-a".into()); + }); + // Selecting the identity happens before navigation, without requiring a render. + shell.add_browser_surface(None, window, cx); + let original = shell.browser_profile.clone().unwrap(); + shell.active_chat = "another-chat".into(); + shell.add_browser_surface(None, window, cx); + assert_eq!(shell.browsers.len(), 2); + assert_eq!(shell.browser_profile.as_ref(), Some(&original)); + shell.state.update(cx, |state, _| { + state.local_device_id = Some("device-b".into()) + }); + shell.sync_browser_profile(cx); + assert!(shell.browsers.is_empty()); + assert!(shell.browser_subs.is_empty()); + assert_ne!(shell.browser_profile.as_ref(), Some(&original)); + shell.state.update(cx, |state, _| { + state.local_device_id = Some("device-a".into()) + }); + shell.sync_browser_profile(cx); + assert_eq!(shell.browser_profile.as_ref(), Some(&original)); + shell.add_browser_surface(None, window, cx); + shell + .state + .update(cx, |state, _| state.workspace_scope = None); + shell.sync_browser_profile(cx); + assert!(shell.browser_profile.is_none()); + assert!(shell.browsers.is_empty()); + }) + .unwrap(); + } + #[gpui::test] fn browser_tabs_keep_session_ownership_and_release_on_close(cx: &mut TestAppContext) { let dir = tempfile::tempdir().unwrap(); diff --git a/scripts/test-browser-storage.py b/scripts/test-browser-storage.py new file mode 100644 index 0000000000..979ad4b019 --- /dev/null +++ b/scripts/test-browser-storage.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +"""Exercise real browser storage with synthetic data across independent processes. + +Linux: xvfb-run -a python3 scripts/test-browser-storage.py --helper /path/to/zeron-webkit +Native shell (Linux/macOS): python3 scripts/test-browser-storage.py --fixture /path/to/browser-fixture +""" +import argparse +import http.server +import json +import os +from pathlib import Path +import queue +import struct +import subprocess +import tempfile +import threading +import time +from urllib.parse import urlparse + + +PAGE = r"""Storage fixture""" + + +class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + mode = urlparse(self.path).path + self.send_response(200) + self.send_header('Cache-Control', 'no-store') + if mode == '/cookies': + body = json.dumps(self.headers.get('Cookie', '')).encode() + self.send_header('Content-Type', 'application/json') + else: + body = PAGE.encode() + self.send_header('Content-Type', 'text/html') + if mode in ('/write', '/delete'): + age = 3600 if mode == '/write' else 0 + self.send_header('Set-Cookie', f'httpOnlyToken=fixture; Path=/; HttpOnly; SameSite=Lax; Max-Age={age}') + self.send_header('Content-Length', str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *_): + pass + + +class Helper: + def __init__(self, binary, profile, expect_error=False): + self.process = subprocess.Popen([binary, '--profile', str(profile)], stdin=subprocess.PIPE, + stdout=subprocess.PIPE) + self.events = queue.Queue() + self.thread = threading.Thread(target=self.receive, daemon=True) + self.thread.start() + kind, _, data = self.events.get(timeout=15) + if expect_error: + assert kind == b'E', (kind, data) + assert self.process.wait(timeout=5) != 0 + self.process.stdin.close() + self.process.stdout.close() + return + if kind != b'R': + self.process.kill() + self.process.wait() + raise AssertionError(('helper startup failed', kind, data)) + + def receive(self): + def read(length): + data = bytearray() + while len(data) < length: + part = self.process.stdout.read(length - len(data)) + if not part: + raise EOFError() + data.extend(part) + return bytes(data) + try: + while True: + kind, page, length = struct.unpack(' Date: Sun, 27 Sep 2026 01:16:43 -0300 Subject: [PATCH 06/16] docs(browser): document persistent profiles and session lifecycle --- docs/reference/browser-profiles.md | 127 +++++++++++++++++++++ docs/reference/linux-browser.md | 2 +- docs/research/diri-sidebar-browser-plan.md | 12 +- 3 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 docs/reference/browser-profiles.md diff --git a/docs/reference/browser-profiles.md b/docs/reference/browser-profiles.md new file mode 100644 index 0000000000..976f33eff8 --- /dev/null +++ b/docs/reference/browser-profiles.md @@ -0,0 +1,127 @@ +# Browser profiles and login persistence + +The embedded browser keeps persistent website data on Linux and macOS 14 or +later. Closing tabs or restarting Zeron does not deliberately clear persistent +cookies, localStorage, or IndexedDB. A website can still expire/revoke a login, +and session-only cookies are not converted into persistent cookies. + +| Action | Linux and macOS 14+ | macOS 12/13 | +| --- | --- | --- | +| Switch tabs or hide the panel | Keeps website data | Keeps website data | +| Close all browser tabs, then reopen | Reuses the profile | Reuses its in-memory store while the profile context lives | +| Restart Zeron | Reopens persistent data | Loses temporary data | +| Switch Zeron profile | Opens a separate store; returning recovers saved data | Opens a separate temporary store | + +Windows opens links in the default external browser. Its profile and cookie +settings control persistence; Zeron does not embed WebView2. + +## Identity and storage + +Tabs and windows in one Zeron process share the same browser context for the +same profile. Chat, project, repository, and navigation changes do not choose a +new profile. Local workspace identity uses the local device ID. Signed-in +identity uses workspace scope, user ID, and organization ID. Local, synced, and +development scopes stay separate. Signing out/unresolving the identity closes +the old tabs without deleting their saved website data. + +Website data stays on the UI device, including when viewing a remote engine's +chat. Browser data is not part of Zeron's workspace synchronization. + +On Linux, storage lives under: + +```text +{ZERON_DATA_DIR or ~/.zeron}/browser/{profile-hash}/ + cookies.sqlite # WebKitGTK's persistent cookie database + data/ # localStorage, IndexedDB and other website data + cache/ # disposable WebKit cache + profile.lock # prevents a second helper from using the same profile +``` + +The profile hash is derived from identity, not a raw user-supplied path. New +directories/files are private to the OS user. The helper executable remains in +`$XDG_CACHE_HOME/zeron/browser` (or `~/.cache/zeron/browser`); deleting that +executable cache does not clear logins. + +The Linux helper stays alive between tabs while its profile context lives. On +normal teardown the parent closes its command pipe, and the helper destroys +pages and waits for pending cookie operations before leaving its event loop. +The parent allows three seconds before forced termination. Another process +opening the same profile gets an explicit error instead of a concurrent writer. +Startup/storage failures are shown on the browser page; they do not silently +select temporary storage. + +On macOS 14+, WebKit manages storage in the app's website-data location. Zeron +uses a named `WKWebsiteDataStore` with a stable UUID derived from the canonical +Zeron data directory and profile identity. The app bundle identifier and data +directory must stay stable to reuse the same store. Separate installations and +test data directories select separate stores. Copying/removing Zeron's data +directory does not copy/remove the WebKit-managed stores. + +macOS 12/13 only exposes one default persistent WebKit store. Zeron keeps its +isolated temporary stores on these versions and displays a notice explaining +that keeping logins requires macOS 14+ or the default external browser. It does +not put unrelated accounts in the single default store. + +References: [WebKit profile APIs](https://webkit.org/blog/14423/building-profiles-with-new-webkit-api/), +[WebKitGTK cookie persistence](https://webkitgtk.org/reference/webkit2gtk/stable/method.CookieManager.set_persistent_storage.html). + +## Validation + +Run the browser unit and shell regression tests: + +```sh +cargo test --locked -p zeron-ui --lib browser --features browser-fixture -- --test-threads=1 +``` + +On Linux, exercise the real helper lifecycle and shared process registry: + +```sh +xvfb-run -a cargo test --locked -p zeron-ui --lib profile_contexts_share_a_live_helper -- --ignored --test-threads=1 +``` + +Build the existing native shell fixture: + +```sh +cargo build --locked -p zeron-ui --example browser-fixture --features browser-fixture +``` + +Then, in a logged-in macOS 14+ desktop session: + +```sh +python3 scripts/test-browser-storage.py --fixture target/debug/examples/browser-fixture +``` + +Or on Linux with an X display (Xvfb is sufficient): + +```sh +xvfb-run -a python3 scripts/test-browser-storage.py --fixture target/debug/examples/browser-fixture +``` + +The driver runs a loopback-only site and independent application launches with +temporary profiles. It checks persistent and HttpOnly cookies, cookie expiry +and deletion, localStorage, IndexedDB, closing/reopening the last tab, switching +profiles with live pages, and returning to the first profile. macOS runs use +the release Info.plist through the existing fixture bundle wrapper, and remove +their named WebKit stores afterward. + +For a faster Linux storage check without building the UI: + +```sh +cc -std=c11 -O2 -Wall -Wextra -Wno-unused-parameter \ + crates/ui/src/browser/linux/helper.c -o /tmp/zeron-webkit-storage \ + $(pkg-config --cflags --libs webkit2gtk-4.1 json-glib-1.0) +xvfb-run -a python3 scripts/test-browser-storage.py --helper /tmp/zeron-webkit-storage +``` + +This additionally checks profile locking, independent concurrent profiles, +invalid storage paths, and cookie file permissions. It does not exercise the +Rust shell. UI CI runs the native fixture on Linux and macOS. + +The tests use synthetic tokens, not personal accounts. A manual GitHub/mail +check should sign in, close all tabs, restart Zeron, revisit the site, switch +Zeron profile, and return. Website-specific OAuth, MFA, expiry, or embedded +browser restrictions remain separate from storage persistence. + +This change does not add password autofill, tab restoration, login import from +another browser, or a browsing-data deletion UI. Sessions already lost under +the old temporary configuration cannot be recovered; users must sign in again. diff --git a/docs/reference/linux-browser.md b/docs/reference/linux-browser.md index 0aa4a7034d..13fe1d48f0 100644 --- a/docs/reference/linux-browser.md +++ b/docs/reference/linux-browser.md @@ -14,7 +14,7 @@ On Fedora: sudo dnf install webkit2gtk4.1 json-glib ``` -Zeron starts its browser helper when a page is first opened. The main application does not link to GTK or WebKit, so other app features remain available if the browser runtime is missing. WebKit runs in a separate process and uses an ephemeral website-data context shared by the open tabs. +Zeron starts its browser helper when a page is first opened. The main application does not link to GTK or WebKit, so other app features remain available if the browser runtime is missing. WebKit runs in a separate process and uses a persistent website-data context shared by tabs and windows in the same Zeron profile. Persistent cookies, localStorage and IndexedDB survive closing tabs and restarting Zeron. See [browser profiles and login persistence](browser-profiles.md) for storage locations, profile isolation, and regression tests. The helper sends live offscreen frames to GPUI, which draws the page alongside the rest of the app. Both X11 and Wayland use this path, including clipping, sidebar transitions, tooltips, and frosted overlays. It uses CPU-addressable frames rather than embedding a separate native browser window. Animated pages therefore incur frame-copy and texture-upload work. diff --git a/docs/research/diri-sidebar-browser-plan.md b/docs/research/diri-sidebar-browser-plan.md index 44fbcd8cb7..984a277bae 100644 --- a/docs/research/diri-sidebar-browser-plan.md +++ b/docs/research/diri-sidebar-browser-plan.md @@ -8,7 +8,17 @@ Diri reference: [`c564784199cfbeabd29f011bac28467a2b12fccf`](https://github.com/ V1 uses the system webview and native compositing; CEF remains a possible later backend. -## Implementation +## Current behavior update (2026-09-27) + +The v1 design below is historical. The embedded browser now supports Linux as +well as macOS. Website data is persistent per Zeron profile on Linux and macOS +14+, and remains temporary with a visible notice on macOS 12/13. Changing +profiles closes the old views without deleting persistent data; returning to a +profile reopens its store. Windows uses the external browser. Tab restoration +is still outside this feature. See [browser profiles](../reference/browser-profiles.md) +and [Linux browser](../reference/linux-browser.md) for the current behavior. + +## Original implementation The implementation uses `browser/{mod,model,view,macos}.rs`, with Wry restricted to macOS dependencies. The real shell owns session-specific browser entities; From e1c4ffe0d41b791bdbebd0136b52253137dafbf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sun, 27 Sep 2026 01:36:35 -0300 Subject: [PATCH 07/16] fix(browser-linux): initialize WebKit without blocking the UI --- crates/ui/src/browser/linux/mod.rs | 100 ++++++- crates/ui/src/browser/linux/startup_tests.rs | 277 +++++++++++++++++++ crates/ui/src/browser/mod.rs | 29 +- crates/ui/src/browser/view.rs | 8 + docs/reference/browser-profiles.md | 13 +- 5 files changed, 409 insertions(+), 18 deletions(-) create mode 100644 crates/ui/src/browser/linux/startup_tests.rs diff --git a/crates/ui/src/browser/linux/mod.rs b/crates/ui/src/browser/linux/mod.rs index f463f4b68b..787fe814f0 100644 --- a/crates/ui/src/browser/linux/mod.rs +++ b/crates/ui/src/browser/linux/mod.rs @@ -28,6 +28,8 @@ pub enum NativeEvent { pub struct BrowserData { current: Arc>>>, profile: Option, + #[cfg(test)] + helper_override: Option, } struct Worker { @@ -70,18 +72,28 @@ struct Route { } fn helper_path() -> Result { - use sha2::{Digest, Sha256}; - const HELPER: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/zeron-webkit")); - let hash = format!("{:x}", Sha256::digest(HELPER)); let root = std::env::var_os("XDG_CACHE_HOME") .map(std::path::PathBuf::from) .or_else(|| std::env::var_os("HOME").map(|p| std::path::PathBuf::from(p).join(".cache"))) .ok_or("Could not locate the browser cache directory")? .join("zeron/browser"); - std::fs::create_dir_all(&root).map_err(|e| e.to_string())?; + extract_helper(&root) +} + +fn extract_helper(root: &std::path::Path) -> Result { + use sha2::{Digest, Sha256}; + const HELPER: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/zeron-webkit")); + let hash = format!("{:x}", Sha256::digest(HELPER)); + std::fs::create_dir_all(root).map_err(|e| e.to_string())?; let path = root.join(format!("webkit-{hash}")); if std::fs::read(&path).ok().as_deref() != Some(HELPER) { - let temp = root.join(format!(".webkit-{}", std::process::id())); + // Different profiles can now initialize concurrently. Each extraction + // needs its own staging file, even within the same application process. + let temp = root.join(format!( + ".webkit-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); let result = (|| -> std::io::Result<()> { let mut f = std::fs::OpenOptions::new() .write(true) @@ -101,6 +113,7 @@ fn helper_path() -> Result { .map_err(|e| e.to_string())?; Ok(path) } + fn read_startup(stdout: &mut impl Read) -> Result<(), String> { let mut header = [0u8; 9]; stdout.read_exact(&mut header).map_err(|error| { @@ -143,8 +156,11 @@ impl BrowserData { Self { profile: Some(profile), current, + #[cfg(test)] + helper_override: None, } } + /// Blocking initialization, called only by the dedicated startup thread. fn worker(&self) -> Result, String> { let mut current = self.current.lock().unwrap(); if let Some(worker) = current.as_ref() { @@ -159,7 +175,15 @@ impl BrowserData { return Ok(worker.clone()); } } - let mut command = Command::new(helper_path()?); + #[cfg(test)] + let executable = self + .helper_override + .clone() + .map(Ok) + .unwrap_or_else(helper_path)?; + #[cfg(not(test))] + let executable = helper_path()?; + let mut command = Command::new(executable); if let Some(profile) = &self.profile { command.arg("--profile").arg(&profile.root); } @@ -285,11 +309,26 @@ pub struct NativePage { pub pressed: std::cell::Cell>, } impl NativePage { - pub fn new( - _: &gpui::Window, - data: &BrowserData, + /// Extract, spawn and handshake entirely off the UI thread. Concurrent + /// tabs wait for their shared profile's worker here, never in navigation. + pub fn start( + data: BrowserData, tx: Sender, - ) -> Result { + ) -> Result>, String> { + let (ready, result) = tokio::sync::oneshot::channel(); + std::thread::Builder::new() + .name("browser-startup".into()) + .spawn(move || { + let page = Self::new(&data, tx); + // If the tab was closed, send returns the page and drops it on + // this thread. The UI never waits for an abandoned startup. + let _ = ready.send(page); + }) + .map_err(|error| format!("Could not start browser initialization: {error}"))?; + Ok(result) + } + + fn new(data: &BrowserData, tx: Sender) -> Result { let worker = data.worker()?; let id = worker.next_id.fetch_add(1, Ordering::Relaxed); let route = Arc::new(Route { @@ -393,6 +432,44 @@ impl Drop for NativePage { } impl super::BrowserSurface { + pub(super) fn start_linux_page( + &mut self, + window: &mut gpui::Window, + cx: &mut gpui::Context, + ) -> Result<(), String> { + if self.native_startup.is_some() { + // navigate() has already updated page.url. Use that latest address + // when startup completes instead of starting a second helper. + return Ok(()); + } + let ready = NativePage::start(self.context.data.clone(), self.native_tx.clone())?; + self.native_startup = Some(cx.spawn_in(window, async move |this, cx| { + let result = ready + .await + .unwrap_or_else(|_| Err("Browser initialization stopped unexpectedly".into())); + let _ = this.update_in(cx, |this, _, cx| { + this.native_startup = None; + let result = result.and_then(|mut native| { + native.present(this.presentation); + let result = this + .page + .url + .as_deref() + .map_or(Ok(()), |url| native.load(url)); + this.native = Some(native); + result + }); + this.page.loading = result.is_ok(); + if let Err(error) = result { + this.page.error = Some(format!("Could not open this page: {error}")); + } + cx.emit(super::BrowserEvent::Changed); + cx.notify(); + }); + })); + Ok(()) + } + pub(super) fn on_native_event( &mut self, event: NativeEvent, @@ -805,3 +882,6 @@ mod storage_tests { drop(reopened); } } + +#[cfg(test)] +mod startup_tests; diff --git a/crates/ui/src/browser/linux/startup_tests.rs b/crates/ui/src/browser/linux/startup_tests.rs new file mode 100644 index 0000000000..790115badf --- /dev/null +++ b/crates/ui/src/browser/linux/startup_tests.rs @@ -0,0 +1,277 @@ +//! A gated helper exercises the actual navigation path without a GTK display. +use super::*; +use crate::browser::{BrowserContext, BrowserSurface}; +use gpui::TestAppContext; +use std::{ + path::Path, + time::{Duration, Instant}, +}; + +struct DelayedHelper(tempfile::TempDir); + +impl DelayedHelper { + fn new() -> Self { + let root = tempfile::tempdir().unwrap(); + let executable = root.path().join("helper.py"); + std::fs::write( + &executable, + r#"#!/usr/bin/env python3 +import json, pathlib, struct, sys, time +root = pathlib.Path(__file__).parent +def packet(kind, page, value): + data = value.encode() + sys.stdout.buffer.write(struct.pack(' &Path { + self.0.path() + } + + fn context(&self) -> BrowserContext { + BrowserContext { + data: BrowserData { + helper_override: Some(self.path().join("helper.py")), + ..Default::default() + }, + } + } + + fn release(&self) { + std::fs::write(self.path().join("release"), "").unwrap(); + } + + fn loaded_urls(&self) -> Vec { + std::fs::read_to_string(self.path().join("commands")) + .unwrap() + .lines() + .filter_map(|line| { + let command: Value = serde_json::from_str(line).unwrap(); + (command["cmd"] == "load").then(|| command["url"].as_str().unwrap().to_owned()) + }) + .collect() + } +} + +fn init(cx: &mut TestAppContext) { + // These regressions intentionally use real process I/O and external wakes. + cx.background_executor.allow_parking(); + cx.update(|cx| { + gpui_base::init(cx); + crate::composer::init(cx, Default::default()); + cx.set_global(crate::theme::Theme::default()); + }); +} + +fn until(cx: &mut TestAppContext, mut condition: impl FnMut(&mut TestAppContext) -> bool) { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + cx.run_until_parked(); + if condition(cx) { + return; + } + assert!(Instant::now() < deadline, "helper/UI completion timed out"); + std::thread::sleep(Duration::from_millis(5)); + } +} + +#[gpui::test] +fn delayed_startup_keeps_both_windows_responsive_and_loads_latest_address(cx: &mut TestAppContext) { + init(cx); + let helper = DelayedHelper::new(); + let context = helper.context(); + let first = cx.add_window(|window, cx| BrowserSurface::new(context.clone(), false, window, cx)); + let second = cx.add_window(|window, cx| BrowserSurface::new(context, false, window, cx)); + let before = Instant::now(); + first + .update(cx, |browser, window, cx| { + browser.navigate("https://first.test/", window, cx) + }) + .unwrap(); + assert!( + before.elapsed() < Duration::from_secs(1), + "navigation blocked on helper startup" + ); + until(cx, |_| helper.path().join("started").exists()); + + // The helper is still gated and holds the shared profile's worker lock. + // A second window must not wait for that lock on the UI thread either. + let before = Instant::now(); + second + .update(cx, |browser, window, cx| { + browser.navigate("https://second.test/", window, cx) + }) + .unwrap(); + first + .update(cx, |browser, window, cx| { + assert!(browser.native.is_none()); + assert!(browser.native_startup.is_some()); + assert!(browser.page.loading); + browser.navigate("https://latest.test/", window, cx); + }) + .unwrap(); + cx.run_until_parked(); + assert!( + before.elapsed() < Duration::from_secs(1), + "another window blocked during startup" + ); + + helper.release(); + until(cx, |cx| { + first + .update(cx, |b, _, _| b.page.title == "Loaded") + .unwrap() + && second + .update(cx, |b, _, _| b.page.title == "Loaded") + .unwrap() + }); + let mut urls = helper.loaded_urls(); + urls.sort(); + assert_eq!(urls, ["https://latest.test/", "https://second.test/"]); + first + .update(cx, |b, _, _| assert!(b.native_startup.is_none())) + .unwrap(); +} + +#[gpui::test] +fn delayed_startup_error_is_delivered_to_the_page_and_can_be_retried(cx: &mut TestAppContext) { + init(cx); + let helper = DelayedHelper::new(); + std::fs::write(helper.path().join("fail"), "").unwrap(); + let browser = + cx.add_window(|window, cx| BrowserSurface::new(helper.context(), false, window, cx)); + browser + .update(cx, |b, w, cx| b.navigate("https://retry.test/", w, cx)) + .unwrap(); + until(cx, |_| helper.path().join("started").exists()); + browser + .update(cx, |b, _, _| { + assert!(b.page.loading); + assert!(b.page.error.is_none()); + }) + .unwrap(); + helper.release(); + until(cx, |cx| { + browser + .update(cx, |b, _, _| b.page.error.is_some()) + .unwrap() + }); + browser + .update(cx, |b, _, _| { + assert!(!b.page.loading); + assert!(b.native.is_none()); + assert!(b.native_startup.is_none()); + assert!( + b.page + .error + .as_deref() + .unwrap() + .contains("Synthetic storage initialization failure") + ); + }) + .unwrap(); + std::fs::remove_file(helper.path().join("fail")).unwrap(); + browser + .update(cx, |b, w, cx| b.navigate("https://retry.test/", w, cx)) + .unwrap(); + until(cx, |cx| { + browser + .update(cx, |b, _, _| { + b.page.title == "Loaded" && b.page.error.is_none() + }) + .unwrap() + }); +} + +#[gpui::test] +fn closing_during_startup_cancels_attachment_without_blocking(cx: &mut TestAppContext) { + init(cx); + let helper = DelayedHelper::new(); + let browser = + cx.add_window(|window, cx| BrowserSurface::new(helper.context(), false, window, cx)); + browser + .update(cx, |b, w, cx| b.navigate("https://closed.test/", w, cx)) + .unwrap(); + until(cx, |_| helper.path().join("started").exists()); + let before = Instant::now(); + browser.update(cx, |b, _, cx| b.close(cx)).unwrap(); + cx.run_until_parked(); + assert!( + before.elapsed() < Duration::from_secs(1), + "closing waited for startup" + ); + helper.release(); + until(cx, |_| helper.path().join("closed").exists()); + browser + .update(cx, |b, _, _| { + assert!(b.native.is_none(), "late completion reopened a closed tab"); + assert!(b.native_startup.is_none()); + }) + .unwrap(); + browser + .update(cx, |b, w, cx| b.navigate("https://reopened.test/", w, cx)) + .unwrap(); + until(cx, |cx| { + browser + .update(cx, |b, _, _| b.page.title == "Loaded") + .unwrap() + }); + assert_eq!(helper.loaded_urls(), ["https://reopened.test/"]); +} + +#[test] +fn concurrent_profiles_can_extract_the_helper_into_a_cold_cache() { + let root = tempfile::tempdir().unwrap(); + let barrier = std::sync::Barrier::new(8); + let paths = std::thread::scope(|scope| { + let jobs: Vec<_> = (0..8) + .map(|_| { + scope.spawn(|| { + barrier.wait(); + extract_helper(root.path()).unwrap() + }) + }) + .collect(); + jobs.into_iter() + .map(|job| job.join().unwrap()) + .collect::>() + }); + assert!(paths.iter().all(|path| path == &paths[0])); + assert_eq!( + std::fs::read(&paths[0]).unwrap(), + include_bytes!(concat!(env!("OUT_DIR"), "/zeron-webkit")) + ); + assert_eq!( + std::fs::read_dir(root.path()).unwrap().count(), + 1, + "staging files were left behind" + ); +} diff --git a/crates/ui/src/browser/mod.rs b/crates/ui/src/browser/mod.rs index 10b083a76b..9a2a4457c0 100644 --- a/crates/ui/src/browser/mod.rs +++ b/crates/ui/src/browser/mod.rs @@ -115,6 +115,8 @@ pub struct BrowserSurface { _input_sub: Subscription, #[cfg(any(target_os = "macos", target_os = "linux"))] native: Option, + #[cfg(target_os = "linux")] + native_startup: Option>, #[cfg(any(target_os = "macos", target_os = "linux"))] native_tx: tokio::sync::mpsc::Sender, #[cfg(any(target_os = "macos", target_os = "linux"))] @@ -193,6 +195,8 @@ impl BrowserSurface { _input_sub: input_sub, #[cfg(any(target_os = "macos", target_os = "linux"))] native: None, + #[cfg(target_os = "linux")] + native_startup: None, #[cfg(any(target_os = "macos", target_os = "linux"))] native_tx, #[cfg(any(target_os = "macos", target_os = "linux"))] @@ -359,12 +363,19 @@ impl BrowserSurface { let result = if let Some(native) = &self.native { native.load(&url) } else { - native::NativePage::new(window, &self.context.data, self.native_tx.clone()) - .map(|mut native| { - native.present(self.presentation); - self.native = Some(native); - }) - .and_then(|_| self.native.as_ref().unwrap().load(&url)) + #[cfg(target_os = "macos")] + { + native::NativePage::new(window, &self.context.data, self.native_tx.clone()) + .map(|mut native| { + native.present(self.presentation); + self.native = Some(native); + }) + .and_then(|_| self.native.as_ref().unwrap().load(&url)) + } + #[cfg(target_os = "linux")] + { + self.start_linux_page(window, cx) + } }; self.page.loading = result.is_ok(); if let Err(error) = result { @@ -433,6 +444,12 @@ impl BrowserSurface { pub fn close(&mut self, cx: &mut Context) { self.set_presentation(Presentation::Hidden, cx); self.clear_favicon(cx); + #[cfg(target_os = "linux")] + { + // Cancels delivery; any helper still starting finishes/cleans up on + // its startup thread and cannot reattach to this closed tab. + self.native_startup = None; + } #[cfg(any(target_os = "macos", target_os = "linux"))] { if let Some(native) = &mut self.native { diff --git a/crates/ui/src/browser/view.rs b/crates/ui/src/browser/view.rs index aafade02fd..30b6d79378 100644 --- a/crates/ui/src/browser/view.rs +++ b/crates/ui/src/browser/view.rs @@ -286,8 +286,14 @@ impl BrowserSurface { } let external = !cfg!(any(target_os = "macos", target_os = "linux")); let has_error = self.page.error.is_some(); + #[cfg(target_os = "linux")] + let starting = self.native_startup.is_some(); + #[cfg(not(target_os = "linux"))] + let starting = false; let title = if has_error { "Couldn’t load this page" + } else if starting { + "Starting browser…" } else if external && self.page.url.is_some() { "Opened in your browser" } else { @@ -295,6 +301,8 @@ impl BrowserSurface { }; let description = if let Some(error) = &self.page.error { error.clone() + } else if starting { + "You can keep working while your page opens.".into() } else if external { "Open a website or local app in your default browser. Embedded browsing is available on macOS and Linux.".into() } else { diff --git a/docs/reference/browser-profiles.md b/docs/reference/browser-profiles.md index 976f33eff8..816c04cab6 100644 --- a/docs/reference/browser-profiles.md +++ b/docs/reference/browser-profiles.md @@ -47,8 +47,12 @@ normal teardown the parent closes its command pipe, and the helper destroys pages and waits for pending cookie operations before leaving its event loop. The parent allows three seconds before forced termination. Another process opening the same profile gets an explicit error instead of a concurrent writer. -Startup/storage failures are shown on the browser page; they do not silently -select temporary storage. +Startup runs on a dedicated background thread, including executable extraction, +profile coordination and the bounded ten-second handshake. Navigation keeps the +UI responsive and shows a loading state until initialization finishes. If the +address changes during startup, the latest address is loaded; closing the tab +cancels delivery of its pending result. Startup/storage failures are shown on +the browser page; they do not silently select temporary storage. On macOS 14+, WebKit manages storage in the app's website-data location. Zeron uses a named `WKWebsiteDataStore` with a stable UUID derived from the canonical @@ -73,6 +77,11 @@ Run the browser unit and shell regression tests: cargo test --locked -p zeron-ui --lib browser --features browser-fixture -- --test-threads=1 ``` +The Linux startup regressions use Python 3 to gate a synthetic helper. They +check that two windows remain responsive, the latest address wins, startup +errors are delivered asynchronously, and closing a pending tab cancels its +attachment. These tests do not need a display. + On Linux, exercise the real helper lifecycle and shared process registry: ```sh From 35f3dad066b91a0443612d4b7913d6b297ff1540 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sun, 27 Sep 2026 11:51:41 -0300 Subject: [PATCH 08/16] fix(browser): correct macOS fixture import and Windows profile test --- crates/ui/src/browser/mod.rs | 2 +- crates/ui/src/browser/profile.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui/src/browser/mod.rs b/crates/ui/src/browser/mod.rs index 9a2a4457c0..1034dee6a6 100644 --- a/crates/ui/src/browser/mod.rs +++ b/crates/ui/src/browser/mod.rs @@ -615,7 +615,7 @@ impl BrowserContext { ) { #[cfg(target_os = "macos")] { - use wry::WebViewExtMacOS; + use wry::WebViewExtDarwin; if native::persistent_profiles_supported() { let profile = profile::BrowserProfile::for_workspace( data_dir, diff --git a/crates/ui/src/browser/profile.rs b/crates/ui/src/browser/profile.rs index 387ce992ba..f49969baaf 100644 --- a/crates/ui/src/browser/profile.rs +++ b/crates/ui/src/browser/profile.rs @@ -93,7 +93,7 @@ mod tests { assert!( make(&["../../outside"]) .root - .starts_with(root.path().join("browser")) + .starts_with(root.path().canonicalize().unwrap().join("browser")) ); } From f879c5342b246bf9a55981f99343973677ee1f0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Mon, 28 Sep 2026 21:44:51 -0300 Subject: [PATCH 09/16] fix(browser): invoke macOS storage fixture wrapper through bash --- scripts/test-browser-storage.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/test-browser-storage.py b/scripts/test-browser-storage.py index 979ad4b019..6f2a17d8ed 100644 --- a/scripts/test-browser-storage.py +++ b/scripts/test-browser-storage.py @@ -185,7 +185,7 @@ def run(profile, mode): ZERON_BROWSER_STORAGE_URL=origin + '/' + mode) command = [str(Path(args.fixture).resolve()), str(root / 'captures')] if os.uname().sysname == 'Darwin': - command.insert(0, str(Path(__file__).resolve().parent / 'run-macos-browser-fixture.sh')) + command[:0] = ['bash', str(Path(__file__).resolve().parent / 'run-macos-browser-fixture.sh')] subprocess.run(command, env=env, check=True, timeout=60) print(f'PASS {profile}: {mode}', flush=True) @@ -212,7 +212,7 @@ def run(profile, mode): env = dict(os.environ, ZERON_BROWSER_STORAGE_ROOT=str(root), ZERON_BROWSER_STORAGE_PROFILE=profile, ZERON_BROWSER_STORAGE_URL='cleanup') - subprocess.run([str(Path(__file__).resolve().parent / 'run-macos-browser-fixture.sh'), + subprocess.run(['bash', str(Path(__file__).resolve().parent / 'run-macos-browser-fixture.sh'), str(Path(args.fixture).resolve()), str(root / 'captures')], env=env, check=True, timeout=60) server.shutdown() From cd7d2f59d8fc74817fa06f48889030239db7d1a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Tue, 29 Sep 2026 00:16:45 -0300 Subject: [PATCH 10/16] fix(browser): propagate macOS fixture failures and initialize profile cleanup Require explicit storage results, validate deletion in every store, and preserve primary failures while attempting all profile cleanups. Add opt-in native cookie and same-process reopen diagnostics. Validated on macOS 27 arm64: release check/build and native tests pass. Two complete storage runs correctly fail on cookies returning after process restart; all profile cleanups pass. Cookie deletion persistence remains unresolved. --- crates/ui/examples/browser-fixture.rs | 11 +++- crates/ui/examples/browser-fixture/storage.rs | 21 +++++- crates/ui/src/browser/macos.rs | 19 ++++++ crates/ui/src/browser/mod.rs | 14 ++++ scripts/test-browser-storage.py | 64 ++++++++++++++----- 5 files changed, 112 insertions(+), 17 deletions(-) diff --git a/crates/ui/examples/browser-fixture.rs b/crates/ui/examples/browser-fixture.rs index 936b00c23c..2456aa46e7 100644 --- a/crates/ui/examples/browser-fixture.rs +++ b/crates/ui/examples/browser-fixture.rs @@ -205,7 +205,11 @@ fn main() -> anyhow::Result<()> { let run: anyhow::Result<()> = async { pause(cx, 1200).await; if let Some(url) = &storage_url { - return storage::exercise(window, state.clone(), &storage_root, &storage_profile, url, cx).await; + storage::exercise(window, state.clone(), &storage_root, &storage_profile, url, cx).await?; + std::fs::write(output.join("storage-result.json"), serde_json::to_vec(&serde_json::json!({ + "profile": storage_profile, "url": url, "status": "pass" + }))?)?; + return Ok(()); } if std::env::var_os("ZERON_TRANSCRIPT_LINK_FIXTURE_ONLY").is_some() { return transcript_links::exercise(window, state.clone(), &_origin, &output, cx).await; @@ -511,6 +515,11 @@ fn main() -> anyhow::Result<()> { drop(state); let _ = window.update(cx, |_, window, _| window.remove_window()); pause(cx, 100).await; + // AppKit terminate: exits the process without returning from Application::run. + // Report failure before entering that path so native runners cannot see a false pass. + if result.lock().unwrap().is_some() { + std::process::exit(1); + } cx.update(|cx| cx.quit()); }).detach(); }); diff --git a/crates/ui/examples/browser-fixture/storage.rs b/crates/ui/examples/browser-fixture/storage.rs index ca043cae6b..b378edc6a2 100644 --- a/crates/ui/examples/browser-fixture/storage.rs +++ b/crates/ui/examples/browser-fixture/storage.rs @@ -56,11 +56,30 @@ pub async fn exercise( let (id, browser) = window.update(cx, |shell, window, cx| { shell.fixture_open_browser(Some(url.to_owned()), window, cx) })?; - loaded(&browser, cx).await?; + let loaded_result = loaded(&browser, cx).await; + #[cfg(target_os = "macos")] + if std::env::var_os("ZERON_BROWSER_STORAGE_DIAGNOSTICS").is_some() { + let (tx, rx) = tokio::sync::oneshot::channel(); + browser.read_with(cx, |browser, _| browser.fixture_cookies(move |cookies| { + let _ = tx.send(cookies); + })); + eprintln!("Storage native cookies before close ({url}): {:?}", rx.await?); + } + loaded_result?; window.update(cx, |shell, window, cx| { shell.fixture_close_browser(id, window, cx) })?; drop(browser); + eprintln!("Storage lifecycle: closed page and dropped browser ({url})"); + if url.ends_with("/delete") && std::env::var_os("ZERON_BROWSER_STORAGE_REOPEN").is_some() { + let empty = format!("{}/empty", url.strip_suffix("/delete").unwrap()); + let (id, reopened) = window.update(cx, |shell, window, cx| { + shell.fixture_open_browser(Some(empty), window, cx) + })?; + loaded(&reopened, cx).await?; + window.update(cx, |shell, window, cx| shell.fixture_close_browser(id, window, cx))?; + eprintln!("Storage diagnostic: empty after closing and reopening the deleted page"); + } if url.ends_with("/write") { super::pause(cx, 2200).await; // Wait for the intentionally short-lived cookie. let url = format!("{}/read", url.strip_suffix("/write").unwrap()); diff --git a/crates/ui/src/browser/macos.rs b/crates/ui/src/browser/macos.rs index ebd9f2e835..9c2e7d730b 100644 --- a/crates/ui/src/browser/macos.rs +++ b/crates/ui/src/browser/macos.rs @@ -859,6 +859,25 @@ impl NativePage { pub fn fixture_visible(&self) -> bool { !self.0.borrow().view.isHidden() } + pub fn fixture_cookies(&self, completion: impl FnOnce(Vec) + 'static) { + use objc2_foundation::{NSArray, NSHTTPCookie}; + let completion = RefCell::new(Some(completion)); + let block = block2::RcBlock::new(move |cookies: std::ptr::NonNull>| { + let cookies = unsafe { cookies.as_ref() }; + let values = cookies.iter().map(|cookie| format!( + "{}={}; domain={}; path={}; httpOnly={}; sessionOnly={}", + cookie.name(), cookie.value(), cookie.domain(), cookie.path(), + cookie.isHTTPOnly(), cookie.isSessionOnly() + )).collect(); + if let Some(completion) = completion.borrow_mut().take() { + completion(values); + } + }); + unsafe { + self.0.borrow().view.configuration().websiteDataStore().httpCookieStore().getAllCookies(&block); + } + } + pub fn fixture_eval(&self, script: &str) { unsafe { self.0 diff --git a/crates/ui/src/browser/mod.rs b/crates/ui/src/browser/mod.rs index 1034dee6a6..a4df7ef706 100644 --- a/crates/ui/src/browser/mod.rs +++ b/crates/ui/src/browser/mod.rs @@ -624,9 +624,18 @@ impl BrowserContext { Some(device), ) .unwrap(); + eprintln!("Storage cleanup: invoke; main_thread={}", objc2::MainThreadMarker::new().is_some()); + // A cleanup-only process has not constructed a WebKit store yet. + // Initialize its main run loop before removal dispatches back from the + // WebsiteDataStoreIO queue. An ephemeral store never opens user data. + let mtm = objc2::MainThreadMarker::new().expect("profile cleanup requires the main thread"); + let initialization = unsafe { objc2_web_kit::WKWebsiteDataStore::nonPersistentDataStore(mtm) }; + drop(initialization); wry::WebView::remove_data_store(&profile.data_store_identifier(), move |result| { + eprintln!("Storage cleanup: callback; main_thread={}; result={result:?}", objc2::MainThreadMarker::new().is_some()); completion(result.map_err(|error| error.to_string())); }); + eprintln!("Storage cleanup: invocation returned"); } else { completion(Ok(())); } @@ -709,6 +718,11 @@ impl BrowserSurface { pub fn fixture_geometry(&self) -> (f32, f32, f32) { self.native.as_ref().unwrap().fixture_geometry() } + #[cfg(target_os = "macos")] + pub fn fixture_cookies(&self, completion: impl FnOnce(Vec) + 'static) { + self.native.as_ref().expect("storage page must be attached").fixture_cookies(completion); + } + pub fn fixture_eval(&self, script: &str) { #[cfg(target_os = "macos")] if let Some(native) = &self.native { diff --git a/scripts/test-browser-storage.py b/scripts/test-browser-storage.py index 6f2a17d8ed..1d71a6d023 100644 --- a/scripts/test-browser-storage.py +++ b/scripts/test-browser-storage.py @@ -11,6 +11,7 @@ from pathlib import Path import queue import struct +import sys import subprocess import tempfile import threading @@ -47,17 +48,25 @@ document.cookie = 'durable=; Path=/; Max-Age=0'; localStorage.removeItem('token'); await transaction(true, null); - } else { - const present = mode === 'read'; - check(document.cookie.includes('durable=fixture') === present, 'persistent cookie'); - check(!document.cookie.includes('expired='), 'expired cookie returned'); - check(!document.cookie.includes('removed='), 'deleted cookie returned'); - check(localStorage.getItem('token') === (present ? 'fixture' : null), 'localStorage'); - check((await transaction(false)) === (present ? 'fixture' : undefined), 'IndexedDB'); - const cookies = await (await fetch('/cookies')).json(); - check(cookies.includes('httpOnlyToken=fixture') === present, 'HttpOnly cookie'); - check(!document.cookie.includes('httpOnlyToken'), 'HttpOnly cookie exposed'); } + const present = mode === 'write' || mode === 'read'; + const cookies = await (await fetch('/cookies')).json(); + const actual = { + cookie: document.cookie, + cookies, + localStorage: localStorage.getItem('token'), + indexedDB: (await transaction(false)) ?? null, + }; + const failures = []; + const verify = (ok, message) => { if (!ok) failures.push(message); }; + verify(actual.cookie.includes('durable=fixture') === present, 'persistent cookie'); + if (mode !== 'write') verify(!actual.cookie.includes('expired='), 'expired cookie returned'); + verify(!actual.cookie.includes('removed='), 'deleted cookie returned'); + verify(actual.localStorage === (present ? 'fixture' : null), 'localStorage'); + verify(actual.indexedDB === (present ? 'fixture' : null), 'IndexedDB'); + verify(cookies.includes('httpOnlyToken=fixture') === present, 'HttpOnly cookie'); + verify(!actual.cookie.includes('httpOnlyToken'), 'HttpOnly cookie exposed'); + check(failures.length === 0, failures.join(', ') + '; state=' + JSON.stringify(actual)); db.close(); document.title = 'storage:pass'; })().catch(error => { document.title = 'storage:fail:' + error.message; }); @@ -183,10 +192,16 @@ def run(profile, mode): env = dict(os.environ, ZERON_BROWSER_STORAGE_ROOT=str(root), ZERON_BROWSER_STORAGE_PROFILE=profile, ZERON_BROWSER_STORAGE_URL=origin + '/' + mode) - command = [str(Path(args.fixture).resolve()), str(root / 'captures')] + captures = root / f'{profile}-{mode}' + result_file = captures / 'storage-result.json' + result_file.unlink(missing_ok=True) + command = [str(Path(args.fixture).resolve()), str(captures)] if os.uname().sysname == 'Darwin': command[:0] = ['bash', str(Path(__file__).resolve().parent / 'run-macos-browser-fixture.sh')] subprocess.run(command, env=env, check=True, timeout=60) + assert json.loads(result_file.read_text()) == { + 'profile': profile, 'url': origin + '/' + mode, 'status': 'pass' + }, f'missing or invalid storage result for {profile}: {mode}' print(f'PASS {profile}: {mode}', flush=True) try: @@ -207,15 +222,34 @@ def run(profile, mode): assert (root / 'a' / 'cookies.sqlite').stat().st_mode & 0o077 == 0 print('PASS profile locking, independent profiles, storage errors, file permissions', flush=True) finally: + # Cleanup must attempt every synthetic profile without replacing the test failure. + primary_error = sys.exc_info()[1] + cleanup_errors = [] if args.fixture and os.uname().sysname == 'Darwin': for profile in ('a', 'a-other', 'b'): env = dict(os.environ, ZERON_BROWSER_STORAGE_ROOT=str(root), ZERON_BROWSER_STORAGE_PROFILE=profile, ZERON_BROWSER_STORAGE_URL='cleanup') - subprocess.run(['bash', str(Path(__file__).resolve().parent / 'run-macos-browser-fixture.sh'), - str(Path(args.fixture).resolve()), str(root / 'captures')], - env=env, check=True, timeout=60) - server.shutdown() + captures = root / f'{profile}-cleanup' + result_file = captures / 'storage-result.json' + try: + subprocess.run(['bash', str(Path(__file__).resolve().parent / 'run-macos-browser-fixture.sh'), + str(Path(args.fixture).resolve()), str(captures)], + env=env, check=True, timeout=60) + assert json.loads(result_file.read_text()) == { + 'profile': profile, 'url': 'cleanup', 'status': 'pass' + }, f'missing or invalid cleanup result for {profile}' + print(f'PASS cleanup: {profile}', flush=True) + except Exception as error: + cleanup_errors.append(error) + print(f'FAIL cleanup: {profile}: {error}', file=sys.stderr, flush=True) + server.shutdown() + server.server_close() + if cleanup_errors: + if primary_error is not None: + primary_error.add_note(f'Profile cleanup also failed: {cleanup_errors!r}') + else: + raise ExceptionGroup('Profile cleanup failed', cleanup_errors) if __name__ == '__main__': From e142d52d5eb8f1f11f47cce7e1cb0867af491aec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Tue, 29 Sep 2026 00:46:35 -0300 Subject: [PATCH 11/16] fix(browser-macos): retain profile stores for the application lifetime --- crates/ui/src/browser/macos.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/crates/ui/src/browser/macos.rs b/crates/ui/src/browser/macos.rs index 9c2e7d730b..8301815c48 100644 --- a/crates/ui/src/browser/macos.rs +++ b/crates/ui/src/browser/macos.rs @@ -36,20 +36,25 @@ pub(super) struct BrowserData(Rc>); impl BrowserData { pub fn for_profile(profile: super::profile::BrowserProfile) -> Self { thread_local! { - static PROFILES: RefCell>>> = RefCell::new(std::collections::HashMap::new()); + // Keep named stores alive until the app exits, including after its + // last window closes or the active identity changes. Destroying a + // WKWebsiteDataStore removes its network session; WebKit's cookie + // flush on UI-process disconnect only visits sessions still alive. + // A weak registry can therefore lose pending cookie deletions while + // localStorage/IndexedDB have already been saved during teardown. + static PROFILES: RefCell>>> = RefCell::new(std::collections::HashMap::new()); } PROFILES.with(|profiles| { let mut profiles = profiles.borrow_mut(); - profiles.retain(|_, store| store.strong_count() > 0); - if let Some(store) = profiles.get(&profile.root).and_then(std::rc::Weak::upgrade) { - return Self(store); + if let Some(store) = profiles.get(&profile.root) { + return Self(store.clone()); } let root = profile.root.clone(); let store = Rc::new(RefCell::new(BrowserStore { profile: Some(profile), ..Default::default() })); - profiles.insert(root, Rc::downgrade(&store)); + profiles.insert(root, store.clone()); Self(store) }) } From 83df523fc9892e6cb7981bbc3688b081e491cf4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Tue, 29 Sep 2026 01:44:59 -0300 Subject: [PATCH 12/16] fix(browser-macos): preserve profile stores through process exit --- crates/ui/src/browser/macos.rs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/crates/ui/src/browser/macos.rs b/crates/ui/src/browser/macos.rs index 8301815c48..c1f166951a 100644 --- a/crates/ui/src/browser/macos.rs +++ b/crates/ui/src/browser/macos.rs @@ -21,6 +21,7 @@ use objc2_web_kit::{ }; use std::{ cell::{Cell, RefCell}, + mem::ManuallyDrop, rc::Rc, }; use wry::{WebView, WebViewBuilderExtMacos, WebViewExtMacOS}; @@ -36,13 +37,17 @@ pub(super) struct BrowserData(Rc>); impl BrowserData { pub fn for_profile(profile: super::profile::BrowserProfile) -> Self { thread_local! { - // Keep named stores alive until the app exits, including after its - // last window closes or the active identity changes. Destroying a - // WKWebsiteDataStore removes its network session; WebKit's cookie - // flush on UI-process disconnect only visits sessions still alive. - // A weak registry can therefore lose pending cookie deletions while - // localStorage/IndexedDB have already been saved during teardown. - static PROFILES: RefCell>>> = RefCell::new(std::collections::HashMap::new()); + // Keep named stores alive through process termination, including + // after the last window closes or the active identity changes. + // macOS runs Rust TLS destructors during AppKit's exit(). Releasing + // a WKWebsiteDataStore there removes its network session before + // WebKit's UI-process-disconnect handler can flush its cookies. + // In particular, deleted cookies can reappear on the next launch. + // Intentionally skip this registry's destructor: WebKit must still + // own the sessions when our process disconnects, and the OS reclaims + // these process-lifetime references on exit. + static PROFILES: ManuallyDrop>>>> = + ManuallyDrop::new(RefCell::new(std::collections::HashMap::new())); } PROFILES.with(|profiles| { let mut profiles = profiles.borrow_mut(); From 7201e07564380471d302659c6ee56a03451d84e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Tue, 29 Sep 2026 02:07:19 -0300 Subject: [PATCH 13/16] test(opencode): remove fixture startup and clock races --- crates/harness/src/opencode/mod.rs | 12 ++++++++++-- crates/harness/src/opencode/tests.rs | 4 ++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/crates/harness/src/opencode/mod.rs b/crates/harness/src/opencode/mod.rs index 35bb507508..4c3bb46ae9 100644 --- a/crates/harness/src/opencode/mod.rs +++ b/crates/harness/src/opencode/mod.rs @@ -4332,10 +4332,17 @@ mod mcp_injection_tests { for major in [1, 2] { let exe = fixture.path().join(format!("opencode-{major}")); let script = format!( - r#"#!/usr/bin/env node + // Version probing has a short production deadline. Answer in + // the shell so a cold Node startup on a busy CI runner cannot + // exhaust it before this MCP-isolation fixture even starts. + r#"#!/bin/sh +if [ "$1" = "--version" ]; then + printf '%s\n' '{major}.0.0' + exit 0 +fi +exec node - "$@" <<'NODE' const http = require('node:http'); const version = '{major}.0.0'; -if (process.argv.includes('--version')) {{ console.log(version); process.exit(0); }} const config = JSON.parse(process.env.OPENCODE_CONFIG_CONTENT); const server = {major} === 1 ? config.mcp.zeron : config.mcp.servers.zeron; if (!server || server.command[1] !== 'mcp') throw new Error('missing MCP config'); @@ -4348,6 +4355,7 @@ http.createServer((req, res) => {{ }} res.statusCode = 404; res.end('{{}}'); }}).listen(port, '127.0.0.1'); +NODE "# ); std::fs::write(&exe, script).unwrap(); diff --git a/crates/harness/src/opencode/tests.rs b/crates/harness/src/opencode/tests.rs index ee86b61e3b..acb09752e0 100644 --- a/crates/harness/src/opencode/tests.rs +++ b/crates/harness/src/opencode/tests.rs @@ -2378,6 +2378,10 @@ async fn stalled_prompt_post_has_a_bounded_timeout() { tokio::task::yield_now().await; tokio::time::pause(); tokio::time::advance(CALL_TIMEOUT + Duration::from_secs(1)).await; + // The timeout is now due, but settling the turn still uses real HTTP/SSE + // sockets. Resume time so done() cannot auto-advance its watchdog before + // the OS delivers those responses. + tokio::time::resume(); assert_eq!(wire.done().await.0, DoneStatus::Errored); } From 515642b2cfeb7636b99cd190124914e0eedf6562 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Tue, 29 Sep 2026 02:10:00 -0300 Subject: [PATCH 14/16] Revert "test(opencode): remove fixture startup and clock races" This reverts commit dffcec2cdee4d6903649fe156c5b813d91e12376. --- crates/harness/src/opencode/mod.rs | 12 ++---------- crates/harness/src/opencode/tests.rs | 4 ---- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/crates/harness/src/opencode/mod.rs b/crates/harness/src/opencode/mod.rs index 4c3bb46ae9..35bb507508 100644 --- a/crates/harness/src/opencode/mod.rs +++ b/crates/harness/src/opencode/mod.rs @@ -4332,17 +4332,10 @@ mod mcp_injection_tests { for major in [1, 2] { let exe = fixture.path().join(format!("opencode-{major}")); let script = format!( - // Version probing has a short production deadline. Answer in - // the shell so a cold Node startup on a busy CI runner cannot - // exhaust it before this MCP-isolation fixture even starts. - r#"#!/bin/sh -if [ "$1" = "--version" ]; then - printf '%s\n' '{major}.0.0' - exit 0 -fi -exec node - "$@" <<'NODE' + r#"#!/usr/bin/env node const http = require('node:http'); const version = '{major}.0.0'; +if (process.argv.includes('--version')) {{ console.log(version); process.exit(0); }} const config = JSON.parse(process.env.OPENCODE_CONFIG_CONTENT); const server = {major} === 1 ? config.mcp.zeron : config.mcp.servers.zeron; if (!server || server.command[1] !== 'mcp') throw new Error('missing MCP config'); @@ -4355,7 +4348,6 @@ http.createServer((req, res) => {{ }} res.statusCode = 404; res.end('{{}}'); }}).listen(port, '127.0.0.1'); -NODE "# ); std::fs::write(&exe, script).unwrap(); diff --git a/crates/harness/src/opencode/tests.rs b/crates/harness/src/opencode/tests.rs index acb09752e0..ee86b61e3b 100644 --- a/crates/harness/src/opencode/tests.rs +++ b/crates/harness/src/opencode/tests.rs @@ -2378,10 +2378,6 @@ async fn stalled_prompt_post_has_a_bounded_timeout() { tokio::task::yield_now().await; tokio::time::pause(); tokio::time::advance(CALL_TIMEOUT + Duration::from_secs(1)).await; - // The timeout is now due, but settling the turn still uses real HTTP/SSE - // sockets. Resume time so done() cannot auto-advance its watchdog before - // the OS delivers those responses. - tokio::time::resume(); assert_eq!(wire.done().await.0, DoneStatus::Errored); } From 30a84ff3f9cb281bcdb5f9728a0ee778b5c83267 Mon Sep 17 00:00:00 2001 From: Contribution Manager Date: Mon, 5 Oct 2026 21:11:12 -0700 Subject: [PATCH 15/16] Mirror upstream PR #573 without triggering bulk CI [skip ci] Source: https://github.com/zeronsh/zeron/pull/573 Source revision: 515642b2cfeb7636b99cd190124914e0eedf6562 From ad7a73349ab2ab78c2e10dedabf1e927b6edacca Mon Sep 17 00:00:00 2001 From: "contribution-manager-katulevskiy[bot]" <338414057+contribution-manager-katulevskiy[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:58:53 +0000 Subject: [PATCH 16/16] ci: run checks (requested via Contribution Manager)