From dc356f0dfc68b77816a80bb6dd6d0eacf7a9d70f Mon Sep 17 00:00:00 2001 From: Dmitriy Bunakalya Date: Fri, 9 Oct 2026 04:04:27 +0400 Subject: [PATCH] Linux: accept file:// URI args so desktop launchers recognise pdfcraft as PDF default (#382) * Linux: accept file:// URI args so desktop launchers recognise pdfcraft as PDF default GNOME Files (and other freedesktop-spec launchers) only register an app as the default handler for a mimetype if its .desktop Exec accepts URIs (%u/%U), not just paths (%F). pdfcraft's entry used %F, so even after `xdg-mime default ai.storyteller.pdfcraft.desktop application/pdf`, double-clicking a PDF in Files silently fell back to another installed viewer. Switch Exec to %u and decode file:// args back to plain paths in main.rs before they reach the rest of the app, which only ever opens paths. Other schemes and plain CLI paths pass through unchanged. Co-Authored-By: Claude Sonnet 5 * path_from_arg: file://localhost, foreign hosts left alone, Windows drive paths, no indexing; Exec %U (several files) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitriy Bunakalya Co-authored-by: Claude Sonnet 5 Co-authored-by: Brandon Thomas --- apps/pdfcraft/src/main.rs | 54 ++++++++++++++++++- .../linux/ai.storyteller.pdfcraft.desktop | 4 +- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index bf3b48833..e40607a85 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -78,6 +78,37 @@ fn migrate_legacy_folders() { } } +/// Desktop launchers (GNOME Files, KDE Dolphin…) only recognise an app as the default handler for a +/// mime type if its `Exec` takes URIs (`%u`/`%U`), not just paths (`%F`); the packaged `.desktop` +/// file uses `%U` accordingly (packaging/linux/ai.storyteller.pdfcraft.desktop). Decode a local +/// `file://` argument (`file:///path` or `file://localhost/path`) to a plain path here so the rest +/// of the app, which only ever opens paths, is unaffected. Other schemes (`http://`, `mailto:`…), +/// URIs naming another host, and plain paths pass through untouched. +fn path_from_arg(arg: String) -> String { + let Some(rest) = arg.strip_prefix("file://") else { return arg }; + let rest = rest.strip_prefix("localhost").unwrap_or(rest); + if !rest.starts_with('/') { + return arg; + } + // `file:///C:/x.pdf` on Windows names `C:/x.pdf`. `rest` starts with the one-byte '/', so + // byte 1 is a char boundary. + let rest = if cfg!(windows) && rest.as_bytes().get(2) == Some(&b':') { &rest[1..] } else { rest }; + let mut out = Vec::with_capacity(rest.len()); + let mut bytes = rest.bytes(); + while let Some(b) = bytes.next() { + if b == b'%' { + let hex = bytes.clone().take(2).collect::>(); + if let Some(byte) = std::str::from_utf8(&hex).ok().filter(|h| h.len() == 2).and_then(|h| u8::from_str_radix(h, 16).ok()) { + out.push(byte); + bytes.nth(1); + continue; + } + } + out.push(b); + } + String::from_utf8(out).unwrap_or(arg) +} + fn main() -> eframe::Result { // First, so the panic hook and every start-up warning are recorded (`logging`). let logger = logging::install(); @@ -115,7 +146,7 @@ fn main() -> eframe::Result { let value = args.next().unwrap_or_default(); options.push((flag.trim_start_matches("--").to_string(), value)); } - _ => files.push(a), + _ => files.push(path_from_arg(a)), } } let integrated = cfg!(target_os = "macos"); @@ -351,6 +382,27 @@ fn pick_adapter(adapters: &[(u32, u32, eframe::wgpu::DeviceType)], displays: &[( #[cfg(test)] mod tests { + #[test] + fn path_from_arg_decodes_file_uris() { + assert_eq!(super::path_from_arg("file:///home/alice/report.pdf".to_string()), "/home/alice/report.pdf"); + assert_eq!(super::path_from_arg("file:///home/alice/my%20report.pdf".to_string()), "/home/alice/my report.pdf"); + assert_eq!(super::path_from_arg("file://localhost/tmp/a%C3%A9.pdf".to_string()), "/tmp/aé.pdf"); + // Malformed or truncated escapes are kept as written; bytes that aren't UTF-8 keep the URI. + assert_eq!(super::path_from_arg("file:///tmp/100%.pdf".to_string()), "/tmp/100%.pdf"); + assert_eq!(super::path_from_arg("file:///tmp/a%2".to_string()), "/tmp/a%2"); + assert_eq!(super::path_from_arg("file:///tmp/%zz%".to_string()), "/tmp/%zz%"); + assert_eq!(super::path_from_arg("file:///tmp/%FF.pdf".to_string()), "file:///tmp/%FF.pdf"); + } + + #[test] + fn path_from_arg_leaves_plain_paths_and_other_schemes_alone() { + assert_eq!(super::path_from_arg("report.pdf".to_string()), "report.pdf"); + assert_eq!(super::path_from_arg("/home/alice/report.pdf".to_string()), "/home/alice/report.pdf"); + assert_eq!(super::path_from_arg("https://example.com/report.pdf".to_string()), "https://example.com/report.pdf"); + assert_eq!(super::path_from_arg("file://server/share/a.pdf".to_string()), "file://server/share/a.pdf"); + assert_eq!(super::path_from_arg("file://".to_string()), "file://"); + } + #[test] fn gpu_backends_avoid_vulkan_on_windows_and_prefer_low_power() { let mut native = eframe::NativeOptions::default(); diff --git a/packaging/linux/ai.storyteller.pdfcraft.desktop b/packaging/linux/ai.storyteller.pdfcraft.desktop index f32d625d7..f7928d62b 100644 --- a/packaging/linux/ai.storyteller.pdfcraft.desktop +++ b/packaging/linux/ai.storyteller.pdfcraft.desktop @@ -3,12 +3,12 @@ Type=Application Name=PdfCraft GenericName=PDF Editor Comment=Read, organize, combine, split and secure PDFs -Exec=pdfcraft %F +Exec=pdfcraft %U TryExec=pdfcraft Icon=ai.storyteller.pdfcraft Terminal=false StartupNotify=true StartupWMClass=pdfcraft -Categories=Office;Viewer;Graphics; +Categories=Office;Viewer; Keywords=pdf;viewer;editor;annotate;sign;forms;merge;split; MimeType=application/pdf;