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
3 changes: 2 additions & 1 deletion crates/openlogi-agent-core/src/orchestrator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -935,7 +935,8 @@ fn configured_wheel_mode(
.flatten();
let inverted = capabilities
.scroll_inversion
.then(|| device.is_some_and(|d| d.effective_invert_scroll(&route_key)));
.then(|| device.and_then(|d| d.configured_invert_scroll(&route_key)))
.flatten();
(resolution, inverted)
}

Expand Down
17 changes: 17 additions & 0 deletions crates/openlogi-agent-core/src/orchestrator/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,23 @@ fn configured_wheel_mode_leaves_unset_resolution_unmanaged() {
assert_eq!(configured_wheel_mode(&config, &device), (None, None));
}

#[test]
fn configured_wheel_mode_leaves_unset_inversion_unmanaged() {
// Regression for #1205: a device the user never touched must not have
// its native invert bit force-written to `false` on every reconnect —
// `invert_scroll` is a bare bool, so an untouched device and a
// deliberately-off one are otherwise indistinguishable.
let config = Config::default();
let mut device = dev("a", 1, true);
device.capabilities = Some(Capabilities {
hires_wheel: false,
scroll_inversion: true,
..Capabilities::default()
});

assert_eq!(configured_wheel_mode(&config, &device), (None, None));
}

#[test]
fn host_switch_links_keep_sleeping_targets_but_require_online_keyboard() {
let mut config = Config::default();
Expand Down
53 changes: 30 additions & 23 deletions crates/openlogi-agent/src/tray.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ use openlogi_core::config::AppIcon;
use openlogi_hid::DeviceIoSignal;
use tracing::{info, warn};

use crate::shutdown::{self, ShutdownRequestSender};
use crate::status_item;

/// The installed menu-bar item plus the action target its menu items weakly
Expand All @@ -60,10 +59,6 @@ thread_local! {
/// on the main thread, which is the same thread that installed it, so the
/// affinity AppKit demands is the affinity the storage already has.
static TRAY: RefCell<Option<TrayState>> = const { RefCell::new(None) };
/// Where menu actions hand process termination to the async lifecycle.
/// Kept separately because the AppKit loop still exists when the status
/// item is hidden by preference.
static SHUTDOWN_TX: RefCell<Option<ShutdownRequestSender>> = const { RefCell::new(None) };
}

/// The menu-bar glyph for `icon`: a monochrome template the system tints for
Expand Down Expand Up @@ -273,8 +268,7 @@ fn open_command(command: DeeplinkCommand) {
open_url(&command.to_url());
}

/// Menu-bar Quit: take a running GUI with us, then hand process termination to
/// the lifecycle that owns firmware capture and the input hook.
/// 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.
Expand All @@ -291,9 +285,12 @@ fn quit_agent() -> ! {
.output();
}
crate::overlay::evict_on_quit();
info!("menu-bar Quit — requesting graceful agent shutdown");
let requests = SHUTDOWN_TX.with_borrow(Clone::clone);
shutdown::request_tray_quit(requests.as_ref(), 0)
info!("menu-bar Quit — exiting agent");
#[expect(
clippy::exit,
reason = "reached from an AppKit menu action on the main thread: the run loop owns `main`'s stack frame, so no status can travel back to it"
)]
std::process::exit(0)
}

/// Whether an OpenLogi GUI process is currently running (prod or dev bundle).
Expand Down Expand Up @@ -325,13 +322,14 @@ pub fn run_app_loop(
show_in_menu_bar: bool,
app_icon: AppIcon,
device_io_signal: DeviceIoSignal,
shutdown_tx: ShutdownRequestSender,
) -> ! {
SHUTDOWN_TX.with_borrow_mut(|slot| *slot = Some(shutdown_tx));
let Some(mtm) = MainThreadMarker::new() else {
warn!("agent AppKit loop not started off the main thread — exiting");
let requests = SHUTDOWN_TX.with_borrow(Clone::clone);
shutdown::request_tray_quit(requests.as_ref(), 1);
#[expect(
clippy::exit,
reason = "this branch means `run_app_loop` was called off the process main thread, where AppKit cannot run at all; the function is `-> !` and `main` returns `()`, so a failure status has nowhere to propagate"
)]
std::process::exit(1);
};
let app = NSApplication::sharedApplication(mtm);
app.setActivationPolicy(NSApplicationActivationPolicy::Accessory);
Expand All @@ -351,9 +349,11 @@ pub fn run_app_loop(
info!(show_in_menu_bar, "agent AppKit loop started");

app.run();
info!("agent AppKit loop ended — requesting graceful core shutdown");
let requests = SHUTDOWN_TX.with_borrow(Clone::clone);
shutdown::request_tray_quit(requests.as_ref(), 0);
#[expect(
clippy::exit,
reason = "AppKit only returns from `run()` once the loop is torn down, and the agent core is still running on another thread; this function is `-> !` with no return path, so the process ends here"
)]
std::process::exit(0);
}

/// Observe display/session sleep and user-visible resume transitions. Generic
Expand Down Expand Up @@ -502,14 +502,21 @@ mod tests {
use super::*;
use openlogi_hid::device_io_channel;

// Both tests post to the process-wide NSWorkspace notification center.
// Keep each observer's entire registration/posting/removal lifetime isolated
// so one test's session-inactive event cannot suspend the other test's gate.
static WORKSPACE_NOTIFICATIONS: Mutex<()> = Mutex::new(());
/// `NSWorkspace::sharedWorkspace()` and its `notificationCenter()` are a
/// process-global singleton: a notification posted by one test's
/// `install_activity_observer` call is delivered to every other live
/// `ActivityTarget`, this test module's included, regardless of which
/// test posted it. Rust's default parallel test runner would otherwise
/// let these tests corrupt each other's gate state through that shared
/// center — this held for a test's whole body serializes them.
fn workspace_notifications_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: Mutex<()> = Mutex::new(());
LOCK.lock().unwrap_or_else(PoisonError::into_inner)
}

#[test]
fn overlapping_suspend_sources_all_clear_before_device_io_resumes() {
let _notifications = WORKSPACE_NOTIFICATIONS.lock().unwrap();
let _lock = workspace_notifications_lock();
let (signal, gate) = device_io_channel();
let target = install_activity_observer(signal);
target.finish_startup(false);
Expand Down Expand Up @@ -564,7 +571,7 @@ mod tests {

#[test]
fn startup_stays_suspended_when_the_display_is_already_asleep() {
let _notifications = WORKSPACE_NOTIFICATIONS.lock().unwrap();
let _lock = workspace_notifications_lock();
let (signal, gate) = device_io_channel();
let target = install_activity_observer(signal);
assert!(!gate.allows_io(), "startup must fail closed");
Expand Down
25 changes: 25 additions & 0 deletions crates/openlogi-core/src/config/device.rs
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,31 @@ impl DeviceConfig {
.unwrap_or(self.invert_scroll)
}

/// [`Self::effective_invert_scroll`], but `None` when nothing on
/// `route_key` is evidence the user ever touched this device's native
/// wheel inversion.
///
/// `invert_scroll` is a bare `bool` (unlike [`Self::dpi`] /
/// [`Self::smartshift`]'s `Option`), so a device-level `false` is
/// indistinguishable from never-set — see the same ambiguity noted in the
/// `identity` module's legacy-config fold. A link override is still
/// real signal either way, because it is an `Option<bool>`. Reapplying an
/// unconfigured `false` would force every capable device's native invert
/// bit off on every reconnect, silently undoing an inversion set through
/// any other host or tool (#1205) — the device's own power-on default is
/// already "not inverted", so there is nothing to reapply until the user
/// actually chooses a value in OpenLogi.
#[must_use]
pub fn configured_invert_scroll(&self, route_key: &str) -> Option<bool> {
if let Some(value) = self
.link_overrides(route_key)
.and_then(|overrides| overrides.invert_scroll)
{
return Some(value);
}
self.invert_scroll.then_some(true)
}

/// Wheel resolution on `route_key`: the link's override when the user set
/// one there, else the device-level value.
#[must_use]
Expand Down
51 changes: 51 additions & 0 deletions crates/openlogi-core/src/config/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2260,3 +2260,54 @@ fn an_unknown_route_gets_the_device_value() {
Some(Dpi::new(1600))
);
}

#[test]
fn configured_invert_scroll_is_none_when_never_touched() {
// A device-level `false` is indistinguishable from never-set (#1205):
// it must not be reported as configured, or the reapply path would
// force every capable device's native invert bit off on every
// reconnect/reboot.
let device = DeviceConfig::default();
assert_eq!(device.configured_invert_scroll("direct:046d:c08d"), None);
}

#[test]
fn configured_invert_scroll_is_some_when_device_level_is_true() {
let device = DeviceConfig {
invert_scroll: true,
..DeviceConfig::default()
};
assert_eq!(
device.configured_invert_scroll("direct:046d:c08d"),
Some(true)
);
}

#[test]
fn configured_invert_scroll_link_override_wins_even_when_false() {
// Unlike the device-level bool, a link override is an `Option<bool>` and
// so can carry a genuine "user chose false here" signal.
let mut device = DeviceConfig {
invert_scroll: true,
..DeviceConfig::default()
};
device.links.insert(
"receiver:82839805:slot:1".to_string(),
LinkConfig {
capabilities: None,
overrides: LinkOverrides {
invert_scroll: Some(false),
..LinkOverrides::default()
},
},
);

assert_eq!(
device.configured_invert_scroll("receiver:82839805:slot:1"),
Some(false)
);
assert_eq!(
device.configured_invert_scroll("direct:046d:c08d"),
Some(true)
);
}
Loading