1use alloc::format;
87use alloc::string::String;
88use alloc::vec::Vec;
89
90use rt::sync::SpinLock;
91
92#[derive(Clone, Debug, PartialEq, Eq)]
94pub struct FileType {
95 pub exts: Vec<String>,
97 pub mime: String,
98 pub desc: String,
99 pub own: bool,
101}
102
103#[derive(Clone, Debug, Default, PartialEq, Eq)]
105pub struct Package {
106 pub app: String,
108 pub display: String,
110 pub app_id: String,
112 pub categories: String,
114 pub types: Vec<FileType>,
115 pub schemes: Vec<String>,
116 pub browser: bool,
119}
120
121#[derive(Clone, Debug, PartialEq, Eq)]
123pub struct Conflict {
124 pub what: String,
126 pub holder: String,
128 pub fixed: bool,
131}
132
133#[derive(Clone, Debug, Default, PartialEq, Eq)]
135pub struct Registered {
136 pub exe: String,
138 pub types: usize,
140 pub schemes: usize,
141 pub kept: Vec<Conflict>,
143 pub notes: Vec<String>,
145}
146
147#[derive(Clone, Debug, PartialEq, Eq)]
149#[non_exhaustive]
150pub enum AssocError {
151 Unavailable,
153 Empty,
155 Declined(Conflict),
157 Failed(String),
159 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#[doc(hidden)]
180pub fn package_set(text: Option<&str>) {
181 *PACKAGE.lock() = text.map(Package::parse);
182}
183
184pub fn package() -> Package {
186 if let Some(p) = PACKAGE.lock().clone() {
187 return p;
188 }
189 Package::parse("")
190}
191
192impl Package {
193 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 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
236pub 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
253pub 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#[derive(Clone, Debug, PartialEq, Eq)]
260pub struct RegValue {
261 pub key: String,
262 pub name: String,
263 pub value: String,
264 pub root: String,
266 pub kind: u8,
267}
268
269pub const RV_TREE: u8 = 0;
271pub const RV_SHARED: u8 = 1;
273pub const RV_DEFAULT: u8 = 2;
275
276pub 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
316const WEB_SCHEMES: [&str; 2] = ["http", "https"];
318
319#[cfg_attr(not(windows), allow(dead_code))]
321pub(crate) const BROWSER_CLIENTS: &str = "Software\\Clients\\StartMenuInternet";
322#[cfg_attr(not(windows), allow(dead_code))]
324pub(crate) const REGISTERED_APPS: &str = "Software\\RegisteredApplications";
325
326pub(crate) fn web_scheme(pkg: &Package, s: &str) -> bool {
328 pkg.browser && WEB_SCHEMES.contains(&s)
329}
330
331pub 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 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 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
386#[non_exhaustive]
387pub enum BrowserStatus {
388 Default,
390 NotDefault,
392 Unknown,
395}
396
397pub fn default_browser_status() -> BrowserStatus {
401 browser_status(&package())
402}
403
404pub fn browser_status(pkg: &Package) -> BrowserStatus {
409 if !pkg.browser {
410 return BrowserStatus::NotDefault;
411 }
412 native::browser_status(pkg)
413}
414
415pub fn default_browser_request() -> Result<(), AssocError> {
418 browser_request(&package())
419}
420
421pub fn browser_request(pkg: &Package) -> Result<(), AssocError> {
432 if !pkg.browser {
433 return Err(AssocError::NotRegistered);
434 }
435 native::browser_request(pkg)
436}
437
438const RECORD: &str = "config://assoc.list";
441const RECORD_HEAD: &str = "jflib-assoc 1";
442
443pub(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
494fn 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#[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
534pub 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
545pub 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
571pub 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
588pub fn assoc_registered() -> Option<String> {
590 record_read().map(|(exe, _)| exe)
591}
592
593#[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
635pub(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
654pub(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
669pub 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 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
691pub 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#[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#[cfg(target_os = "macos")]
748#[path = "apple_assoc.rs"]
749mod mac_browser;