openlogi_core/app.rs
1//! The foreground application, in the form per-app profiles match against.
2//!
3//! Lives in the platform-free core crate because three layers need the same
4//! shape and none of them can see the others: `openlogi-hook` reads it from the
5//! window server, `openlogi-agent-core` holds it, and `openlogi-ipc` puts it on
6//! the wire. That last one makes this a wire type — see
7//! `crates/openlogi-ipc/AGENTS.md`.
8
9use std::collections::BTreeMap;
10use std::path::Path;
11
12use serde::{Deserialize, Serialize};
13
14/// One application, named the way a per-app profile names it.
15///
16/// [`Self::id`] is the whole of the matching contract; [`Self::display_name`]
17/// exists only so a UI never has to show a reverse-DNS string to a human.
18#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
19pub struct ForegroundApp {
20 /// The exact application identifier a per-app profile key is compared against:
21 ///
22 /// - macOS: the bundle identifier, or the executable path when no bundle
23 /// identifier is available. Paths retain their original spelling and case.
24 /// - Linux: the X11 `WM_CLASS` class or Wayland xdg `app_id`.
25 /// - Windows: the lower-cased executable path.
26 ///
27 /// Those namespaces do not map onto one another by any simple string rule,
28 /// which is why a profile authored under one of them will not match under
29 /// another. See
30 /// [`Config::effective_bindings`](crate::config::Config::effective_bindings)
31 /// for the matcher this feeds.
32 pub id: String,
33 /// Human-readable name for the UI. Equal to [`Self::id`] on the platforms
34 /// that report no name of their own.
35 pub display_name: String,
36}
37
38impl ForegroundApp {
39 /// An application the platform identified but did not name — the display
40 /// name falls back to the identifier, which on those platforms (X11's
41 /// `WM_CLASS`, a Wayland `app_id`) is already close to readable.
42 #[must_use]
43 pub fn unnamed(id: String) -> Self {
44 Self {
45 display_name: id.clone(),
46 id,
47 }
48 }
49}
50
51/// Resolve the most specific per-app entry for a foreground identifier — the
52/// one matcher every per-app map uses, so a selector cannot mean one thing for
53/// button overrides and another for Actions Ring layouts.
54///
55/// The exact key wins. On Windows the identifier is a lower-cased executable
56/// path, so `exe:<filename>` is the stable fallback for Store and self-updating
57/// applications whose install directory changes between versions; both path
58/// separators are recognized so hand-authored Windows config stays inspectable
59/// on every platform. An identifier with no path separator is a macOS bundle
60/// identifier or a Linux application class, never an executable, so a name
61/// that merely ends in `.exe` is not reinterpreted as one.
62#[must_use]
63pub fn overlay_for<'a, T>(overlays: &'a BTreeMap<String, T>, app: &str) -> Option<&'a T> {
64 overlays.get(app).or_else(|| {
65 let (_, executable_name) = app.rsplit_once(['\\', '/'])?;
66 if !Path::new(executable_name)
67 .extension()
68 .is_some_and(|ext| ext.eq_ignore_ascii_case("exe"))
69 {
70 return None;
71 }
72 overlays.get(&format!("exe:{}", executable_name.to_ascii_lowercase()))
73 })
74}
75
76#[cfg(test)]
77mod tests {
78 use super::*;
79
80 fn overlays(keys: &[&str]) -> BTreeMap<String, &'static str> {
81 keys.iter()
82 .map(|key| ((*key).to_string(), "overlay"))
83 .collect()
84 }
85
86 #[test]
87 fn the_exact_key_wins_over_the_executable_fallback() {
88 let mut map = BTreeMap::new();
89 map.insert(
90 r"c:\program files\windowsapps\sharex_16.0_x64\sharex.exe".to_string(),
91 "exact",
92 );
93 map.insert("exe:sharex.exe".to_string(), "fallback");
94 assert_eq!(
95 overlay_for(
96 &map,
97 r"c:\program files\windowsapps\sharex_16.0_x64\sharex.exe"
98 ),
99 Some(&"exact")
100 );
101 }
102
103 #[test]
104 fn a_versioned_path_falls_back_to_the_executable_name() {
105 let map = overlays(&["exe:sharex.exe"]);
106 // The install directory carries the version, so only the basename is
107 // stable across updates.
108 for path in [
109 r"c:\program files\windowsapps\sharex_16.0_x64\sharex.exe",
110 r"c:\program files\windowsapps\sharex_17.1_x64\sharex.exe",
111 r"C:\Program Files\ShareX\ShareX.EXE",
112 "/c/program files/sharex/sharex.exe",
113 ] {
114 assert_eq!(overlay_for(&map, path), Some(&"overlay"), "{path}");
115 }
116 }
117
118 #[test]
119 fn identifiers_that_name_no_executable_never_fall_back() {
120 let map = overlays(&["exe:code.exe", "exe:com.example.exe", "exe:.exe"]);
121 // macOS bundle ids and Linux classes are not paths, even when they end
122 // in `.exe`; a path with no executable name has nothing stable to match.
123 for app in [
124 "com.microsoft.VSCode",
125 "com.example.exe",
126 "Firefox",
127 "code.exe",
128 r"c:\program files\microsoft vs code\code.exe.bak",
129 r"c:\program files\microsoft vs code\",
130 "",
131 ] {
132 assert_eq!(overlay_for(&map, app), None, "{app}");
133 }
134 }
135}