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