From c1be234ba56951bdb266890c7741b4c398d1c5ea Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 14 Aug 2026 02:16:31 +0800 Subject: [PATCH] fix(core): apply application selectors to Actions Ring layouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit action_ring.per_app kept looking the foreground identifier up verbatim. Both maps are keyed by the same identifier, so on Windows a Store or self-updating app could keep its button overlay across an update while silently losing its ring layout — the versioned path the ring was keyed by no longer exists. The resolution moves to a shared app_selector module both maps use, so a selector cannot mean one thing for buttons and another for the ring. Behavior for macOS bundle ids and Linux classes is unchanged: an identifier that names no .exe never acquires a fallback. --- crates/openlogi-core/src/app_selector.rs | 114 ++++++++++++++++++ .../openlogi-core/src/binding/action_ring.rs | 54 ++++++++- crates/openlogi-core/src/config.rs | 30 +---- crates/openlogi-core/src/lib.rs | 1 + docs/CONFIGURATION.md | 3 +- 5 files changed, 174 insertions(+), 28 deletions(-) create mode 100644 crates/openlogi-core/src/app_selector.rs diff --git a/crates/openlogi-core/src/app_selector.rs b/crates/openlogi-core/src/app_selector.rs new file mode 100644 index 000000000..4fe1de616 --- /dev/null +++ b/crates/openlogi-core/src/app_selector.rs @@ -0,0 +1,114 @@ +//! Resolving a foreground-application identifier against per-app config keys. +//! +//! Per-app overlays are keyed by whatever the platform reports as the frontmost +//! application: a bundle identifier on macOS, a `WM_CLASS` or xdg app id on +//! Linux, and a lower-cased executable path on Windows. A Windows path is not +//! stable — Store and self-updating applications live under a versioned +//! directory that changes from under the config — so `exe:.exe` is +//! accepted as a fallback selector for the same overlay maps. +//! +//! Every map keyed by that identifier resolves through [`overlay_for`], so a +//! selector means the same thing wherever it is written: button overlays and +//! Actions Ring layouts cannot disagree about which application is in front. + +use std::collections::BTreeMap; +use std::path::Path; + +/// Resolve the most specific overlay for a foreground identifier. +/// +/// An exact key always wins, so a per-path overlay still beats the +/// executable-name fallback when a config carries both. +pub(crate) fn overlay_for<'a, T>(overlays: &'a BTreeMap, app: &str) -> Option<&'a T> { + if let Some(exact) = overlays.get(app) { + return Some(exact); + } + + overlays.get(&executable_selector(app)?) +} + +/// The `exe:` selector a Windows-style identifier falls back to, or +/// `None` for an identifier that does not name an executable — macOS bundle ids +/// and Linux application classes must never acquire one by accident. +fn executable_selector(app: &str) -> Option { + // `rsplit` always yields, so this is the trailing path component, or the + // whole identifier when it carries no separator. Both separators are + // recognized so a Windows config stays inspectable on any platform. + let executable_name = app.rsplit(['\\', '/']).next().unwrap_or(app); + if executable_name.is_empty() + || !Path::new(executable_name) + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("exe")) + { + return None; + } + Some(format!("exe:{}", executable_name.to_ascii_lowercase())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn overlays(keys: &[&str]) -> BTreeMap { + keys.iter() + .map(|key| ((*key).to_string(), "overlay")) + .collect() + } + + #[test] + fn exact_key_wins_over_the_executable_fallback() { + let mut map = BTreeMap::new(); + map.insert( + r"c:\program files\windowsapps\sharex_16.0_x64\sharex.exe".to_string(), + "exact", + ); + map.insert("exe:sharex.exe".to_string(), "fallback"); + assert_eq!( + overlay_for( + &map, + r"c:\program files\windowsapps\sharex_16.0_x64\sharex.exe" + ), + Some(&"exact") + ); + } + + #[test] + fn a_versioned_path_falls_back_to_the_executable_name() { + let map = overlays(&["exe:sharex.exe"]); + // The install directory carries the version, so only the basename is + // stable across updates. + for path in [ + r"c:\program files\windowsapps\sharex_16.0_x64\sharex.exe", + r"c:\program files\windowsapps\sharex_17.1_x64\sharex.exe", + "/c/program files/sharex/sharex.exe", + "sharex.exe", + ] { + assert_eq!(overlay_for(&map, path), Some(&"overlay"), "{path}"); + } + } + + #[test] + fn identifiers_that_name_no_executable_never_fall_back() { + let map = overlays(&["exe:code.exe", "exe:.exe"]); + // macOS bundle ids and Linux classes must not be reinterpreted as paths, + // and a path with no executable name has nothing stable to match on. + for app in [ + "com.microsoft.VSCode", + "Firefox", + "org.mozilla.firefox", + r"c:\program files\microsoft vs code\code.exe.bak", + r"c:\program files\microsoft vs code\", + "", + ] { + assert_eq!(overlay_for(&map, app), None, "{app}"); + } + } + + #[test] + fn a_bundle_id_ending_in_exe_still_resolves_by_its_own_key() { + // Contrived, but it must not be swallowed by the fallback: the exact + // key is what the platform reported. + let mut map = BTreeMap::new(); + map.insert("com.example.exe".to_string(), "exact"); + assert_eq!(overlay_for(&map, "com.example.exe"), Some(&"exact")); + } +} diff --git a/crates/openlogi-core/src/binding/action_ring.rs b/crates/openlogi-core/src/binding/action_ring.rs index 442271e76..43809373e 100644 --- a/crates/openlogi-core/src/binding/action_ring.rs +++ b/crates/openlogi-core/src/binding/action_ring.rs @@ -11,6 +11,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; use thiserror::Error; use super::Action; +use crate::app_selector::overlay_for; mod icon; @@ -301,10 +302,14 @@ impl ActionRingConfig { } /// Resolve the complete layout for the foreground application. + /// + /// Keys are matched the same way as every other per-app overlay (see + /// [`crate::app_selector`]), so a Windows `exe:.exe` selector + /// covers the ring as well as the button bindings. #[must_use] pub fn effective_layout(&self, app_id: Option<&str>) -> ActionRingLayout { app_id - .and_then(|app| self.per_app.get(app)) + .and_then(|app| overlay_for(&self.per_app, app)) .cloned() .unwrap_or_else(|| self.default.clone()) } @@ -471,4 +476,51 @@ Bottom = { action = { CustomShortcut = "Cmd+Shift+P" } } assert_eq!(config.effective_layout(Some("com.apple.Safari")), safari); assert_eq!(config.effective_layout(Some("other")), config.default); } + + fn single_slot_layout(action: Action) -> ActionRingLayout { + ActionRingLayout { + slots: BTreeMap::from([( + ActionRingSlot::Top, + ActionRingEntry::new( + RingAction::new(action).unwrap_or_else(|error| panic!("{error}")), + ), + )]), + } + } + + #[test] + fn a_windows_executable_selector_covers_the_ring() { + let mut config = ActionRingConfig::default(); + let sharex = single_slot_layout(Action::Copy); + config + .per_app + .insert("exe:sharex.exe".to_string(), sharex.clone()); + + // The install directory carries the version, so the layout has to + // survive an update that moves the executable. + assert_eq!( + config.effective_layout(Some( + r"c:\program files\windowsapps\sharex_17.1_x64\sharex.exe" + )), + sharex + ); + assert_eq!( + config.effective_layout(Some(r"c:\program files\microsoft vs code\code.exe")), + config.default + ); + } + + #[test] + fn an_exact_ring_path_outranks_the_executable_selector() { + let mut config = ActionRingConfig::default(); + let exact = single_slot_layout(Action::Copy); + let fallback = single_slot_layout(Action::Paste); + let path = r"c:\program files\windowsapps\sharex_17.1_x64\sharex.exe"; + config.per_app.insert(path.to_string(), exact.clone()); + config + .per_app + .insert("exe:sharex.exe".to_string(), fallback); + + assert_eq!(config.effective_layout(Some(path)), exact); + } } diff --git a/crates/openlogi-core/src/config.rs b/crates/openlogi-core/src/config.rs index b673ae4f3..1bdf44f4d 100644 --- a/crates/openlogi-core/src/config.rs +++ b/crates/openlogi-core/src/config.rs @@ -6,7 +6,7 @@ //! as `"receiver:abc123:slot:2"`. Schema migrations branch on //! [`Config::schema_version`]. -use std::{collections::BTreeMap, path::Path}; +use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; @@ -31,6 +31,7 @@ pub use settings::{ WheelMode, clamp_thumbwheel_sensitivity, }; +use crate::app_selector::overlay_for; use crate::binding::{ Action, ActionRingConfig, ActionRingIcon, ActionRingSlot, Binding, ButtonId, GestureDirection, RingAction, default_binding, default_binding_for, default_gesture_binding, @@ -409,7 +410,7 @@ impl Config { }; let mut out = device.bindings.clone(); if let Some(bid) = bundle_id - && let Some(overlay) = app_overlay(&device.per_app_bindings, bid) + && let Some(overlay) = overlay_for(&device.per_app_bindings, bid) { for (k, v) in overlay { out.insert(*k, Binding::Single(v.clone())); @@ -564,7 +565,7 @@ impl Config { #[must_use] pub fn has_app_override(&self, device_key: &str, app: &str) -> bool { self.devices.get(device_key).is_some_and(|d| { - app_overlay(&d.per_app_bindings, app).is_some_and(|overlay| !overlay.is_empty()) + overlay_for(&d.per_app_bindings, app).is_some_and(|overlay| !overlay.is_empty()) }) } @@ -780,26 +781,3 @@ impl Config { .thumbwheel_sensitivity = sensitivity.map(clamp_thumbwheel_sensitivity); } } - -/// Resolve the most specific application overlay for a foreground identifier. -/// -/// Exact keys retain precedence. On Windows the foreground identifier is a -/// lower-cased executable path, so `exe:` provides a stable fallback -/// for Store and self-updating applications whose install directory changes -/// between versions. Recognizing both path separators keeps hand-authored -/// Windows config inspectable on every platform without changing macOS bundle -/// identifiers or Linux application classes. -fn app_overlay<'a, T>(overlays: &'a BTreeMap, app: &str) -> Option<&'a T> { - overlays.get(app).or_else(|| { - let executable_name = app.rsplit(['\\', '/']).next()?; - if executable_name.is_empty() - || !Path::new(executable_name) - .extension() - .is_some_and(|ext| ext.eq_ignore_ascii_case("exe")) - { - return None; - } - - overlays.get(&format!("exe:{}", executable_name.to_ascii_lowercase())) - }) -} diff --git a/crates/openlogi-core/src/lib.rs b/crates/openlogi-core/src/lib.rs index fbdb2c7ea..2721fe6fb 100644 --- a/crates/openlogi-core/src/lib.rs +++ b/crates/openlogi-core/src/lib.rs @@ -7,6 +7,7 @@ #![deny(missing_docs)] pub mod action_ring; +mod app_selector; pub mod binding; pub mod bindings; pub mod brand; diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 2e4bcf424..80a14ea27 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -56,7 +56,8 @@ Common device fields are: - `per_app_bindings`: sparse action overlays keyed by macOS bundle id, Linux application id, exact lower-cased Windows executable path, or `exe:.exe` -- `action_ring`: default and complete per-application eight-slot layouts +- `action_ring`: default and complete per-application eight-slot layouts; + `action_ring.per_app` takes the same selectors as `per_app_bindings` - `lighting`, `smartshift`, standalone `light`, and camera controls / profiles - `host_switch_targets` and `fn_lock` for compatible keyboards - `identity` and `disabled_gestures`, which are application-managed metadata