Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
4da3c9a
refactor(browser): define stable device-local profile identity
jsgrrchg Sep 27, 2026
81d8d0b
fix(browser-linux): persist website data and cookies by profile
jsgrrchg Sep 27, 2026
6465e29
fix(browser-macos): persist isolated website stores on macOS 14
jsgrrchg Sep 27, 2026
75bffb8
fix(browser): preserve profile lifecycle across tabs and windows
jsgrrchg Sep 27, 2026
46ba2fc
test(browser): verify persistent storage across app launches and prof…
jsgrrchg Sep 27, 2026
4c898fb
docs(browser): document persistent profiles and session lifecycle
jsgrrchg Sep 27, 2026
e1c4ffe
fix(browser-linux): initialize WebKit without blocking the UI
jsgrrchg Sep 27, 2026
35f3dad
fix(browser): correct macOS fixture import and Windows profile test
jsgrrchg Sep 27, 2026
f879c53
fix(browser): invoke macOS storage fixture wrapper through bash
jsgrrchg Sep 29, 2026
cd7d2f5
fix(browser): propagate macOS fixture failures and initialize profile…
jsgrrchg Sep 29, 2026
e142d52
fix(browser-macos): retain profile stores for the application lifetime
jsgrrchg Sep 29, 2026
83df523
fix(browser-macos): preserve profile stores through process exit
jsgrrchg Sep 29, 2026
7201e07
test(opencode): remove fixture startup and clock races
jsgrrchg Sep 29, 2026
515642b
Revert "test(opencode): remove fixture startup and clock races"
jsgrrchg Sep 29, 2026
30a84ff
Mirror upstream PR #573 without triggering bulk CI [skip ci]
Oct 6, 2026
ad7a733
ci: run checks (requested via Contribution Manager)
contribution-manager-katulevskiy[bot] Oct 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/macos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/**'
Expand All @@ -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/**'
Expand Down Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion .github/workflows/ui-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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:

Expand Down Expand Up @@ -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:
Expand Down
24 changes: 21 additions & 3 deletions crates/ui/examples/browser-fixture.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
#[path = "browser-fixture/storage.rs"]
mod storage;
#[path = "browser-fixture/transcript_links.rs"]
mod transcript_links;
#[cfg(target_os = "linux")]
Expand Down Expand Up @@ -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 || {
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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();
});
Expand Down
118 changes: 118 additions & 0 deletions crates/ui/examples/browser-fixture/storage.rs
Original file line number Diff line number Diff line change
@@ -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<BrowserSurface>, 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<Shell>,
state: Entity<zeron_ui::state::AppState>,
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(())
}
102 changes: 100 additions & 2 deletions crates/ui/src/browser/linux/helper.c
Original file line number Diff line number Diff line change
@@ -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 <errno.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/file.h>
#include <glib-unix.h>
#include <json-glib/json-glib.h>
#include <signal.h>
Expand All @@ -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;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -616,13 +710,17 @@ 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);
gtk_main();
g_hash_table_destroy(pages);
g_byte_array_unref(input);
g_object_unref(context);
if (profile_lock >= 0)
close(profile_lock);
return 0;
}
Loading
Loading