Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions crates/openlogi-core/src/app_selector.rs
Original file line number Diff line number Diff line change
@@ -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:<filename>.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<String, T>, app: &str) -> Option<&'a T> {
if let Some(exact) = overlays.get(app) {
return Some(exact);
}

overlays.get(&executable_selector(app)?)
}

/// The `exe:<filename>` 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<String> {
// `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<String, &'static str> {
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"));
}
}
54 changes: 53 additions & 1 deletion crates/openlogi-core/src/binding/action_ring.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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:<filename>.exe` selector
/// covers the ring as well as the button bindings.
#[must_use]
pub fn effective_layout(&self, app_id: Option<&str>) -> ActionRingLayout {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Non-Windows .exe identifiers collide

If a macOS bundle ID or Linux application ID ends in .exe, has no exact ring entry, and shares its name with an exe: entry, overlay_for treats it as a Windows executable selector and applies an unrelated Actions Ring layout.

Knowledge Base Used: openlogi-core

Fix in Codex Fix in Claude Code

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())
}
Expand Down Expand Up @@ -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);
}
}
30 changes: 4 additions & 26 deletions crates/openlogi-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand All @@ -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,
Expand Down Expand Up @@ -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()));
Expand Down Expand Up @@ -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())
})
}

Expand Down Expand Up @@ -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:<filename>` 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<String, T>, 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()))
})
}
1 change: 1 addition & 0 deletions crates/openlogi-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#![deny(missing_docs)]

pub mod action_ring;
mod app_selector;
pub mod binding;
pub mod bindings;
pub mod brand;
Expand Down
3 changes: 2 additions & 1 deletion docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<filename>.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
Expand Down