web browser (Servo)
git clone https://git.lucas.co/cce-browser.git
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)]