Skip to main content

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}