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
1 change: 0 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion crates/openlogi-agent/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ rust-i18n = "4"
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
sysinfo = { version = "0.38.4", default-features = false, features = ["system"] }
opener = { workspace = true }
succession = { version = "0.2", features = ["supervision", "eviction"] }
tracing-appender = "0.2.5"

Expand Down
84 changes: 58 additions & 26 deletions crates/openlogi-agent/src/tray.rs
Original file line number Diff line number Diff line change
Expand Up @@ -255,34 +255,60 @@ impl MenuTarget {
}
}

fn open_url(url: &str) {
match opener::open(url) {
Ok(()) => info!(url, "menu-bar — opening URL"),
Err(e) => warn!(error = %e, url, "could not open URL from menu bar"),
fn gui_bundle_id_for_profile(dev_profile: bool) -> String {
if dev_profile {
brand::dev_id(brand::APP_ID)
} else {
brand::APP_ID.to_string()
}
}

/// Route a GUI-directed [`DeeplinkCommand`] through the `openlogi://` scheme.
/// macOS launches the GUI (cold start) or hands the URL to the running app.
fn current_gui_bundle_id() -> String {
gui_bundle_id_for_profile(openlogi_core::paths::is_dev_profile())
}

/// Route a GUI-directed [`DeeplinkCommand`] to the GUI bundle that matches this
/// agent's profile. Both prod and dev bundles register `openlogi://`; a generic
/// LaunchServices open can therefore hand a dev-agent click to the installed
/// production app. `open -b` pins delivery to the matching bundle id while still
/// preserving cold-start and warm-reopen Apple Event behaviour.
fn open_command(command: DeeplinkCommand) {
open_url(&command.to_url());
let url = command.to_url();
let bundle_id = current_gui_bundle_id();
match std::process::Command::new("/usr/bin/open")
.arg("-b")
.arg(&bundle_id)
.arg(&url)
.status()
{
Ok(status) if status.success() => {
info!(url, bundle_id, "menu-bar — opening GUI command");
}
Ok(status) => warn!(
%status,
url,
bundle_id,
"could not open GUI command from menu bar"
),
Err(e) => warn!(
error = %e,
url,
bundle_id,
"could not open GUI command from menu bar"
),
}
}

/// Menu-bar Quit: take a running GUI with us, then end the process.
///
/// Kept out of `define_class!` so the lint set actually sees the exit — clippy
/// does not look inside macro expansions.
fn quit_agent() -> ! {
// Tell a *running* GUI to quit too, but don't let `open` cold-launch one
// just to immediately quit it (it would flash a window — and on first run
// the update-consent prompt — before exiting). The gate keeps the target
// warm in the common case, so the blocking `.output()` (which guarantees
// Apple-Event delivery) returns at once; a GUI that races to exit after the
// check was quitting anyway.
// Tell only this profile's *running* GUI to quit. Matching the agent's
// profile matters when a dev bundle coexists with the installed app: seeing
// the other profile must never make us cold-launch this one just to quit it.
if gui_is_running() {
let _ = std::process::Command::new("open")
.arg(DeeplinkCommand::Quit.to_url())
.output();
open_command(DeeplinkCommand::Quit);
}
crate::overlay::evict_on_quit();
info!("menu-bar Quit — exiting agent");
Expand All @@ -293,17 +319,14 @@ fn quit_agent() -> ! {
std::process::exit(0)
}

/// Whether an OpenLogi GUI process is currently running (prod or dev bundle).
/// Used to avoid cold-launching the GUI from the Quit handler just to quit it.
/// Whether the GUI matching this agent's prod/dev profile is currently running.
/// Used to avoid cold-launching that GUI from the Quit handler just to quit it.
fn gui_is_running() -> bool {
// Release and dev; the agent's own id is `brand::AGENT_ID`, so neither
// matches the agent itself.
let dev = brand::dev_id(brand::APP_ID);
[brand::APP_ID, dev.as_str()].iter().any(|id| {
let running =
NSRunningApplication::runningApplicationsWithBundleIdentifier(&NSString::from_str(id));
!running.is_empty()
})
let bundle_id = current_gui_bundle_id();
let running = NSRunningApplication::runningApplicationsWithBundleIdentifier(
&NSString::from_str(&bundle_id),
);
!running.is_empty()
}

/// Run the agent's AppKit main loop: an `Accessory` `NSApplication` (no Dock
Expand Down Expand Up @@ -502,6 +525,15 @@ mod tests {
use super::*;
use openlogi_hid::device_io_channel;

#[test]
fn gui_bundle_id_matches_the_agent_profile() {
assert_eq!(gui_bundle_id_for_profile(false), brand::APP_ID);
assert_eq!(
gui_bundle_id_for_profile(true),
brand::dev_id(brand::APP_ID)
);
}

#[test]
fn overlapping_suspend_sources_all_clear_before_device_io_resumes() {
let (signal, gate) = device_io_channel();
Expand Down
38 changes: 29 additions & 9 deletions crates/openlogi-core/src/binding/button.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ use std::fmt;

use serde::{Deserialize, Serialize};

/// One of the user-rebindable hotspots on a Logi mouse. The order matches the
/// physical layout from front to side; [`ButtonId::ALL`] is consumed by the
/// default-binding generator and the popover trigger list.
/// One user-rebindable Logitech HID control. Mouse controls occupy the legacy
/// declaration prefix; keyboard controls are appended so persisted/binary enum
/// indices remain stable. [`ButtonId::ALL`] contains mouse controls only, while
/// [`ButtonId::KEYBOARD_KEYS`] contains divertable keyboard controls.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub enum ButtonId {
/// The primary button. Rebindable in the config schema, but the OS hook
Expand Down Expand Up @@ -73,10 +74,20 @@ pub enum ButtonId {
/// Tilting the main wheel right — `0x1b04` CID `0x005d` ("Right Scroll"),
/// Logi metadata slot `SLOT_NAME_RIGHT_SCROLL_BUTTON`. Counterpart to
/// [`ButtonId::WheelTiltLeft`].
WheelTiltRight,
/// Keyboard "Calculator" control (CID `0x000a`) — one of the extra
/// programmable controls on full-size MX Mechanical boards.
KeyCalculator,
/// Keyboard "Show Desktop" control (CID `0x006e`) on full-size MX Mechanical
/// boards.
KeyShowDesktop,
/// Keyboard "Lock PC" control (CID `0x006f`) on full-size MX Mechanical
/// boards. The adjacent MultiPlatform Search control already maps to
/// [`ButtonId::KeySearch`] (`0x00d4`).
///
/// Declared last: the TOML config and any serialized form encode the
/// variant identifier / index, so new buttons are append-only.
WheelTiltRight,
KeyLockPC,
}

impl ButtonId {
Expand All @@ -99,11 +110,11 @@ impl ButtonId {
ButtonId::HapticPanel,
];

/// The divertable keyboard F-row controls, in F-row order. Kept out of
/// [`ButtonId::ALL`]: that array seeds mouse defaults and the mouse
/// popover trigger list, while keyboard keys stay native unless the user
/// binds them (an unbound key is never diverted).
pub const KEYBOARD_KEYS: [ButtonId; 9] = [
/// Divertable Logitech keyboard controls. Kept out of [`ButtonId::ALL`]:
/// that array seeds mouse defaults and the mouse popover trigger list, while
/// keyboard controls stay native unless the user binds them (an unbound key
/// is never diverted).
pub const KEYBOARD_KEYS: [ButtonId; 12] = [
ButtonId::KeySearch,
ButtonId::KeyDictation,
ButtonId::KeyEmoji,
Expand All @@ -113,6 +124,9 @@ impl ButtonId {
ButtonId::KeyMute,
ButtonId::KeyVolumeDown,
ButtonId::KeyVolumeUp,
ButtonId::KeyCalculator,
ButtonId::KeyShowDesktop,
ButtonId::KeyLockPC,
];

/// Whether this button is one the OS hook (macOS `CGEventTap` / Linux evdev)
Expand Down Expand Up @@ -165,6 +179,9 @@ impl ButtonId {
ButtonId::KeyMute => "Mute Key",
ButtonId::KeyVolumeDown => "Volume Down Key",
ButtonId::KeyVolumeUp => "Volume Up Key",
ButtonId::KeyCalculator => "Calculator Key",
ButtonId::KeyShowDesktop => "Show Desktop Key",
ButtonId::KeyLockPC => "Lock PC Key",
ButtonId::HapticPanel => "Haptic Panel",
}
}
Expand Down Expand Up @@ -194,6 +211,9 @@ impl ButtonId {
ButtonId::KeyMute => "keyboard.mute_key",
ButtonId::KeyVolumeDown => "keyboard.volume_down_key",
ButtonId::KeyVolumeUp => "keyboard.volume_up_key",
ButtonId::KeyCalculator => "keyboard.calculator_key",
ButtonId::KeyShowDesktop => "keyboard.show_desktop_key",
ButtonId::KeyLockPC => "keyboard.lock_pc_key",
ButtonId::HapticPanel => "actions.haptic_panel",
}
}
Expand Down
5 changes: 4 additions & 1 deletion crates/openlogi-core/src/binding/defaults.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,10 @@ pub fn default_binding(button: ButtonId) -> Action {
| ButtonId::KeyPlayPause
| ButtonId::KeyMute
| ButtonId::KeyVolumeDown
| ButtonId::KeyVolumeUp => Action::None,
| ButtonId::KeyVolumeUp
| ButtonId::KeyCalculator
| ButtonId::KeyShowDesktop
| ButtonId::KeyLockPC => Action::None,
}
}

Expand Down
12 changes: 12 additions & 0 deletions crates/openlogi-core/src/binding/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,18 @@ fn wheel_tilt_defaults_to_the_scroll_its_firmware_already_does() {
}
}

#[test]
fn mx_mechanical_extra_keyboard_controls_default_to_native() {
for key in [
ButtonId::KeyCalculator,
ButtonId::KeyShowDesktop,
ButtonId::KeyLockPC,
] {
assert!(ButtonId::KEYBOARD_KEYS.contains(&key));
assert_eq!(default_binding(key), Action::None);
}
}

#[test]
fn thumbwheel_defaults_match_normalised_native_direction() {
// HID++ capture normalises the per-model firmware polarity to physical
Expand Down
41 changes: 41 additions & 0 deletions crates/openlogi-core/src/config/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,47 @@ fn bindings_roundtrip_per_device() {
assert!(parsed.bindings_for("deadbeef").is_empty());
}

#[test]
fn mx_mechanical_extra_bindings_roundtrip_as_device_controls() {
let mut cfg = Config::default();
let key = "serial:2233sct00sw8";
cfg.set_binding(
key,
ButtonId::KeyCalculator,
Binding::Single(Action::CustomShortcut(
"Ctrl+Alt+N".parse().expect("valid shortcut"),
)),
);
cfg.set_binding(
key,
ButtonId::KeyShowDesktop,
Binding::Single(Action::CustomShortcut(
"Ctrl+Alt+L".parse().expect("valid shortcut"),
)),
);

let body = toml::to_string_pretty(&cfg).expect("serialize");
assert!(body.contains("KeyCalculator"));
assert!(body.contains("KeyShowDesktop"));
assert!(!body.contains("KeyLockPC"));

let parsed = write_and_read(&cfg);
let bindings = parsed.bindings_for(key);
assert_eq!(bindings.len(), 2);
assert_eq!(
bindings.get(&ButtonId::KeyCalculator),
Some(&Binding::Single(Action::CustomShortcut(
"Ctrl+Alt+N".parse().expect("valid shortcut")
)))
);
assert_eq!(
bindings.get(&ButtonId::KeyShowDesktop),
Some(&Binding::Single(Action::CustomShortcut(
"Ctrl+Alt+L".parse().expect("valid shortcut")
)))
);
}

#[test]
fn human_readable_toml_layout() {
let mut cfg = Config::default();
Expand Down
Loading
Loading