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..2456aa46e7 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,13 @@ fn main() -> anyhow::Result<()> { cx.spawn(async move |cx| { let run: anyhow::Result<()> = async { pause(cx, 1200).await; + if let Some(url) = &storage_url { + 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; } @@ -252,7 +265,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"); } @@ -502,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 new file mode 100644 index 0000000000..b378edc6a2 --- /dev/null +++ b/crates/ui/examples/browser-fixture/storage.rs @@ -0,0 +1,118 @@ +//! 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) + })?; + 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()); + 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 1ec1a3b8ee..e57fbcfa10 100644 --- a/crates/ui/src/browser/linux/helper.c +++ b/crates/ui/src/browser/linux/helper.c @@ -1,6 +1,10 @@ +#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 #include @@ -26,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; @@ -470,9 +476,34 @@ 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_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. + // 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) { 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) @@ -579,7 +610,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); @@ -604,8 +635,71 @@ 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; + } + 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); + 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 +710,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); @@ -624,5 +720,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 7a9023bd0a..787fe814f0 100644 --- a/crates/ui/src/browser/linux/mod.rs +++ b/crates/ui/src/browser/linux/mod.rs @@ -25,17 +25,38 @@ pub enum NativeEvent { } #[derive(Clone, Default)] -pub struct BrowserData(Arc>>); +pub struct BrowserData { + current: Arc>>>, + profile: Option, + #[cfg(test)] + helper_override: 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(); } @@ -51,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) @@ -82,10 +113,57 @@ 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 { + // One live helper per profile, shared across all windows in this process. + type SharedWorker = Mutex>>; + 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, + #[cfg(test)] + helper_override: None, + } + } + /// Blocking initialization, called only by the dedicated startup thread. fn worker(&self) -> Result, String> { - let mut current = self.0.lock().unwrap(); - if let Some(worker) = current.upgrade() { + let mut current = self.current.lock().unwrap(); + if let Some(worker) = current.as_ref() { if worker .child .lock() @@ -94,13 +172,45 @@ impl BrowserData { .map_err(|e| e.to_string())? .is_none() { - return Ok(worker); + return Ok(worker.clone()); } } - let mut child = Command::new(helper_path()?).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::inherit()).spawn() + #[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); + } + 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 || { @@ -163,11 +273,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) } } @@ -175,7 +285,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()) @@ -198,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 { @@ -306,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, @@ -674,3 +838,50 @@ 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); + } +} + +#[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/macos.rs b/crates/ui/src/browser/macos.rs index 4b4ad0b24f..c1f166951a 100644 --- a/crates/ui/src/browser/macos.rs +++ b/crates/ui/src/browser/macos.rs @@ -21,26 +21,64 @@ use objc2_web_kit::{ }; use std::{ cell::{Cell, RefCell}, + mem::ManuallyDrop, rc::Rc, }; 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 { + thread_local! { + // 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(); + 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, store.clone()); + Self(store) + }) + } fn configuration( &self, mtm: MainThreadMarker, ) -> 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) { @@ -78,6 +116,21 @@ 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. +pub(super) 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. @@ -298,7 +351,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)); @@ -817,6 +869,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 6b4cb526a8..a4df7ef706 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,30 @@ 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, @@ -97,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"))] @@ -175,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"))] @@ -341,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 { @@ -415,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 { @@ -558,6 +593,61 @@ 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::WebViewExtDarwin; + if native::persistent_profiles_supported() { + let profile = profile::BrowserProfile::for_workspace( + data_dir, + Some(zeron_proto::WorkspaceScope::Local), + None, + 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(())); + } + } + #[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) { @@ -628,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/crates/ui/src/browser/profile.rs b/crates/ui/src/browser/profile.rs new file mode 100644 index 0000000000..f49969baaf --- /dev/null +++ b/crates/ui/src/browser/profile.rs @@ -0,0 +1,166 @@ +//! 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().canonicalize().unwrap().join("browser")) + ); + } + + #[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"); + 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/browser/view.rs b/crates/ui/src/browser/view.rs index 47d268754b..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 { @@ -402,6 +410,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 +658,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) diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 54e0070761..23ad134558 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>, @@ -3458,6 +3458,30 @@ 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 +3492,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,25 +12279,7 @@ impl Render for Shell { .clone() .unwrap_or_else(|| self.state.read(cx).gate()); - let browser_profile = { - let state = self.state.read(cx); - crate::links::workspace_locator( - 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(); - } - self.browser_profile = browser_profile; - } + self.sync_browser_profile(cx); let browser_active = matches!(gate, GatePhase::Ready) && !restart_required && matches!(self.route, Route::Chat) @@ -15589,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/docs/reference/browser-profiles.md b/docs/reference/browser-profiles.md new file mode 100644 index 0000000000..816c04cab6 --- /dev/null +++ b/docs/reference/browser-profiles.md @@ -0,0 +1,136 @@ +# 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 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 +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 +``` + +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 +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; diff --git a/scripts/test-browser-storage.py b/scripts/test-browser-storage.py new file mode 100644 index 0000000000..1d71a6d023 --- /dev/null +++ b/scripts/test-browser-storage.py @@ -0,0 +1,256 @@ +#!/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 sys +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('