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