Skip to main content

plat/
assoc.rs

1// assoc -- the app's file types and URL schemes (apps/NAME/PACKAGE filetypes= / schemes=)
2// registered for the running program without a setup: developer and portable builds.  The
3// setup (apps/installer), the .deb, MSIX and Flatpak declare them themselves (docs/PACKAGING.md).
4//
5//   package() -> Package                what build_rs.py built in (JFGUI_PACKAGE: display,
6//                                       app_id, categories, filetypes, schemes; ui::main! hands
7//                                       it over with package_set); without it: the app's name,
8//                                       no types
9//   Package::parse(text)                that text ("key=value" lines)
10//   parse_types(text) -> [FileType]     "ext[,ext..]|mime|description[|own]; ..." -- every
11//                                       extension gets the app under "Open with"; "own" types
12//                                       also open with it by default
13//   parse_schemes(text) -> [scheme]     "scheme scheme ..." (';', ',' or blanks)
14//   reg_entries(app, display, exe, types, schemes, app_id) -> [RegValue]   the values below
15//                                       HKCU\Software\Classes the setup and assoc_register write
16//                                       (app_id: the program's AppUserModelID in its ProgIDs,
17//                                       "" = none)
18//   package_entries(&Package, exe) -> [RegValue]   all of a package's values: reg_entries and,
19//                                       with browser=1, the web browser's registration (below)
20//   app_id_boot() -> Option<id>         (ui::main!, Windows) PACKAGE's app id as the process's
21//                                       explicit AppUserModelID (taskbar.rs) once the setup
22//                                       installed this program (install.ini beside it names the
23//                                       id) or --register registered it (the record names it);
24//                                       jfgui_AUMID=ID gives one, jfgui_AUMID=off none.  Every
25//                                       other program keeps the id Windows derives from its path
26//   assoc_conflicts(&Package) -> Result<[Conflict], AssocError>   the defaults of other
27//                                       programs a registration would replace
28//   assoc_register(&Package, take) -> Result<Registered, AssocError>   register this program;
29//                                       take(&Conflict) decides each default of another program
30//                                       (false: it stays, the app is only offered under "Open
31//                                       with"); a registration of before is undone first
32//   assoc_unregister() -> Result<bool, AssocError>   undo exactly what the record lists (false:
33//                                       nothing was registered)
34//   assoc_registered() -> Option<String>   the program the record names
35//   default_browser_status() -> BrowserStatus   (PACKAGE browser=1) is this app the system's web
36//                                       browser: Default, NotDefault, Unknown (browser_status(pkg))
37//   default_browser_request() -> Result<(), AssocError>   let the user make it the default: the
38//                                       system's own page or question (browser_request(pkg))
39//   file_uri_path(arg) -> Option<String>   a local file:// URI as a path (Linux desktops hand
40//                                       files over as URIs for Exec=... %U)
41//   console_print(text)                 stdout, else the console the program was started from
42//                                       (Windows programs of the GUI subsystem: --register)
43//
44// The record (config://assoc.list) lists every key, value and file the registration added and
45// what it replaced; unregistering removes those (a value only while it still holds what was
46// written) and puts back what was there.  Windows (win_assoc.rs): the setup's entries below
47// HKCU\Software\Classes -- the ProgID <app>.<ext> (description, DefaultIcon, shell\open\command
48// = "<exe>" "%1", AppUserModelID = the app id: what Windows asks of a handler with an explicit
49// id for the recent files of the type), .<ext>\OpenWithProgids, for own types the default and
50// Content Type of .<ext>, a scheme's key (URL:<display>, URL Protocol) -- then SHChangeNotify.
51// A default of another program is the extension's default value (HKCU or HKLM) or a key of the
52// same name with another command; the user's own choice in Windows' dialog (UserChoice, hashed) is never
53// changed (Conflict::fixed).  Linux (linux_assoc.rs): $XDG_DATA_HOME/applications/<app_id>.desktop
54// (Exec with %U for schemes, else %F; MimeType=), own types as shared-mime-info XML in
55// $XDG_DATA_HOME/mime/packages/<app_id>.xml, the defaults of own types and schemes in
56// $XDG_CONFIG_HOME/mimeapps.list [Default Applications] (written directly, no xdg-mime); the
57// caches are refreshed by update-mime-database / update-desktop-database when they are there
58// (without update-mime-database the own types are known after the next system update of the
59// MIME database; the .desktop file needs no cache).  A default of another program is an entry
60// in a mimeapps.list or, without one, another installed .desktop file with that MIME type.
61// In an MSIX package or a Flatpak the package declares everything: Unavailable.  Web and
62// Android: Unavailable.
63//
64// Not a permission of the manifest: only what PACKAGE declares is written, in the user's own
65// part of the system (HKCU, $XDG_*_HOME).
66//
67// A web browser (PACKAGE browser=1, schemes= with http and https).  Windows: http and https get no
68// keys of their own (HKCU\Software\Classes\http would stand in for the system's handler); the
69// ProgID <app>URL opens web addresses (URL Protocol, the open command, Application with the
70// name and icon), Software\Clients\StartMenuInternet\<app> is the browser of Default Programs
71// (name, icon, the command, Capabilities: ApplicationName / Description / Icon, StartMenu,
72// URLAssociations http / https -> <app>URL, FileAssociations .<ext> -> the file types' ProgIDs)
73// and Software\RegisteredApplications\<app> names its Capabilities.  Windows 10 and later never
74// let a program take the default itself: browser_request opens Settings at the app's page of the
75// default apps (ms-settings:defaultapps?registeredAppUser=<app>), the user decides there;
76// browser_status reads the user's choice of http and https (UserChoiceLatest, else UserChoice).
77// Linux: the .desktop file says Network;WebBrowser; besides the MIME types; browser_status reads
78// the defaults of x-scheme-handler/http and https from the mimeapps.list files (as xdg-mime and
79// xdg-settings do), browser_request writes the app as the user's default of those and of
80// text/html (and KDE's BrowserApplication in kdeglobals): the user asked for it with the call, and
81// the desktops offer no question of their own.  macOS: browser_status asks Launch Services for
82// http's handler, browser_request sets it for http and https -- macOS asks the user itself ("Do you
83// want to change your default web browser?").  In a Flatpak, an MSIX package, the browser and on
84// Android / iOS: Unknown and Unavailable.
85
86use alloc::format;
87use alloc::string::String;
88use alloc::vec::Vec;
89
90use rt::sync::SpinLock;
91
92/// one file type of PACKAGE filetypes=
93#[derive(Clone, Debug, PartialEq, Eq)]
94pub struct FileType {
95    /// lower case, without the dot
96    pub exts: Vec<String>,
97    pub mime: String,
98    pub desc: String,
99    /// the app is the default program of these extensions
100    pub own: bool,
101}
102
103/// what the app registers (apps/NAME/PACKAGE, built in by build_rs.py)
104#[derive(Clone, Debug, Default, PartialEq, Eq)]
105pub struct Package {
106    /// the app's name (vfs::app_id): Windows ProgIDs <app>.<ext>
107    pub app: String,
108    /// PACKAGE display=: menus, "URL:<display>"
109    pub display: String,
110    /// the reverse-DNS id (PACKAGE app_id=, else org.jflib.<App>): the Linux .desktop id
111    pub app_id: String,
112    /// PACKAGE categories= (the .desktop file)
113    pub categories: String,
114    pub types: Vec<FileType>,
115    pub schemes: Vec<String>,
116    /// PACKAGE browser=1: the app is a web browser (registered as one; default_browser_status,
117    /// default_browser_request)
118    pub browser: bool,
119}
120
121/// a default of another program that a registration would replace
122#[derive(Clone, Debug, PartialEq, Eq)]
123pub struct Conflict {
124    /// ".ext" (an own file type), "scheme:" (a URL scheme), a ProgID or a file that is in the way
125    pub what: String,
126    /// who has it now: a ProgID or command (Windows), a .desktop id or file (Linux)
127    pub holder: String,
128    /// the user chose that program in the system's dialog (Windows UserChoice): it stays,
129    /// take is not asked
130    pub fixed: bool,
131}
132
133/// what assoc_register did
134#[derive(Clone, Debug, Default, PartialEq, Eq)]
135pub struct Registered {
136    /// the program the entries start
137    pub exe: String,
138    /// file types and schemes registered
139    pub types: usize,
140    pub schemes: usize,
141    /// the defaults of other programs that stayed (take said no, or fixed)
142    pub kept: Vec<Conflict>,
143    /// hints (a missing cache tool ...)
144    pub notes: Vec<String>,
145}
146
147/// why a registration failed
148#[derive(Clone, Debug, PartialEq, Eq)]
149#[non_exhaustive]
150pub enum AssocError {
151    /// not here: web, Android, an MSIX package or a Flatpak (the package declares the types)
152    Unavailable,
153    /// PACKAGE has no filetypes= and no schemes=
154    Empty,
155    /// a file of another program is in the way and take said no (Linux: the .desktop file)
156    Declined(Conflict),
157    /// the system refused or failed: what it said
158    Failed(String),
159    /// (default_browser_request) the app is not registered as a web browser here: PACKAGE
160    /// without browser=1, or neither the package nor --register registered this program
161    NotRegistered,
162}
163
164impl core::fmt::Display for AssocError {
165    fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
166        match self {
167            AssocError::Unavailable => f.write_str("file associations cannot be registered here (the package declares them)"),
168            AssocError::Empty => f.write_str("PACKAGE declares no filetypes= and no schemes="),
169            AssocError::Declined(c) => write!(f, "{} belongs to {} and was kept", c.what, c.holder),
170            AssocError::Failed(s) => write!(f, "file associations: {s}"),
171            AssocError::NotRegistered => f.write_str("the app is not registered as a web browser (PACKAGE browser=1, the package or --register)"),
172        }
173    }
174}
175
176static PACKAGE: SpinLock<Option<Package>> = SpinLock::new(None);
177
178/// (ui::main!) keep the built-in PACKAGE text (JFGUI_PACKAGE)
179#[doc(hidden)]
180pub fn package_set(text: Option<&str>) {
181    *PACKAGE.lock() = text.map(Package::parse);
182}
183
184/// what the app registers: the built-in PACKAGE, else the app's name without types
185pub fn package() -> Package {
186    if let Some(p) = PACKAGE.lock().clone() {
187        return p;
188    }
189    Package::parse("")
190}
191
192impl Package {
193    /// "key=value" lines: display, app_id, categories, filetypes, schemes, browser (the app is
194    /// vfs::app_id; missing values get the defaults of tools/pkgmeta.py)
195    pub fn parse(text: &str) -> Package {
196        let app = crate::vfs::app_id();
197        let mut p = Package { app: app.clone(), ..Package::default() };
198        for line in text.lines() {
199            let Some((k, v)) = line.split_once('=') else { continue };
200            let v = v.trim();
201            match k.trim() {
202                "display" => p.display = String::from(v),
203                "app_id" => p.app_id = String::from(v),
204                "categories" => p.categories = String::from(v),
205                "filetypes" => p.types = parse_types(v),
206                "schemes" => p.schemes = parse_schemes(v),
207                "browser" => p.browser = v == "1",
208                _ => {}
209            }
210        }
211        if p.display.is_empty() || p.display.contains(['\n', '\r', '\t']) {
212            p.display = app.clone();
213        }
214        if p.app_id.is_empty() || !p.app_id.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'_' || c == b'.') {
215            let clean: String = app.chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }).collect();
216            p.app_id = format!("org.jflib.{clean}");
217        }
218        if p.categories.is_empty() || p.categories.contains(['\n', '\r', '\t']) {
219            p.categories = String::from("Utility;");
220        }
221        p
222    }
223
224    /// the MIME type of a file name by its extension (own file types first), None when unknown
225    pub fn mime_of(&self, path: &str) -> Option<String> {
226        let name = &path[path.rfind(['/', '\\']).map_or(0, |k| k + 1)..];
227        let ext = name.rsplit_once('.')?.1.to_ascii_lowercase();
228        self.types.iter().find(|t| t.exts.contains(&ext)).map(|t| t.mime.clone())
229    }
230}
231
232fn name_ok(s: &str) -> bool {
233    !s.is_empty() && s.len() <= 32 && s.bytes().all(|c| c.is_ascii_alphanumeric() || b"-_+.".contains(&c))
234}
235
236/// "ext[,ext..]|mime|description[|own]; ..." -> the valid entries
237pub fn parse_types(text: &str) -> Vec<FileType> {
238    let mut out = Vec::new();
239    for entry in text.split(';') {
240        let f: Vec<&str> = entry.split('|').map(str::trim).collect();
241        if f.len() < 3 || f.len() > 4 || (f.len() == 4 && f[3] != "own") {
242            continue;
243        }
244        let exts: Vec<String> = f[0].split(',').map(|e| e.trim().trim_start_matches('.').to_ascii_lowercase()).filter(|e| name_ok(e)).collect();
245        if exts.is_empty() || !f[1].contains('/') || f[1].contains(['\\', '"', '\t', '\n']) || f[2].contains(['"', '\t', '\n']) {
246            continue;
247        }
248        out.push(FileType { exts, mime: String::from(f[1]), desc: String::from(f[2]), own: f.len() == 4 });
249    }
250    out
251}
252
253/// "scheme scheme; ..." -> the valid schemes (lower case)
254pub fn parse_schemes(text: &str) -> Vec<String> {
255    text.split([';', ' ', ',']).map(|s| s.trim().to_ascii_lowercase()).filter(|s| name_ok(s) && s.as_bytes()[0].is_ascii_alphabetic()).collect()
256}
257
258/// a value below HKCU (reg_entries): key, name ("" = the default value), REG_SZ value
259#[derive(Clone, Debug, PartialEq, Eq)]
260pub struct RegValue {
261    pub key: String,
262    pub name: String,
263    pub value: String,
264    /// RV_TREE: the app's own key it lies in (a ProgID, a scheme), removed as a whole
265    pub root: String,
266    pub kind: u8,
267}
268
269/// a value in a key of the app's own (RegValue::root)
270pub const RV_TREE: u8 = 0;
271/// a value in a shared key (.ext\OpenWithProgids): only the value is the app's
272pub const RV_SHARED: u8 = 1;
273/// the default (and Content Type) of an extension: the app becomes its default program
274pub const RV_DEFAULT: u8 = 2;
275
276/// the HKCU values of the file types and schemes for the program exe (backslashes): the setup's
277/// and assoc_register's.  app_id: the AppUserModelID the program runs under ("" = none, Windows
278/// derives one): each ProgID names it (AppUserModelID), as Windows wants for the recent files
279/// of an explicit id
280pub fn reg_entries(app: &str, display: &str, exe: &str, types: &[FileType], schemes: &[String], app_id: &str) -> Vec<RegValue> {
281    let mut out = Vec::new();
282    let mut put = |key: &str, name: &str, value: &str, root: &str, kind: u8| {
283        out.push(RegValue { key: String::from(key), name: String::from(name), value: String::from(value), root: String::from(root), kind });
284    };
285    let icon = format!("{exe},0");
286    let cmd = format!("\"{exe}\" \"%1\"");
287    let classes = "Software\\Classes";
288    for t in types {
289        let progid = format!("{app}.{}", t.exts[0]);
290        let key = format!("{classes}\\{progid}");
291        put(&key, "", &t.desc, &key, RV_TREE);
292        put(&format!("{key}\\DefaultIcon"), "", &icon, &key, RV_TREE);
293        put(&format!("{key}\\shell\\open\\command"), "", &cmd, &key, RV_TREE);
294        if !app_id.is_empty() {
295            put(&key, "AppUserModelID", app_id, &key, RV_TREE);
296        }
297        for e in &t.exts {
298            let ext = format!("{classes}\\.{e}");
299            put(&format!("{ext}\\OpenWithProgids"), &progid, "", "", RV_SHARED);
300            if t.own {
301                put(&ext, "", &progid, "", RV_DEFAULT);
302                put(&ext, "Content Type", &t.mime, "", RV_DEFAULT);
303            }
304        }
305    }
306    for s in schemes {
307        let key = format!("{classes}\\{s}");
308        put(&key, "", &format!("URL:{display}"), &key, RV_TREE);
309        put(&key, "URL Protocol", "", &key, RV_TREE);
310        put(&format!("{key}\\DefaultIcon"), "", &icon, &key, RV_TREE);
311        put(&format!("{key}\\shell\\open\\command"), "", &cmd, &key, RV_TREE);
312    }
313    out
314}
315
316/// the schemes a web browser opens through its ProgID <app>URL (Windows: no keys of their own)
317const WEB_SCHEMES: [&str; 2] = ["http", "https"];
318
319/// HKCU\Software\Clients\StartMenuInternet: the web browsers of Windows' Default Programs
320#[cfg_attr(not(windows), allow(dead_code))]
321pub(crate) const BROWSER_CLIENTS: &str = "Software\\Clients\\StartMenuInternet";
322/// HKCU\Software\RegisteredApplications: an app's name -> the key of its Capabilities
323#[cfg_attr(not(windows), allow(dead_code))]
324pub(crate) const REGISTERED_APPS: &str = "Software\\RegisteredApplications";
325
326/// a web browser's scheme (http, https): opened through <app>URL, no key of its own
327pub(crate) fn web_scheme(pkg: &Package, s: &str) -> bool {
328    pkg.browser && WEB_SCHEMES.contains(&s)
329}
330
331/// every HKCU value of pkg for the program exe: reg_entries of its file types and schemes and,
332/// with browser=1, the web browser's registration in place of http's and https's own keys (the
333/// ProgID <app>URL, Software\Clients\StartMenuInternet\<app> with its Capabilities, the value
334/// <app> in Software\RegisteredApplications) -- what the setup and assoc_register write
335pub fn package_entries(pkg: &Package, exe: &str) -> Vec<RegValue> {
336    let schemes: Vec<String> = pkg.schemes.iter().filter(|s| !web_scheme(pkg, s)).cloned().collect();
337    let mut out = reg_entries(&pkg.app, &pkg.display, exe, &pkg.types, &schemes, &pkg.app_id);
338    if !pkg.browser {
339        return out;
340    }
341    let mut put = |key: &str, name: &str, value: &str, root: &str, kind: u8| {
342        out.push(RegValue { key: String::from(key), name: String::from(name), value: String::from(value), root: String::from(root), kind });
343    };
344    let (app, display) = (pkg.app.as_str(), pkg.display.as_str());
345    let icon = format!("{exe},0");
346    // the ProgID of web addresses
347    let progid = format!("{app}URL");
348    let url = format!("Software\\Classes\\{progid}");
349    put(&url, "", &format!("{display} URL"), &url, RV_TREE);
350    put(&url, "URL Protocol", "", &url, RV_TREE);
351    put(&format!("{url}\\DefaultIcon"), "", &icon, &url, RV_TREE);
352    put(&format!("{url}\\shell\\open\\command"), "", &format!("\"{exe}\" \"%1\""), &url, RV_TREE);
353    let app_key = format!("{url}\\Application");
354    put(&app_key, "ApplicationName", display, &url, RV_TREE);
355    put(&app_key, "ApplicationIcon", &icon, &url, RV_TREE);
356    if !pkg.app_id.is_empty() {
357        put(&url, "AppUserModelID", &pkg.app_id, &url, RV_TREE);
358        put(&app_key, "AppUserModelID", &pkg.app_id, &url, RV_TREE);
359    }
360    // the browser of Default Programs
361    let smi = format!("{BROWSER_CLIENTS}\\{app}");
362    let caps = format!("{smi}\\Capabilities");
363    put(&smi, "", display, &smi, RV_TREE);
364    put(&format!("{smi}\\DefaultIcon"), "", &icon, &smi, RV_TREE);
365    put(&format!("{smi}\\shell\\open\\command"), "", &format!("\"{exe}\""), &smi, RV_TREE);
366    put(&caps, "ApplicationName", display, &smi, RV_TREE);
367    put(&caps, "ApplicationDescription", display, &smi, RV_TREE);
368    put(&caps, "ApplicationIcon", &icon, &smi, RV_TREE);
369    put(&format!("{caps}\\StartMenu"), "StartMenuInternet", app, &smi, RV_TREE);
370    for s in pkg.schemes.iter().filter(|s| web_scheme(pkg, s)) {
371        put(&format!("{caps}\\URLAssociations"), s, &progid, &smi, RV_TREE);
372    }
373    for t in &pkg.types {
374        for e in &t.exts {
375            put(&format!("{caps}\\FileAssociations"), &format!(".{e}"), &format!("{app}.{}", t.exts[0]), &smi, RV_TREE);
376        }
377    }
378    put(REGISTERED_APPS, app, &caps, "", RV_SHARED);
379    out
380}
381
382// ---------------------------------------------------------------- the web browser
383
384/// (default_browser_status) is the app the system's web browser
385#[derive(Clone, Copy, Debug, PartialEq, Eq)]
386#[non_exhaustive]
387pub enum BrowserStatus {
388    /// web addresses (http and https) open with this app
389    Default,
390    /// with another program (or the app is no browser: PACKAGE without browser=1)
391    NotDefault,
392    /// the system does not say: no default set (Linux), or not to be found out here (a
393    /// Flatpak, the browser, Android, iOS)
394    Unknown,
395}
396
397/// (PACKAGE browser=1) is this app the system's web browser (the built-in PACKAGE:
398/// browser_status(&package())).  Ask again when the app's window is active again (UIC_ACTIVE):
399/// the user may have changed it in the system's settings meanwhile
400pub fn default_browser_status() -> BrowserStatus {
401    browser_status(&package())
402}
403
404/// is the app of pkg the system's web browser: Windows -- the user's choice of http and https
405/// (UserChoiceLatest, else UserChoice) is its ProgID <app>URL; Linux -- the mimeapps.list files'
406/// defaults of x-scheme-handler/http and https name one of its .desktop files (none named:
407/// Unknown); macOS -- Launch Services' handler of http and https is its bundle
408pub fn browser_status(pkg: &Package) -> BrowserStatus {
409    if !pkg.browser {
410        return BrowserStatus::NotDefault;
411    }
412    native::browser_status(pkg)
413}
414
415/// (PACKAGE browser=1) let the user make this app the system's web browser (the built-in
416/// PACKAGE: browser_request(&package())); call it from the user's click
417pub fn default_browser_request() -> Result<(), AssocError> {
418    browser_request(&package())
419}
420
421/// let the user make the app of pkg the web browser: Windows 10 and later -- Settings opens at
422/// the app's page of the default apps (ms-settings:defaultapps?registeredAppUser=<app>; Windows
423/// lets no program take the default itself), the user picks there; Linux -- the app's installed
424/// .desktop file becomes the user's default of x-scheme-handler/http, https and text/html in
425/// mimeapps.list (and KDE's BrowserApplication), as xdg-settings set default-web-browser does;
426/// macOS -- Launch Services' handler of http and https, macOS asks the user.  Ok: asked or set
427/// (read browser_status again later); NotRegistered: no browser=1, or this program is not
428/// registered as a browser (install the package or run --register first); Unavailable: a
429/// Flatpak, an MSIX package, the browser, Android, iOS.  jfgui_OPEN_DRYRUN=1 (Windows): the
430/// page is printed, not opened
431pub fn browser_request(pkg: &Package) -> Result<(), AssocError> {
432    if !pkg.browser {
433        return Err(AssocError::NotRegistered);
434    }
435    native::browser_request(pkg)
436}
437
438// ---------------------------------------------------------------- the record
439
440const RECORD: &str = "config://assoc.list";
441const RECORD_HEAD: &str = "jflib-assoc 1";
442
443/// one record line: its tab-separated fields
444pub(crate) type Line = Vec<String>;
445
446fn esc(s: &str, out: &mut String) {
447    for c in s.chars() {
448        match c {
449            '\\' => out.push_str("\\\\"),
450            '\t' => out.push_str("\\t"),
451            '\n' => out.push_str("\\n"),
452            '\r' => out.push_str("\\r"),
453            _ => out.push(c),
454        }
455    }
456}
457
458fn unesc(s: &str) -> String {
459    let mut out = String::new();
460    let mut it = s.chars();
461    while let Some(c) = it.next() {
462        if c != '\\' {
463            out.push(c);
464            continue;
465        }
466        match it.next() {
467            Some('t') => out.push('\t'),
468            Some('n') => out.push('\n'),
469            Some('r') => out.push('\r'),
470            Some(o) => out.push(o),
471            None => {}
472        }
473    }
474    out
475}
476
477fn record_text(exe: &str, lines: &[Line]) -> String {
478    let mut s = String::from(RECORD_HEAD);
479    s.push_str("\nexe\t");
480    esc(exe, &mut s);
481    s.push('\n');
482    for l in lines {
483        for (k, f) in l.iter().enumerate() {
484            if k > 0 {
485                s.push('\t');
486            }
487            esc(f, &mut s);
488        }
489        s.push('\n');
490    }
491    s
492}
493
494/// the record -> (exe, lines); None without one (or a damaged one)
495fn record_read() -> Option<(String, Vec<Line>)> {
496    let data = crate::read_file(RECORD)?;
497    let text = String::from_utf8(data).ok()?;
498    let mut it = text.lines();
499    if it.next() != Some(RECORD_HEAD) {
500        return None;
501    }
502    let mut exe = String::new();
503    let mut lines = Vec::new();
504    for l in it.filter(|l| !l.is_empty()) {
505        let f: Line = l.split('\t').map(unesc).collect();
506        if f[0] == "exe" {
507            exe = f.get(1).cloned().unwrap_or_default();
508        } else {
509            lines.push(f);
510        }
511    }
512    Some((exe, lines))
513}
514
515// ---------------------------------------------------------------- the API
516
517/// the program the entries would start: this executable (Linux: the AppImage when it runs
518/// from one)
519#[cfg(any(windows, target_os = "linux"))]
520fn program() -> Result<String, AssocError> {
521    #[cfg(target_os = "linux")]
522    if let Some(p) = crate::env("APPIMAGE").filter(|p| p.starts_with('/')) {
523        return Ok(p);
524    }
525    let exe = crate::current_exe().ok_or_else(|| AssocError::Failed(String::from("no path of the program")))?;
526    Ok(if cfg!(windows) { exe.replace('/', "\\") } else { exe })
527}
528
529#[cfg(not(any(windows, target_os = "linux")))]
530fn program() -> Result<String, AssocError> {
531    Err(AssocError::Unavailable)
532}
533
534/// the defaults of other programs a registration of pkg would replace (nothing is written)
535pub fn assoc_conflicts(pkg: &Package) -> Result<Vec<Conflict>, AssocError> {
536    if pkg.types.is_empty() && pkg.schemes.is_empty() {
537        return Err(AssocError::Empty);
538    }
539    native::available()?;
540    let exe = program()?;
541    let mine = record_read().map(|(_, l)| l).unwrap_or_default();
542    Ok(native::conflicts(pkg, &exe, &mine))
543}
544
545/// register this program for pkg's file types and schemes; take(conflict) -> replace that
546/// default of another program
547pub fn assoc_register(pkg: &Package, take: &mut dyn FnMut(&Conflict) -> bool) -> Result<Registered, AssocError> {
548    if pkg.types.is_empty() && pkg.schemes.is_empty() {
549        return Err(AssocError::Empty);
550    }
551    native::available()?;
552    let exe = program()?;
553    assoc_unregister()?;
554    let mut lines: Vec<Line> = Vec::new();
555    let mut done = Registered { exe: exe.clone(), ..Registered::default() };
556    if let Err(e) = native::register(pkg, &exe, take, &mut lines, &mut done) {
557        native::undo(&lines);
558        native::changed(&mut Vec::new());
559        return Err(e);
560    }
561    let dir_ok = crate::vfs::app_dir(crate::vfs::APP_CONFIG).is_some_and(|d| crate::mkdir(&d));
562    if !dir_ok || !crate::write_file(RECORD, record_text(&exe, &lines).as_bytes()) {
563        native::undo(&lines);
564        native::changed(&mut Vec::new());
565        return Err(AssocError::Failed(String::from("the record config://assoc.list was not written")));
566    }
567    native::changed(&mut done.notes);
568    Ok(done)
569}
570
571/// undo the registration of the record -> Ok(false) when there was none
572pub fn assoc_unregister() -> Result<bool, AssocError> {
573    let Some((_, lines)) = record_read() else {
574        return if crate::file_exists(RECORD) == 1 {
575            Err(AssocError::Failed(String::from("config://assoc.list is damaged")))
576        } else {
577            Ok(false)
578        };
579    };
580    native::undo(&lines);
581    if !crate::delete(RECORD) {
582        return Err(AssocError::Failed(String::from("config://assoc.list was not removed")));
583    }
584    native::changed(&mut Vec::new());
585    Ok(true)
586}
587
588/// the program a registration of this app starts (the record), None without one
589pub fn assoc_registered() -> Option<String> {
590    record_read().map(|(exe, _)| exe)
591}
592
593/// (ui::main!) Windows: PACKAGE's app id as the process's explicit AppUserModelID when this
594/// program was installed by the setup (install.ini beside it names the id) or registered with
595/// --register (the record names this program); jfgui_AUMID=ID gives one, =off none -> the id
596/// set.  Other programs (and other systems) keep the id the system derives
597#[doc(hidden)]
598pub fn app_id_boot() -> Option<String> {
599    #[cfg(windows)]
600    {
601        let id = match crate::env("jfgui_AUMID").as_deref() {
602            Some("off") => return None,
603            Some(id) if !id.is_empty() => String::from(id),
604            _ => {
605                let pkg = PACKAGE.lock().clone()?;
606                let exe = program().ok()?;
607                let line = format!("app_id={}", pkg.app_id);
608                let installed = exe.rfind('\\')
609                    .and_then(|k| crate::read_file_raw(&format!("{}\\install.ini", &exe[..k])))
610                    .and_then(|d| String::from_utf8(d).ok())
611                    .is_some_and(|t| t.lines().any(|l| l.trim() == line));
612                let registered = assoc_registered().is_some_and(|p| p.replace('/', "\\").eq_ignore_ascii_case(&exe));
613                if !installed && !registered {
614                    return None;
615                }
616                pkg.app_id
617            }
618        };
619        if crate::taskbar::set_app_user_model_id(&id) {
620            return Some(id);
621        }
622    }
623    None
624}
625
626fn hexval(c: u8) -> Option<u8> {
627    match c {
628        b'0'..=b'9' => Some(c - b'0'),
629        b'a'..=b'f' => Some(c - b'a' + 10),
630        b'A'..=b'F' => Some(c - b'A' + 10),
631        _ => None,
632    }
633}
634
635/// %XX sequences decoded (None: a broken one or no UTF-8)
636pub(crate) fn percent_decode(s: &str) -> Option<String> {
637    let b = s.as_bytes();
638    let mut out = Vec::with_capacity(b.len());
639    let mut i = 0;
640    while i < b.len() {
641        if b[i] == b'%' {
642            let hi = hexval(*b.get(i + 1)?)?;
643            let lo = hexval(*b.get(i + 2)?)?;
644            out.push(hi << 4 | lo);
645            i += 3;
646        } else {
647            out.push(b[i]);
648            i += 1;
649        }
650    }
651    String::from_utf8(out).ok()
652}
653
654/// a path as a file:// URI: everything but unreserved characters and /:@!$&'()*+,;= escaped
655/// (Windows: file:///C:/dir/a.txt)
656pub(crate) fn file_uri(path: &str) -> String {
657    let p = path.replace('\\', "/");
658    let mut s = String::from(if p.starts_with('/') { "file://" } else { "file:///" });
659    for &c in p.as_bytes() {
660        if c.is_ascii_alphanumeric() || b"-._~/:@!$&'()*+,;=".contains(&c) {
661            s.push(c as char);
662        } else {
663            s.push_str(&format!("%{c:02X}"));
664        }
665    }
666    s
667}
668
669/// a local file:// URI as a path ("file:///home/a%20b.txt" -> "/home/a b.txt", Windows
670/// "file:///C:/a.txt" -> "C:/a.txt"); None for anything else (another host, no URI)
671pub fn file_uri_path(arg: &str) -> Option<String> {
672    let rest = arg.strip_prefix("file://").or_else(|| arg.strip_prefix("FILE://"))?;
673    let slash = rest.find('/')?;
674    let host = &rest[..slash];
675    if !host.is_empty() && !host.eq_ignore_ascii_case("localhost") {
676        return None;
677    }
678    let path = rest[slash..].split(['?', '#']).next().unwrap_or("");
679    let path = percent_decode(path)?;
680    if path.contains('\0') {
681        return None;
682    }
683    let b = path.as_bytes();
684    // /C:/dir -> C:/dir
685    if cfg!(windows) && b.len() >= 3 && b[0] == b'/' && b[1].is_ascii_alphabetic() && b[2] == b':' {
686        return Some(String::from(&path[1..]));
687    }
688    Some(path)
689}
690
691/// text on stdout, else (Windows, a GUI program started from a console) on the console of
692/// the parent process
693pub fn console_print(text: &str) {
694    #[cfg(windows)]
695    if native::console_print(text) {
696        return;
697    }
698    crate::write_stdout(text.as_bytes());
699}
700
701/// (tests, Windows) HKCU\sub: name Some(n) its string value ("" = the default value), None:
702/// Some("") when the key exists
703#[cfg(windows)]
704#[doc(hidden)]
705pub fn hkcu_get(sub: &str, name: Option<&str>) -> Option<String> {
706    native::hkcu_get(sub, name)
707}
708
709#[cfg(windows)]
710#[path = "win_assoc.rs"]
711mod native;
712
713#[cfg(target_os = "linux")]
714#[path = "linux_assoc.rs"]
715mod native;
716
717#[cfg(not(any(windows, target_os = "linux")))]
718mod native {
719    use super::{AssocError, Conflict, Line, Package, Registered};
720    use alloc::string::String;
721    use alloc::vec::Vec;
722
723    pub fn available() -> Result<(), AssocError> {
724        Err(AssocError::Unavailable)
725    }
726    pub fn conflicts(_: &Package, _: &str, _: &[Line]) -> Vec<Conflict> {
727        Vec::new()
728    }
729    pub fn register(_: &Package, _: &str, _: &mut dyn FnMut(&Conflict) -> bool, _: &mut Vec<Line>, _: &mut Registered) -> Result<(), AssocError> {
730        Err(AssocError::Unavailable)
731    }
732    pub fn undo(_: &[Line]) {}
733    pub fn changed(_: &mut Vec<String>) {}
734    #[cfg(target_os = "macos")]
735    pub use super::mac_browser::{browser_request, browser_status};
736    #[cfg(not(target_os = "macos"))]
737    pub fn browser_status(_: &Package) -> super::BrowserStatus {
738        super::BrowserStatus::Unknown
739    }
740    #[cfg(not(target_os = "macos"))]
741    pub fn browser_request(_: &Package) -> Result<(), AssocError> {
742        Err(AssocError::Unavailable)
743    }
744}
745
746// macOS: the default web browser through Launch Services
747#[cfg(target_os = "macos")]
748#[path = "apple_assoc.rs"]
749mod mac_browser;