How Per‑Application Profiles Are Configured in Open Logi
Open Logi stores per‑application profiles as override tables inside each device's config.toml, with the desktop UI providing selectors to create, edit, and delete these profiles through the AppState and ProfileScopeActions APIs.
Open Logi, an open‑source Logitech HID++ device manager, lets you bind different actions to the same physical controls depending on which application is currently active. This per‑application profile system is implemented across three layers: a TOML‑based configuration in openlogi‑core, a reactive state layer in the desktop app, and a UI feature module that renders the profile selector. This article explains exactly how each layer works, with source‑level references from the AprilNEA/OpenLogi repository.
Data Model: How Profiles Are Stored
Every Logitech device with a stable unit identifier gets its own persistent config.toml. Inside this file, per‑application overrides live in a dedicated table that maps application identifiers to action‑override maps.
Reading Existing Profiles
In crates/openlogi-desktop/src/state.rs, the AppState::app_profiles method surfaces these entries to the UI:
// Extract from state.rs lines 94-100
pub fn app_profiles(&self) -> impl Iterator<Item = (&str, usize)> + '_ {
self.config
.app_profiles(&self.current_device_key)
.map(|id| {
let count = self.config.per_app_overrides(&self.current_device_key, id)
.map(|m| m.len())
.unwrap_or(0);
(id, count)
})
}
Each tuple contains the profile identifier (e.g., com.google.Chrome on macOS or exe:C:\Tools\Zed.exe on Windows) and the number of overridden actions for that app. The Config::app_profiles and Config::per_app_overrides methods in crates/openlogi-core/src/config.rs handle the actual TOML deserialization.
Friendly Names for Unknown Apps
When the agent encounters an application it has never seen, friendly_app_name in crates/openlogi-desktop/src/features/profiles.rs generates a human‑readable label:
// Simplified from profiles.rs lines 66-82
fn friendly_app_name(identifier: &str) -> String {
if let Some(path) = identifier.strip_prefix("exe:") {
Path::new(path).file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| identifier.into())
} else {
// macOS bundle ID: com.company.AppName → "AppName"
identifier.split('.').last()
.map(|s| s.to_string())
.unwrap_or_else(|| identifier.into())
}
}
Configuration Storage in openlogi‑core
The Config struct in crates/openlogi-core/src/config.rs provides the low‑level interface for per‑application profile persistence.
| Method | Purpose | Source Location |
|---|---|---|
Config::app_profiles(device_key) |
Returns all profile identifiers for a device | Lines 665-672 |
Config::per_app_overrides(key, app) |
Returns the HashMap<Action, Target> for a specific app |
Referenced in state.rs lines 101-108 |
These methods abstract the TOML structure so the desktop UI never parses configuration directly. Changes flow downward: UI actions mutate AppState, which delegates to Config methods, which rewrite the config.toml atomically.
UI Representation: The Profile Selector
The desktop application builds a profile scope bar that appears in both the button‑layout editor and the action‑ring editor. Two helper functions construct this UI:
button_profile_scope_bar— for the main button configuration viewaction_ring_profile_scope_bar— for the radial action‑ring editor
Both create a ProfileScopeShell that drives the selector, as seen in crates/openlogi-desktop/src/features/profiles.rs lines 74-110.
ProfileScopeModel Structure
The selector's model, defined at lines 38-42 and populated at lines 71-84, contains three categories of applications:
// Conceptual structure from profiles.rs
pub struct ProfileScopeModel {
pub current_app: Option<String>, // Currently editing this profile
pub persisted_profiles: Vec<AppProfile>, // Already have overrides stored
pub recent_apps: Vec<AppEntry>, // Running apps not yet persisted
pub catalogue_apps: Vec<AppEntry>, // Known applications from system scan
}
The persisted profiles list comes directly from AppState::app_profiles. Recent apps and catalogue entries let users create new profiles without manually typing identifiers.
Creating and Editing Per‑Application Profiles
When a user selects an application from the profile selector, ProfileScopeActions::select updates the editing state:
// From profiles.rs lines 100-106
impl ProfileScopeActions {
pub fn select(&mut self, app_id: String, cx: &mut AppContext) {
AppState::update_bindings(cx, |state| {
state.set_editing_app(Some(app_id));
});
}
}
The UI then displays bindings for that specific application. Any modifications trigger AppState::update_bindings, which ultimately persists changes through Config::set_per_app_override in the core crate.
Practical Example: Adding a Chrome‑Specific Binding
// Start editing the Chrome profile
AppState::update_bindings(cx, |state| {
state.set_editing_app(Some("com.google.Chrome".to_string()));
});
// Add a custom scroll-to-top binding
AppState::update_bindings(cx, |state| {
state.bindings_mut().insert(
openlogi_core::binding::Action::ScrollUp,
openlogi_core::binding::Target::KeyCombo("Cmd+Home".into()),
);
});
Removing Per‑Application Profiles
Profile deletion requires confirmation through a dialog. The UI provides separate confirmation flows for buttons and action rings:
| Dialog Function | Removal Handler |
|---|---|
open_button_remove_confirmation |
AppState::remove_editing_app_profile |
open_action_ring_remove_confirmation |
AppState::remove_editing_action_ring_profile |
Both handlers clear the edit selection and delete the entry from config.toml, as implemented in profiles.rs lines 99-115 and 121-128.
// Simplified removal flow
AppState::update_bindings(cx, |state| {
state.remove_editing_app_profile(); // Clears editing_app + deletes config entry
});
Device Persistence Requirements
Not all connected devices support per‑application profiles. The current_device_is_persistent guard in crates/openlogi-desktop/src/state.rs (lines 85-92) ensures the profile UI only appears for devices with stable unit identifiers—typically Logitech's Unifying receivers or Bolt receivers in persistent mode. Transient BLE probes skip profile configuration entirely.
// From state.rs lines 85-92
pub fn current_device_is_persistent(&self) -> bool {
self.current_device
.as_ref()
.map(|d| d.unit_id().is_some())
.unwrap_or(false)
}
Summary
- Storage: Per‑application profiles live as override tables in each device's
config.toml, managed byConfig::app_profilesandConfig::per_app_overridesinopenlogi‑core. - State:
AppStateinopenlogi‑desktopexposes these profiles throughapp_profiles()and tracks the currently editing application viaediting_app/editing_action_ring_app. - UI: The
ProfileScopeShellinfeatures/profiles.rsrenders a selector with persisted profiles, recent apps, and catalogue entries;ProfileScopeActions::selectinitiates editing. - Lifecycle: Create profiles by selecting apps and updating bindings; remove them through confirmation dialogs that call
remove_editing_app_profile. - Requirements: Profiles only apply to persistent devices with stable unit identifiers, not transient BLE connections.
Frequently Asked Questions
What identifier format does Open Logi use for per‑application profiles?
Open Logi uses platform‑specific binary identifiers: macOS applications use their bundle ID (e.g., com.google.Chrome), while Windows applications use the exe: prefix followed by the full executable path (e.g., exe:C:\Tools\Zed.exe). The friendly_app_name function in profiles.rs derives human‑readable labels from these identifiers when displaying them in the UI.
Can I configure per‑application profiles for any connected Logitech device?
No. Per‑application profile configuration is only available for persistent devices—those with a stable unit identifier that can store a config.toml. The current_device_is_persistent guard checks device.unit_id().is_some(); transient BLE probes or unpaired devices bypass the profile UI entirely.
How does Open Logi handle applications that have never been profiled before?
The profile selector shows recently active applications and catalogue entries alongside already‑persisted profiles. When you select a new app, ProfileScopeActions::select stores it in AppState::editing_app, and the first override you create automatically persists the profile to config.toml via Config::set_per_app_override.
Where are per‑application binding overrides actually written to disk?
All overrides are stored in the per‑device TOML configuration file managed by openlogi‑core. The Config struct in crates/openlogi-core/src/config.rs handles atomic read‑modify‑write cycles; desktop UI code never touches the filesystem directly, instead calling through AppState::update_bindings which delegates to core configuration methods.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →