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

commitd7a79846dd2ccff4a4fb0290c88b340d39ac0b2e
parent6d899fdb1b
authorLucas Galante <[email protected]>
date2026-08-16 11:50
feat: ship a desktop entry, and open a local file path from argv

cce-browser had no .desktop at all, so it could never be chosen as the Web Browser default however well it handled URLs — the settings app builds that dropdown from installed entries claiming x-scheme-handler/http, https or text/html, and an app with no entry is not a candidate. It now ships one, alongside the other crates' (see cce-compositor@754d0ba).

Claiming text/html means local .html files arrive here, and the XDG spec lets a launcher pass those for %u "either as a file: URL or as a file path". A plain path was mishandled: parse_url_input is the URL *bar* parser, where a dotted, space-free word is meant to become a domain guess, so /home/me/page.html loaded as https:///home/me/page.html. Startup argv now resolves an existing path to a file: URL first and only falls through to the bar rules otherwise, which keeps typing behaviour untouched.

Note this is reachable even without the text/html claim: picking a browser on that page sets all three types, so the field code has to be honored regardless.

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

 cce-browser.desktop | 12 ++++++++++
 src/main.rs         | 63 ++++++++++++++++++++++++++++++++++++++++++++++++++++-
 2 files changed, 74 insertions(+), 1 deletion(-)

diff --git a/cce-browser.desktop b/cce-browser.desktop
new file mode 100644
index 0000000..0532434
--- /dev/null
+++ b/cce-browser.desktop
@@ -0,0 +1,12 @@
+[Desktop Entry]
+Type=Application
+Name=Browser
+GenericName=Web Browser
+Comment=Browse the web
+Exec=cce-browser %u
+Icon=cce-browser
+Terminal=false
+Categories=Network;WebBrowser;
+MimeType=x-scheme-handler/http;x-scheme-handler/https;text/html;
+StartupNotify=true
+StartupWMClass=cce-browser
diff --git a/src/main.rs b/src/main.rs
index a14e79f..e6fc29b 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -203,6 +203,28 @@ fn parse_url_input(input: &str, search_prefix: &str) -> Option<Url> {
     Url::parse(&format!("{search_prefix}{q}")).ok()
 }
 
+/// Turn the startup argument into something loadable.
+///
+/// This is deliberately not [`parse_url_input`]: that one is the URL *bar*,
+/// where a dotted word is meant to become a domain guess. Argv is different —
+/// the desktop entry claims `text/html`, and the XDG spec lets a launcher pass
+/// a local file for `%u` "either as a file: URL or as a file path". A plain
+/// path takes the domain-guess branch and turns `/home/me/page.html` into
+/// `https:///home/me/page.html`, so an existing path is resolved to a file:
+/// URL first and only a non-path falls through to the bar's parsing.
+fn parse_startup_arg(arg: &str, search_prefix: &str) -> Option<Url> {
+    let path = std::path::Path::new(arg);
+    if path.exists() {
+        // Relative paths need the cwd joined on before file: URL conversion.
+        if let Ok(abs) = std::fs::canonicalize(path) {
+            if let Ok(u) = Url::from_file_path(&abs) {
+                return Some(u);
+            }
+        }
+    }
+    parse_url_input(arg, search_prefix)
+}
+
 fn dom_button(button: MouseButton) -> Option<servo::MouseButton> {
     match button {
         MouseButton::Left => Some(servo::MouseButton::Left),
@@ -437,7 +459,7 @@ impl Application for BrowserApp {
         // otherwise the configured homepage.
         let url = std::env::args()
             .nth(1)
-            .and_then(|arg| parse_url_input(&arg, &settings.search_prefix))
+            .and_then(|arg| parse_startup_arg(&arg, &settings.search_prefix))
             .or_else(|| parse_url_input(&settings.homepage, &settings.search_prefix))
             .unwrap_or_else(|| Url::parse(settings::DEFAULT_HOMEPAGE).expect("home url"));
         let url_input = url.to_string();
@@ -831,3 +853,42 @@ fn main() {
     env_logger::init();
     cce_ui::engine::run::<BrowserApp>();
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    const SEARCH: &str = "https://duckduckgo.com/?q=";
+
+    #[test]
+    fn startup_arg_resolves_an_existing_path_to_a_file_url() {
+        let dir = std::env::temp_dir().join("cce-browser-argv-test");
+        std::fs::create_dir_all(&dir).unwrap();
+        let page = dir.join("page.html");
+        std::fs::write(&page, "<html></html>").unwrap();
+
+        let u = parse_startup_arg(page.to_str().unwrap(), SEARCH).unwrap();
+        assert_eq!(u.scheme(), "file");
+        assert!(u.path().ends_with("page.html"), "got {u}");
+
+        // The bar parser is what this guards against: a dotted, space-free
+        // path takes its bare-host branch and becomes a bogus https URL.
+        let bar = parse_url_input(page.to_str().unwrap(), SEARCH).unwrap();
+        assert_eq!(bar.scheme(), "https");
+
+        std::fs::remove_file(&page).unwrap();
+    }
+
+    #[test]
+    fn startup_arg_still_takes_urls_and_searches() {
+        let u = parse_startup_arg("https://example.com/x", SEARCH).unwrap();
+        assert_eq!(u.as_str(), "https://example.com/x");
+
+        // A bare host that is not a path still guesses https.
+        assert_eq!(parse_startup_arg("example.com", SEARCH).unwrap().scheme(), "https");
+
+        // A non-existent path is not a file: it falls through to the bar rules.
+        let missing = parse_startup_arg("/nonexistent/nope.html", SEARCH).unwrap();
+        assert_ne!(missing.scheme(), "file");
+    }
+}