git.lucas.co / cce-browser
web browser (Servo)
git clone https://git.lucas.co/cce-browser.git

commitb3e3e8abeabf3e9cfc29a195b92e97eb1ad1e05f
parent5c922b7226
authorLucas Galante <[email protected]>
date2026-09-01 10:12
feat: single instance — an external open becomes a tab, not a process

xdg-open (cce-mail links, and any %u launch) spawned a whole new browser
per link. main() now forwards the argument to a running instance over
/tmp/cce-browser-<WAYLAND_DISPLAY>.sock and exits before any engine
work; the instance opens it as a new active tab (a bare launch becomes a
blank tab). Connect-then-bind closes the startup race, a refused connect
clears a crashed instance's stale socket, and relative file paths are
canonicalized sender-side. Also keeps two engines off the shared
plaintext cookie jar.

Co-Authored-By: Claude Fable 5 <[email protected]>

 CLAUDE.md       |  19 ++++++-
 src/instance.rs | 161 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/main.rs     |  29 ++++++++++
 3 files changed, 208 insertions(+), 1 deletion(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 4f7599e..0ca02e3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -13,11 +13,12 @@ truth, there is no push remote). Read the workspace-level
 `../cce-compositor/WORKSPACE.md` first: workspace layout, the `cce-ui` toolkit, config
 conventions, and the multi-repo rules all live there.
 
-Five files, ~2.6k lines:
+Six files, ~2.8k lines:
 
 | file | what it owns |
 | --- | --- |
 | `src/main.rs` | `BrowserApp` — the `cce-ui` `Application`: chrome layout, hit-testing, the URL line editor, key/pointer routing |
+| `src/instance.rs` | single-instance forwarding: a later launch hands its argument to the running instance's socket and exits |
 | `src/webview.rs` | `ServoHost` — Servo boot, the delegate, one `WebView` per tab, the frame pipeline |
 | `src/pages.rs` | the `cce:` protocol handler and its History / Bookmarks stores |
 | `src/downloads.rs` | the chrome-side download pipeline (Servo has none) |
@@ -54,6 +55,22 @@ not a git pin. Servo's embedding API churns
 hard between releases, so when a version bump breaks the build, expect the delegate
 trait, the input-event constructors, and `Preferences`/`Opts` to be where it broke.
 
+## Single instance
+
+An external open (`xdg-open` → the desktop entry's `cce-browser %u`) spawns a
+fresh process per link. `src/instance.rs` turns that into a tab: `main()` tries
+`/tmp/cce-browser-<WAYLAND_DISPLAY>.sock` (the standard `cce_ui::ipc`
+convention; display keying isolates shadow sessions) before any engine or
+Wayland work, forwards `open <arg>` / `new-tab` and exits on success, or binds
+the socket and becomes the instance. The listener thread pushes
+`Message::OpenExternal` into calloop; `update()` parses the relayed argument
+with `parse_startup_arg` — it *is* a launch argument, so the URL bar's
+domain-guess parsing stays wrong for it — and a forwarded relative file path is
+canonicalized on the *sending* side, whose cwd it is relative to. Beyond
+tidiness this guards the profile dir: two engines must not share the plaintext
+cookie jar. There is deliberately no `--new-window` yet; raising the existing
+window on forward is also still open.
+
 ## The frame pipeline
 
 Servo renders into a **`SoftwareRenderingContext`** (CPU, no GPU handoff), one context
diff --git a/src/instance.rs b/src/instance.rs
new file mode 100644
index 0000000..1d898ea
--- /dev/null
+++ b/src/instance.rs
@@ -0,0 +1,161 @@
+//! Single-instance forwarding over the CCE socket convention.
+//!
+//! Every external open (`xdg-open` via the desktop entry's `%u`) spawns a
+//! fresh `cce-browser <url>` process. A second full instance is not just
+//! clutter: the profile dir holds a plaintext cookie jar two engines must
+//! not share. So the first instance listens on
+//! `/tmp/cce-browser-<WAYLAND_DISPLAY>.sock` (keyed by display, which keeps
+//! shadow sessions isolated for free), and every later launch hands its
+//! argument to it and exits before any Wayland or engine work happens.
+//!
+//! The order in [`forward_or_claim`] is what closes the startup race: try to
+//! connect, and only bind after a connect has failed. A refused connection
+//! means the socket file outlived a crashed instance and is removed before
+//! binding; losing the bind to a simultaneous launch falls back to one more
+//! connect. If that also fails the launch proceeds un-listened rather than
+//! not at all.
+//!
+//! The claimed listener has to survive from `main()` (before the engine
+//! starts) to `BrowserApp::new` (where the calloop sender first exists), so
+//! it parks in a static until [`spawn_listener`] adopts it.
+
+use std::io::{BufRead, BufReader, Write};
+use std::os::unix::net::{UnixListener, UnixStream};
+use std::sync::Mutex;
+
+use crate::Message;
+
+/// Socket prefix; `cce_ui::ipc::socket_path` appends `-<WAYLAND_DISPLAY>`.
+const PREFIX: &str = "cce-browser";
+
+/// The listener claimed by `forward_or_claim`, waiting for `spawn_listener`.
+static CLAIMED: Mutex<Option<UnixListener>> = Mutex::new(None);
+/// The socket path this process bound (and must unlink on exit), if any.
+static OWNED_PATH: Mutex<Option<String>> = Mutex::new(None);
+
+/// Hand `arg` to a running instance, or claim the instance socket.
+///
+/// Returns `true` when a running instance took the launch (the caller should
+/// exit without starting the engine). Returns `false` when this process is
+/// the instance — with the listener parked for [`spawn_listener`] — or when
+/// single-instance handling failed entirely and the launch should proceed
+/// standalone.
+pub fn forward_or_claim(arg: Option<&str>) -> bool {
+    let path = cce_ui::ipc::socket_path(PREFIX);
+
+    if try_forward(&path, arg) {
+        return true;
+    }
+
+    // Nothing answered. A socket file that still exists is a leftover from a
+    // crashed instance; binding needs it gone.
+    if std::path::Path::new(&path).exists() {
+        let _ = std::fs::remove_file(&path);
+    }
+    match UnixListener::bind(&path) {
+        Ok(listener) => {
+            *CLAIMED.lock().unwrap() = Some(listener);
+            *OWNED_PATH.lock().unwrap() = Some(path);
+            false
+        }
+        // Lost the bind race to a simultaneous launch: it is the instance.
+        Err(_) => try_forward(&path, arg),
+    }
+}
+
+/// One forwarding attempt. False on any failure — there is no retry inside.
+fn try_forward(path: &str, arg: Option<&str>) -> bool {
+    let Ok(mut stream) = UnixStream::connect(path) else {
+        return false;
+    };
+    // A relative file path is resolved against *this* process's cwd — the
+    // instance's differs, so it must travel absolute.
+    let command = match arg {
+        Some(a) => {
+            let p = std::path::Path::new(a);
+            let abs = if p.exists() {
+                std::fs::canonicalize(p)
+                    .ok()
+                    .and_then(|c| c.to_str().map(String::from))
+            } else {
+                None
+            };
+            format!("open {}\n", abs.as_deref().unwrap_or(a))
+        }
+        None => "new-tab\n".to_string(),
+    };
+    if stream.write_all(command.as_bytes()).is_err() {
+        return false;
+    }
+    // Wait for the ack: returning (and exiting) on write alone races the
+    // instance actually reading the line.
+    let mut reply = String::new();
+    BufReader::new(stream).read_line(&mut reply).is_ok()
+}
+
+/// Adopt the listener claimed in `main()` and serve it on a thread, pushing
+/// each received launch into the app's calloop channel. No-op when this
+/// process runs standalone.
+pub fn spawn_listener(sender: calloop::channel::Sender<Message>) {
+    let Some(listener) = CLAIMED.lock().unwrap().take() else {
+        return;
+    };
+    std::thread::spawn(move || {
+        for conn in listener.incoming() {
+            let Ok(conn) = conn else { continue };
+            let mut reader = BufReader::new(conn);
+            let mut line = String::new();
+            if reader.read_line(&mut line).is_err() {
+                continue;
+            }
+            let msg = match parse_command(line.trim()) {
+                Some(m) => m,
+                None => continue,
+            };
+            if sender.send(msg).is_err() {
+                return; // channel gone: the app is shutting down
+            }
+            let _ = reader.get_mut().write_all(b"ok\n");
+        }
+    });
+}
+
+/// `open <arg>` / `new-tab` → the message the app loop handles.
+fn parse_command(line: &str) -> Option<Message> {
+    if let Some(arg) = line.strip_prefix("open ") {
+        let arg = arg.trim();
+        (!arg.is_empty()).then(|| Message::OpenExternal(Some(arg.to_string())))
+    } else if line == "new-tab" {
+        Some(Message::OpenExternal(None))
+    } else {
+        None
+    }
+}
+
+/// Unlink the socket if this process bound it. Called after the engine loop
+/// returns; a crash skips it, which is what the stale-socket removal in
+/// [`forward_or_claim`] exists for.
+pub fn cleanup() {
+    if let Some(path) = OWNED_PATH.lock().unwrap().take() {
+        let _ = std::fs::remove_file(path);
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn commands_parse() {
+        assert!(matches!(
+            parse_command("open https://example.com"),
+            Some(Message::OpenExternal(Some(u))) if u == "https://example.com"
+        ));
+        assert!(matches!(
+            parse_command("new-tab"),
+            Some(Message::OpenExternal(None))
+        ));
+        assert!(parse_command("open ").is_none());
+        assert!(parse_command("bogus").is_none());
+    }
+}
diff --git a/src/main.rs b/src/main.rs
index 9ef181a..ef2a3a1 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -7,6 +7,7 @@
 //! input events; the URL bar is a small hand-rolled line editor.
 
 mod downloads;
+mod instance;
 mod lineedit;
 mod pages;
 mod settings;
@@ -243,6 +244,10 @@ pub enum Message {
     Spin,
     /// Last tab closed: exit the app.
     Quit,
+    /// A later launch forwarded its argument here (see `instance.rs`):
+    /// `Some` is a URL or file path to open in a new tab, `None` a bare
+    /// launch that becomes a blank tab.
+    OpenExternal(Option<String>),
 }
 
 struct BrowserApp {
@@ -890,6 +895,8 @@ impl Application for BrowserApp {
     type Message = Message;
 
     fn new(_qh: &QueueHandle<EngineState<Self>>, sender: calloop::channel::Sender<Self::Message>) -> Self {
+        // Serve the instance socket claimed in main(), if this launch won it.
+        instance::spawn_listener(sender.clone());
         let settings = settings::load();
         downloads::set_download_dir(settings.download_dir.clone());
         // Optional CLI arg: the start URL (same parsing as the URL bar);
@@ -1014,6 +1021,21 @@ impl Application for BrowserApp {
                 }
             }
             Message::Quit => *exit = true,
+            Message::OpenExternal(arg) => {
+                match arg {
+                    Some(arg) => {
+                        // Same parsing as the launch argument, and for the
+                        // same reason: this *is* one, relayed.
+                        if let Some(url) = parse_startup_arg(&arg, &self.settings.search_prefix) {
+                            self.host.open_tab(url);
+                            self.url_focused = false;
+                            self.sync_page_state();
+                        }
+                    }
+                    None => self.new_tab(),
+                }
+                *needs_rebuild = true;
+            }
         }
     }
 
@@ -1518,7 +1540,14 @@ impl Application for BrowserApp {
 
 fn main() {
     env_logger::init();
+    // Hand the launch to a running instance before any engine work: an
+    // external open (`xdg-open` → `cce-browser %u`) becomes a tab there,
+    // and this process never touches Wayland or the shared profile dir.
+    if instance::forward_or_claim(std::env::args().nth(1).as_deref()) {
+        return;
+    }
     cce_ui::engine::run::<BrowserApp>();
+    instance::cleanup();
 }
 
 #[cfg(test)]