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

commit51edbfd14eab41c97f46b858e0ef6c79c1eb9935
parent2e700feb4f
authorLucas Galante <[email protected]>
date2026-08-27 08:46
feat: Ctrl+Shift+O hands the current page to another browser

The escape hatch for pages Servo cannot follow. A Cloudflare challenge
that never completes is not fixable here — the engine cannot pass a
browser-integrity check — so the useful thing is to leave without
retyping the URL.

`browser.external-browser` sets the command; empty asks XDG. The guard is
the point of the empty case: cce-browser's own desktop entry claims
http/https, so the moment it becomes the default handler, `xdg-open` would
hand the page straight back to us. When that is the case it warns instead,
pointing at the config key.

Shadow-verified with a recording stub as the configured command: the
chord handed it the loaded page URL, https://example.com/, verbatim.

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

 src/main.rs     | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++
 src/settings.rs |  9 +++++++++
 2 files changed, 60 insertions(+)

diff --git a/src/main.rs b/src/main.rs
index 4c06990..5c57f0f 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -350,6 +350,52 @@ impl BrowserApp {
         true
     }
 
+    /// Hand the current page to another browser — the escape hatch for the
+    /// places Servo cannot follow, like a Cloudflare challenge that never
+    /// completes.
+    ///
+    /// Prefers the configured command; otherwise asks XDG. The guard matters:
+    /// cce-browser's own desktop entry claims http/https, so once it is the
+    /// default handler, `xdg-open` would hand the page straight back to us.
+    fn open_external(&mut self) {
+        let Some(url) = self
+            .host
+            .url()
+            .map(|u| u.to_string())
+            .or_else(|| parse_url_input(&self.url_input, &self.settings.search_prefix).map(|u| u.to_string()))
+        else {
+            return;
+        };
+        let configured = self.settings.external_browser.clone();
+        std::thread::spawn(move || {
+            let command = match configured {
+                Some(c) => c,
+                None => {
+                    let default = std::process::Command::new("xdg-mime")
+                        .args(["query", "default", "x-scheme-handler/https"])
+                        .output()
+                        .ok()
+                        .and_then(|o| String::from_utf8(o.stdout).ok())
+                        .unwrap_or_default();
+                    if default.trim_start().starts_with("cce-browser") {
+                        log::warn!(
+                            "cce-browser is the default https handler; set browser.external-browser                              to another command or this would just reopen here"
+                        );
+                        return;
+                    }
+                    "xdg-open".to_string()
+                }
+            };
+            let mut parts = command.split_whitespace();
+            let Some(program) = parts.next() else { return };
+            let args: Vec<&str> = parts.collect();
+            match std::process::Command::new(program).args(args).arg(&url).spawn() {
+                Ok(_) => log::info!("handed {url} to {program}"),
+                Err(e) => log::warn!("could not run {program}: {e}"),
+            }
+        });
+    }
+
     /// New blank tab with the URL bar focused for typing.
     fn new_tab(&mut self) {
         let url = Url::parse("about:blank").expect("about:blank");
@@ -779,6 +825,11 @@ impl Application for BrowserApp {
                     *needs_rebuild = true;
                     return None;
                 }
+                // Ctrl+Shift+O: open the current page in another browser.
+                Key::Character(c) if event.shift && c.eq_ignore_ascii_case("o") => {
+                    self.open_external();
+                    return None;
+                }
                 Key::Named(NamedKey::Tab) if count > 1 => {
                     let cur = self.host.active_index();
                     let next = if event.shift { (cur + count - 1) % count } else { (cur + 1) % count };
diff --git a/src/settings.rs b/src/settings.rs
index a5755e3..d6998a2 100644
--- a/src/settings.rs
+++ b/src/settings.rs
@@ -82,6 +82,9 @@ pub struct Settings {
     pub bar_position: BarPosition,
     /// What pages are told to prefer.
     pub color_scheme: ColorScheme,
+    /// Command used to hand the current page to another browser. Empty means
+    /// "ask XDG", which is right until cce-browser is itself the default.
+    pub external_browser: Option<String>,
 }
 
 impl Default for Settings {
@@ -93,6 +96,7 @@ impl Default for Settings {
             history: true,
             bar_position: BarPosition::Top,
             color_scheme: ColorScheme::Dark,
+            external_browser: None,
         }
     }
 }
@@ -134,5 +138,10 @@ pub fn load() -> Settings {
         history: b["history"].as_bool().unwrap_or(true),
         bar_position: BarPosition::from_key(b["bar-position"].as_str().unwrap_or("top")),
         color_scheme: ColorScheme::from_key(b["color-scheme"].as_str().unwrap_or("dark")),
+        external_browser: b["external-browser"]
+            .as_str()
+            .map(str::trim)
+            .filter(|s| !s.is_empty())
+            .map(str::to_string),
     }
 }