diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 646120cd..e82c9bb3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -593,6 +593,10 @@ jobs: mkdir -p dist ref_name="${GITHUB_REF_NAME:-dev}" ref_name="${ref_name//\//-}" + for pkg in target/release/*.tar.gz; do + [ -f "$pkg" ] || continue + cp "$pkg" "dist/openlogi-${ref_name}-linux-${PKG_ARCH}.tar.gz" + done for pkg in target/release/*.deb target/release/*.rpm; do [ -f "$pkg" ] || continue ext="${pkg##*.}" @@ -671,7 +675,7 @@ jobs: # nothing instead of erroring on a literal pattern, so the DMGs are # hashed alone. shopt -s nullglob - sha256sum *.dmg *.exe *.msi *.deb *.rpm > SHA256SUMS + sha256sum *.dmg *.exe *.msi *.tar.gz *.deb *.rpm > SHA256SUMS # softprops' `files` can't list a glob that matches nothing without # tripping fail_on_unmatched_files, and the exe/msi sets vary per arch @@ -747,11 +751,11 @@ jobs: # treatment as the DMGs: manual verification today, and the future # auto-updaters need the detached signatures to exist for every # shipped version. - # nullglob: the exe/msi/deb/rpm sets are best-effort per arch leg, so + # nullglob: the exe/msi/tar/deb/rpm sets are best-effort per arch leg, so # the globs may match nothing; the DMGs are guaranteed by the publish # gate. shopt -s nullglob - for artifact in dist/*.dmg dist/*.exe dist/*.msi dist/*.deb dist/*.rpm; do + for artifact in dist/*.dmg dist/*.exe dist/*.msi dist/*.tar.gz dist/*.deb dist/*.rpm; do minisign -S -m "$artifact" -s "$key_file" -x "$artifact.minisig" -W minisign -V -m "$artifact" -P "$OPENLOGI_UPDATE_MINISIGN_PUBLIC_KEY" -x "$artifact.minisig" done @@ -814,6 +818,7 @@ jobs: body_path: .release/RELEASE_NOTES.md files: | dist/*.dmg + dist/*.tar.gz dist/*.deb dist/*.rpm dist/*.minisig diff --git a/README.md b/README.md index 719272bc..21a523e1 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ Things OpenLogi does that Options+ won't: | Per-application profile overlays (auto-switch on app focus) | ✅ macOS, 🟡 Linux (X11 only) | | Settings window: launch-at-login, update check, menu-bar, permissions, language | ✅ macOS + Linux | | Interface localization (20 languages: da, de, el, en, es, fi, fr, it, ja, ko, nb, nl, pl, pt-BR, pt-PT, ru, sv, zh-CN, zh-HK, zh-TW) | ✅ | -| Linux packaging: udev rules, systemd unit, `.deb` / `.rpm` | ✅ Linux | +| Linux packaging: udev rules, systemd unit, `.deb` / `.rpm` / `.tar.gz` | ✅ Linux | | Gesture-button per-direction bindings | 🟡 configurable; hardware capture pending | | Middle / mode-shift / thumbwheel button capture | 🟡 configurable; hook owns side buttons only | | Windows (agent, GUI, event hook) | 🟡 untested preview — signed `.exe` / `.msi` ship per release | @@ -113,7 +113,8 @@ before the official cask autobump lands. Install either `openlogi` or ### Linux -Download the `.deb` or `.rpm` from the [latest release](https://github.com/AprilNEA/OpenLogi/releases/latest): +Download the Linux package for your distro from the +[latest release](https://github.com/AprilNEA/OpenLogi/releases/latest): ```sh # Debian / Ubuntu @@ -123,18 +124,24 @@ sudo dpkg -i openlogi_*.deb sudo rpm -i openlogi-*.rpm ``` -Packages are published for both `x86_64`/`amd64` and `arm64`/`aarch64`. +For other Linux distributions, use the portable release tarball: -The package installs udev rules that grant your user access to -`/dev/hidraw*` and `/dev/uinput` without `sudo`. After installation, -enable the background agent for your user: +```sh +tar -xzf openlogi-*-linux-*.tar.gz +cd openlogi-*-linux-* +sudo packaging/linux/install.sh --prefix=/usr +``` + +The packages and install script install udev rules that grant your user access +to `/dev/hidraw*` and `/dev/uinput` without `sudo`. After installation, enable +the background agent for your user: ```sh systemctl --user enable --now openlogi-agent.service ``` -See [docs/INSTALL-linux.md](docs/INSTALL-linux.md) for manual / source installs -and distros without systemd. +See [docs/INSTALL-linux.md](docs/INSTALL-linux.md) for source installs, +packaging details, and distros without systemd. ### Windows (preview) diff --git a/crates/openlogi-agent/src/main.rs b/crates/openlogi-agent/src/main.rs index 63f10f1e..b1f7d6c5 100644 --- a/crates/openlogi-agent/src/main.rs +++ b/crates/openlogi-agent/src/main.rs @@ -31,6 +31,10 @@ use tracing_subscriber::EnvFilter; use crate::server::AgentServer; fn main() { + if handle_probe_args() { + return; + } + init_tracing(); // Single-instance guard: the agent owns all device I/O, the CGEventTap, and @@ -102,6 +106,38 @@ fn main() { runtime.block_on(run(config)); } +fn handle_probe_args() -> bool { + let mut args = std::env::args().skip(1); + let Some(arg) = args.next() else { + return false; + }; + if arg != "--check-uinput" { + return false; + } + if args.next().is_some() { + eprintln!("usage: openlogi-agent --check-uinput"); + std::process::exit(2); + } + #[cfg(target_os = "linux")] + { + match std::fs::OpenOptions::new().write(true).open("/dev/uinput") { + Ok(_) => { + println!("uinput OK"); + true + } + Err(e) => { + eprintln!("uinput not writable: {e}"); + std::process::exit(1); + } + } + } + #[cfg(not(target_os = "linux"))] + { + eprintln!("--check-uinput is only supported on Linux"); + std::process::exit(2); + } +} + async fn run(config: Config) { // Reconcile the agent's launch-at-login autostart and clear the legacy GUI // LaunchAgent, before `config` moves into the orchestrator. diff --git a/crates/openlogi-cli/src/cmd/list.rs b/crates/openlogi-cli/src/cmd/list.rs index 4f648052..24f1ebeb 100644 --- a/crates/openlogi-cli/src/cmd/list.rs +++ b/crates/openlogi-cli/src/cmd/list.rs @@ -14,15 +14,7 @@ pub async fn run(_args: ListArgs) -> Result<()> { println!("No Logitech HID++ devices found."); println!(); println!("Notes:"); - println!(" - On macOS, quit Logi Options+ first — both apps fight over HID++ access."); - println!( - " - A Bluetooth-direct mouse (e.g. Lift, Signature) needs Input Monitoring \ - permission: System Settings → Privacy & Security → Input Monitoring." - ); - println!( - " - hidpp 0.2 only recognises Logi Bolt receivers (PID 0xC548); other \ - receivers (Unifying) aren't surfaced yet." - ); + print_no_devices_notes(); std::process::exit(2); } @@ -36,6 +28,32 @@ pub async fn run(_args: ListArgs) -> Result<()> { Ok(()) } +#[cfg(target_os = "linux")] +fn print_no_devices_notes() { + println!( + " - Plug in a Logi Bolt or Unifying receiver, or pair/connect a Bluetooth Logitech device." + ); + println!( + " - Install the Linux udev rules from packaging/linux/udev/70-openlogi.rules \ + so your user can access /dev/hidraw* and /dev/uinput." + ); + println!(" - Quit Solaar or any other Logitech manager before starting OpenLogi."); +} + +#[cfg(target_os = "macos")] +fn print_no_devices_notes() { + println!(" - Quit Logi Options+ first — both apps fight over HID++ access."); + println!( + " - A Bluetooth-direct mouse (e.g. Lift, Signature) needs Input Monitoring \ + permission: System Settings → Privacy & Security → Input Monitoring." + ); +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +fn print_no_devices_notes() { + println!(" - Connect a supported Logitech HID++ receiver or direct device."); +} + fn print_inventory(inv: &DeviceInventory) { let uid = inv.receiver.unique_id.as_deref().unwrap_or("—"); println!( diff --git a/crates/openlogi-core/src/binding.rs b/crates/openlogi-core/src/binding.rs index ed160165..ed0eb848 100644 --- a/crates/openlogi-core/src/binding.rs +++ b/crates/openlogi-core/src/binding.rs @@ -672,6 +672,14 @@ impl From for Binding { } } +fn platform_label(default: &'static str, linux: &'static str) -> String { + if cfg!(target_os = "linux") { + linux.into() + } else { + default.into() + } +} + impl Action { /// Display label for the popover row. /// @@ -703,12 +711,12 @@ impl Action { Action::NextTab => "Next Tab".into(), Action::PrevTab => "Previous Tab".into(), Action::ReloadPage => "Reload Page".into(), - Action::MissionControl => "Mission Control".into(), - Action::AppExpose => "App Exposé".into(), - Action::PreviousDesktop => "Previous Desktop".into(), - Action::NextDesktop => "Next Desktop".into(), + Action::MissionControl => platform_label("Mission Control", "Activities Overview"), + Action::AppExpose => platform_label("App Exposé", "App Windows"), + Action::PreviousDesktop => platform_label("Previous Desktop", "Previous Workspace"), + Action::NextDesktop => platform_label("Next Desktop", "Next Workspace"), Action::ShowDesktop => "Show Desktop".into(), - Action::LaunchpadShow => "Launchpad".into(), + Action::LaunchpadShow => platform_label("Launchpad", "Applications"), Action::LockScreen => "Lock Screen".into(), Action::Screenshot => "Screenshot".into(), Action::PlayPause => "Play / Pause".into(), @@ -851,11 +859,10 @@ impl Action { /// handled at the hook/HID layer, logging a trace here. /// /// On Linux, key and scroll events are injected via a lazily-created `uinput` - /// virtual device. Mouse clicks inject `BTN_*` events. macOS-only window - /// manager actions (`MissionControl`, `AppExpose`, `ShowDesktop`, - /// `LaunchpadShow`) have no universal Linux equivalent and are silently - /// skipped (debug-logged). `CustomShortcut` maps macOS `kVK_*` codes to - /// Linux key codes; macOS Cmd maps to Ctrl. + /// virtual device. Mouse clicks inject `BTN_*` events. Desktop-navigation + /// actions read GNOME's configured keybindings when available, then fall + /// back to common Ubuntu/GNOME shortcuts. `CustomShortcut` maps macOS + /// `kVK_*` codes to Linux key codes; macOS Cmd maps to Ctrl. /// /// On Windows, key and mouse events are synthesised via `SendInput`. The /// macOS window-manager actions map to their Windows equivalents (e.g. @@ -891,6 +898,7 @@ impl Action { let ctrl = KeyCode::KEY_LEFTCTRL; let shift = KeyCode::KEY_LEFTSHIFT; let alt = KeyCode::KEY_LEFTALT; + let meta = KeyCode::KEY_LEFTMETA; match self { // ── Mouse clicks ────────────────────────────────────────────────── Action::LeftClick => linux::click(KeyCode::BTN_LEFT), @@ -919,20 +927,60 @@ impl Action { Action::NextTab => linux::press_key(&[ctrl], KeyCode::KEY_TAB), Action::PrevTab => linux::press_key(&[ctrl, shift], KeyCode::KEY_TAB), Action::ReloadPage => linux::press_key(&[ctrl], KeyCode::KEY_R), - // ── Navigation — macOS-specific ─────────────────────────────────── - // No universal Linux equivalent; the compositor shortcut varies. - Action::MissionControl - | Action::AppExpose - | Action::ShowDesktop - | Action::LaunchpadShow => { - tracing::debug!( - action = self.label(), - "no Linux equivalent — action skipped" - ); - } - // Ctrl+Alt+←/→ is the default in GNOME and KDE. - Action::PreviousDesktop => linux::press_key(&[ctrl, alt], KeyCode::KEY_LEFT), - Action::NextDesktop => linux::press_key(&[ctrl, alt], KeyCode::KEY_RIGHT), + // ── Desktop navigation ─────────────────────────────────────────── + // Prefer the user's GNOME shortcut settings on Linux desktops that + // expose them; fall back to common GNOME/Ubuntu chords. + Action::MissionControl => linux::press_gsettings_shortcut( + "org.gnome.shell.keybindings", + "toggle-overview", + &[], + meta, + ), + Action::AppExpose => linux::press_gsettings_shortcut( + "org.gnome.desktop.wm.keybindings", + "switch-group", + &[meta], + KeyCode::KEY_GRAVE, + ), + Action::PreviousDesktop => linux::press_gsettings_shortcut_any( + &[ + ( + "org.gnome.desktop.wm.keybindings", + "switch-to-workspace-left", + ), + ("org.gnome.desktop.wm.keybindings", "switch-to-workspace-up"), + ], + &[alt], + &[ctrl, alt], + KeyCode::KEY_LEFT, + ), + Action::NextDesktop => linux::press_gsettings_shortcut_any( + &[ + ( + "org.gnome.desktop.wm.keybindings", + "switch-to-workspace-right", + ), + ( + "org.gnome.desktop.wm.keybindings", + "switch-to-workspace-down", + ), + ], + &[alt], + &[ctrl, alt], + KeyCode::KEY_RIGHT, + ), + Action::ShowDesktop => linux::press_gsettings_shortcut( + "org.gnome.desktop.wm.keybindings", + "show-desktop", + &[meta], + KeyCode::KEY_D, + ), + Action::LaunchpadShow => linux::press_gsettings_shortcut( + "org.gnome.shell.keybindings", + "toggle-application-view", + &[meta], + KeyCode::KEY_A, + ), // ── System ──────────────────────────────────────────────────────── // logind LockSessions() via the system bus; falls back to Super+L. Action::LockScreen => linux::lock_screen(), @@ -1768,12 +1816,20 @@ mod linux { use evdev::{AttributeSet, EventType, InputEvent, KeyCode, RelativeAxisCode}; use zbus::blocking::Connection as DbusConn; - const DEVICE_NAME: &str = "OpenLogi action injector"; + const KEYBOARD_DEVICE_NAME: &str = "OpenLogi action keyboard"; + const POINTER_DEVICE_NAME: &str = "OpenLogi action pointer"; + + static VIRTUAL_KEYBOARD: LazyLock>> = LazyLock::new(|| { + build_keyboard() + .map(Mutex::new) + .map_err(|e| tracing::warn!("failed to create uinput action keyboard: {e}")) + .ok() + }); - static VIRTUAL_INPUT: LazyLock>> = LazyLock::new(|| { - build() + static VIRTUAL_POINTER: LazyLock>> = LazyLock::new(|| { + build_pointer() .map(Mutex::new) - .map_err(|e| tracing::warn!("failed to create uinput action device: {e}")) + .map_err(|e| tracing::warn!("failed to create uinput action pointer: {e}")) .ok() }); @@ -1812,18 +1868,34 @@ mod linux { // Multimedia KeyCode::KEY_PLAYPAUSE, KeyCode::KEY_NEXTSONG, KeyCode::KEY_PREVIOUSSONG, KeyCode::KEY_VOLUMEUP, KeyCode::KEY_VOLUMEDOWN, KeyCode::KEY_MUTE, + ]; + + #[rustfmt::skip] + const POINTER_KEY_CAPABILITIES: &[KeyCode] = &[ // Mouse buttons (injected as EV_KEY with BTN_* codes). The side pair // must be registered here or the kernel silently drops their events. KeyCode::BTN_LEFT, KeyCode::BTN_RIGHT, KeyCode::BTN_MIDDLE, KeyCode::BTN_SIDE, KeyCode::BTN_EXTRA, ]; - fn build() -> io::Result { + fn build_keyboard() -> io::Result { let mut keys = AttributeSet::::default(); for &k in KEY_CAPABILITIES { keys.insert(k); } + VirtualDevice::builder()? + .name(KEYBOARD_DEVICE_NAME) + .with_keys(&keys)? + .build() + } + + fn build_pointer() -> io::Result { + let mut keys = AttributeSet::::default(); + for &k in POINTER_KEY_CAPABILITIES { + keys.insert(k); + } + // Only scroll axes: the device never emits cursor movement, so leaving // out REL_X/REL_Y keeps libinput from classifying it as a pointer — // which can otherwise cause injected key/wheel events to be grabbed by @@ -1834,14 +1906,14 @@ mod linux { } VirtualDevice::builder()? - .name(DEVICE_NAME) + .name(POINTER_DEVICE_NAME) .with_keys(&keys)? .with_relative_axes(&axes)? .build() } - fn emit(events: &[InputEvent]) { - if let Some(m) = &*VIRTUAL_INPUT { + fn emit_to(device: &LazyLock>>, events: &[InputEvent]) { + if let Some(m) = &**device { if let Ok(mut guard) = m.lock() { if let Err(e) = guard.emit(events) { tracing::warn!("uinput action emit failed: {e}"); @@ -1881,7 +1953,7 @@ mod linux { } down.push(key_ev(key, 1)); down.push(syn()); - emit(&down); + emit_to(&VIRTUAL_KEYBOARD, &down); // Up phase. let mut up: Vec = Vec::with_capacity(mods.len() + 2); @@ -1890,18 +1962,149 @@ mod linux { up.push(key_ev(m, 0)); } up.push(syn()); - emit(&up); + emit_to(&VIRTUAL_KEYBOARD, &up); + } + + /// Press the first configured GNOME accelerator for `schema.key`, falling + /// back to `fallback_mods + fallback_key` when GNOME is unavailable, the + /// setting is empty, or the accelerator uses a key we do not map yet. + pub(super) fn press_gsettings_shortcut( + schema: &str, + key: &str, + fallback_mods: &[KeyCode], + fallback_key: KeyCode, + ) { + press_gsettings_shortcut_any(&[(schema, key)], &[], fallback_mods, fallback_key); + } + + /// Like [`press_gsettings_shortcut`], but tries several settings in order. + pub(super) fn press_gsettings_shortcut_any( + settings: &[(&str, &str)], + preferred_mods: &[KeyCode], + fallback_mods: &[KeyCode], + fallback_key: KeyCode, + ) { + let mut first_configured = None; + for (schema, key) in settings { + for (mods, key_code) in configured_shortcuts(schema, key) { + if first_configured.is_none() { + first_configured = Some((mods.clone(), key_code)); + } + if !preferred_mods.is_empty() && mods == preferred_mods { + press_key(&mods, key_code); + return; + } + } + } + if let Some((mods, key_code)) = first_configured { + press_key(&mods, key_code); + return; + } + press_key(fallback_mods, fallback_key); + } + + fn configured_shortcuts(schema: &str, key: &str) -> Vec<(Vec, KeyCode)> { + let output = std::process::Command::new("gsettings") + .args(["get", schema, key]) + .output() + .ok(); + let Some(output) = output else { + return Vec::new(); + }; + if !output.status.success() { + return Vec::new(); + } + let Ok(stdout) = String::from_utf8(output.stdout) else { + return Vec::new(); + }; + accelerator_values(&stdout) + .into_iter() + .filter_map(|shortcut| parse_accelerator(shortcut)) + .collect() + } + + pub(super) fn accelerator_values(gsettings_output: &str) -> Vec<&str> { + let mut values = Vec::new(); + let mut rest = gsettings_output; + while let Some(start) = rest.find('\'') { + rest = &rest[start + 1..]; + let Some(end) = rest.find('\'') else { + break; + }; + let value = &rest[..end]; + if !value.is_empty() { + values.push(value); + } + rest = &rest[end + 1..]; + } + values + } + + pub(super) fn parse_accelerator(accelerator: &str) -> Option<(Vec, KeyCode)> { + let mut rest = accelerator.trim(); + let mut mods = Vec::new(); + while let Some(after_open) = rest.strip_prefix('<') { + let Some(end) = after_open.find('>') else { + break; + }; + let modifier = &after_open[..end]; + if let Some(key) = modifier_keycode(modifier) + && !mods.contains(&key) + { + mods.push(key); + } + rest = &after_open[end + 1..]; + } + key_name_to_keycode(rest).map(|key| (mods, key)) + } + + fn modifier_keycode(name: &str) -> Option { + match name.to_ascii_lowercase().as_str() { + "control" | "ctrl" | "primary" => Some(KeyCode::KEY_LEFTCTRL), + "shift" => Some(KeyCode::KEY_LEFTSHIFT), + "alt" | "mod1" => Some(KeyCode::KEY_LEFTALT), + "super" | "meta" | "mod4" => Some(KeyCode::KEY_LEFTMETA), + _ => None, + } + } + + fn key_name_to_keycode(name: &str) -> Option { + Some(match name.to_ascii_lowercase().as_str() { + "a" => KeyCode::KEY_A, + "d" => KeyCode::KEY_D, + "l" => KeyCode::KEY_L, + "0" => KeyCode::KEY_0, + "1" => KeyCode::KEY_1, + "2" => KeyCode::KEY_2, + "3" => KeyCode::KEY_3, + "4" => KeyCode::KEY_4, + "5" => KeyCode::KEY_5, + "6" => KeyCode::KEY_6, + "7" => KeyCode::KEY_7, + "8" => KeyCode::KEY_8, + "9" => KeyCode::KEY_9, + "tab" => KeyCode::KEY_TAB, + "above_tab" | "grave" => KeyCode::KEY_GRAVE, + "left" => KeyCode::KEY_LEFT, + "right" => KeyCode::KEY_RIGHT, + "up" => KeyCode::KEY_UP, + "down" => KeyCode::KEY_DOWN, + "page_up" | "pageup" => KeyCode::KEY_PAGEUP, + "page_down" | "pagedown" => KeyCode::KEY_PAGEDOWN, + "print" | "sysrq" => KeyCode::KEY_SYSRQ, + _ => return None, + }) } /// Inject a button-down in one SYN frame and button-up in a second. pub(super) fn click(button: KeyCode) { - emit(&[key_ev(button, 1), syn()]); - emit(&[key_ev(button, 0), syn()]); + emit_to(&VIRTUAL_POINTER, &[key_ev(button, 1), syn()]); + emit_to(&VIRTUAL_POINTER, &[key_ev(button, 0), syn()]); } /// Inject a single relative-axis delta followed by `SYN_REPORT`. pub(super) fn scroll(axis: RelativeAxisCode, value: i32) { - emit(&[rel_ev(axis, value), syn()]); + emit_to(&VIRTUAL_POINTER, &[rel_ev(axis, value), syn()]); } /// Force the virtual device to initialise (if it hasn't already) and return @@ -1913,10 +2116,10 @@ mod linux { /// within a few milliseconds of the `ioctl`). pub(super) fn device_node() -> Option { // Touch the LazyLock to force initialisation. - let _ = &*VIRTUAL_INPUT; + let _ = &*VIRTUAL_KEYBOARD; // Give udev a moment to create the /dev node. std::thread::sleep(std::time::Duration::from_millis(150)); - if let Some(m) = &*VIRTUAL_INPUT + if let Some(m) = &*VIRTUAL_KEYBOARD && let Ok(mut guard) = m.lock() { return guard.enumerate_dev_nodes_blocking().ok()?.flatten().next(); @@ -3045,4 +3248,57 @@ mod tests { assert_eq!(macos_vk_to_linux(0x34), None); // gap in the kVK table } } + + #[cfg(target_os = "linux")] + mod linux_accelerators { + use evdev::KeyCode; + + use crate::binding::linux::{accelerator_values, parse_accelerator}; + + #[test] + fn extracts_gsettings_string_array_values() { + assert_eq!( + accelerator_values("['Page_Down', 'Down']\n"), + vec!["Page_Down", "Down"] + ); + assert!(accelerator_values("@as []\n").is_empty()); + } + + #[test] + fn parses_super_page_down() { + let (mods, key) = parse_accelerator("Page_Down").expect("parse"); + assert_eq!(mods, vec![KeyCode::KEY_LEFTMETA]); + assert_eq!(key, KeyCode::KEY_PAGEDOWN); + } + + #[test] + fn parses_control_alt_up() { + let (mods, key) = parse_accelerator("Up").expect("parse"); + assert_eq!(mods, vec![KeyCode::KEY_LEFTCTRL, KeyCode::KEY_LEFTALT]); + assert_eq!(key, KeyCode::KEY_UP); + } + + #[test] + fn parses_alt_digit_workspace_shortcuts() { + let (mods, key) = parse_accelerator("1").expect("parse"); + assert_eq!(mods, vec![KeyCode::KEY_LEFTALT]); + assert_eq!(key, KeyCode::KEY_1); + + let (mods, key) = parse_accelerator("2").expect("parse"); + assert_eq!(mods, vec![KeyCode::KEY_LEFTALT]); + assert_eq!(key, KeyCode::KEY_2); + } + + #[test] + fn parses_above_tab_for_switch_group() { + let (mods, key) = parse_accelerator("Above_Tab").expect("parse"); + assert_eq!(mods, vec![KeyCode::KEY_LEFTMETA]); + assert_eq!(key, KeyCode::KEY_GRAVE); + } + + #[test] + fn unsupported_keys_are_ignored() { + assert!(parse_accelerator("Frobulate").is_none()); + } + } } diff --git a/crates/openlogi-gui/locales/da.yml b/crates/openlogi-gui/locales/da.yml index e9e60aea..b0ffee15 100644 --- a/crates/openlogi-gui/locales/da.yml +++ b/crates/openlogi-gui/locales/da.yml @@ -149,11 +149,16 @@ _version: 1 "Previous Tab": "Forrige faneblad" "Reload Page": "Genindlæs side" "Mission Control": "Mission Control" +"Activities Overview": "Activities Overview" "App Exposé": "App Exposé" +"App Windows": "App Windows" "Previous Desktop": "Forrige skrivebord" +"Previous Workspace": "Previous Workspace" "Next Desktop": "Næste skrivebord" +"Next Workspace": "Next Workspace" "Show Desktop": "Vis skrivebord" "Launchpad": "Launchpad" +"Applications": "Applications" "Lock Screen": "Lås skærm" "Screenshot": "Skærmbillede" "Play / Pause": "Afspil / pause" diff --git a/crates/openlogi-gui/locales/de.yml b/crates/openlogi-gui/locales/de.yml index 1eebd093..b882e0c1 100644 --- a/crates/openlogi-gui/locales/de.yml +++ b/crates/openlogi-gui/locales/de.yml @@ -149,11 +149,16 @@ _version: 1 "Previous Tab": "Vorheriger Tab" "Reload Page": "Seite neu laden" "Mission Control": "Mission Control" +"Activities Overview": "Activities Overview" "App Exposé": "App Exposé" +"App Windows": "App Windows" "Previous Desktop": "Vorheriger Schreibtisch" +"Previous Workspace": "Previous Workspace" "Next Desktop": "Nächster Schreibtisch" +"Next Workspace": "Next Workspace" "Show Desktop": "Schreibtisch anzeigen" "Launchpad": "Launchpad" +"Applications": "Applications" "Lock Screen": "Bildschirm sperren" "Screenshot": "Bildschirmfoto" "Play / Pause": "Wiedergabe / Pause" diff --git a/crates/openlogi-gui/locales/el.yml b/crates/openlogi-gui/locales/el.yml index 4d8b215a..c98e3453 100644 --- a/crates/openlogi-gui/locales/el.yml +++ b/crates/openlogi-gui/locales/el.yml @@ -149,11 +149,16 @@ _version: 1 "Previous Tab": "Προηγούμενη καρτέλα" "Reload Page": "Επαναφόρτωση σελίδας" "Mission Control": "Mission Control" +"Activities Overview": "Activities Overview" "App Exposé": "App Exposé" +"App Windows": "App Windows" "Previous Desktop": "Προηγούμενο γραφείο εργασίας" +"Previous Workspace": "Previous Workspace" "Next Desktop": "Επόμενο γραφείο εργασίας" +"Next Workspace": "Next Workspace" "Show Desktop": "Εμφάνιση γραφείου εργασίας" "Launchpad": "Launchpad" +"Applications": "Applications" "Lock Screen": "Κλείδωμα οθόνης" "Screenshot": "Στιγμιότυπο οθόνης" "Play / Pause": "Αναπαραγωγή / Παύση" diff --git a/crates/openlogi-gui/locales/en.yml b/crates/openlogi-gui/locales/en.yml index 206afd72..f90d5d0f 100644 --- a/crates/openlogi-gui/locales/en.yml +++ b/crates/openlogi-gui/locales/en.yml @@ -149,11 +149,16 @@ _version: 1 "Previous Tab": "Previous Tab" "Reload Page": "Reload Page" "Mission Control": "Mission Control" +"Activities Overview": "Activities Overview" "App Exposé": "App Exposé" +"App Windows": "App Windows" "Previous Desktop": "Previous Desktop" +"Previous Workspace": "Previous Workspace" "Next Desktop": "Next Desktop" +"Next Workspace": "Next Workspace" "Show Desktop": "Show Desktop" "Launchpad": "Launchpad" +"Applications": "Applications" "Lock Screen": "Lock Screen" "Screenshot": "Screenshot" "Play / Pause": "Play / Pause" diff --git a/crates/openlogi-gui/locales/es.yml b/crates/openlogi-gui/locales/es.yml index be02f19f..9779b572 100644 --- a/crates/openlogi-gui/locales/es.yml +++ b/crates/openlogi-gui/locales/es.yml @@ -149,11 +149,16 @@ _version: 1 "Previous Tab": "Pestaña anterior" "Reload Page": "Recargar página" "Mission Control": "Mission Control" +"Activities Overview": "Activities Overview" "App Exposé": "App Exposé" +"App Windows": "App Windows" "Previous Desktop": "Escritorio anterior" +"Previous Workspace": "Previous Workspace" "Next Desktop": "Escritorio siguiente" +"Next Workspace": "Next Workspace" "Show Desktop": "Mostrar escritorio" "Launchpad": "Launchpad" +"Applications": "Applications" "Lock Screen": "Bloquear pantalla" "Screenshot": "Captura de pantalla" "Play / Pause": "Reproducir / Pausar" diff --git a/crates/openlogi-gui/locales/fi.yml b/crates/openlogi-gui/locales/fi.yml index 33a2f52c..3e12897d 100644 --- a/crates/openlogi-gui/locales/fi.yml +++ b/crates/openlogi-gui/locales/fi.yml @@ -149,11 +149,16 @@ _version: 1 "Previous Tab": "Edellinen välilehti" "Reload Page": "Lataa sivu uudelleen" "Mission Control": "Mission Control" +"Activities Overview": "Activities Overview" "App Exposé": "App Exposé" +"App Windows": "App Windows" "Previous Desktop": "Edellinen työpöytä" +"Previous Workspace": "Previous Workspace" "Next Desktop": "Seuraava työpöytä" +"Next Workspace": "Next Workspace" "Show Desktop": "Näytä työpöytä" "Launchpad": "Launchpad" +"Applications": "Applications" "Lock Screen": "Lukitse näyttö" "Screenshot": "Kuvakaappaus" "Play / Pause": "Toista / Keskeytä" diff --git a/crates/openlogi-gui/locales/fr.yml b/crates/openlogi-gui/locales/fr.yml index 5d19a8ed..1f19b897 100644 --- a/crates/openlogi-gui/locales/fr.yml +++ b/crates/openlogi-gui/locales/fr.yml @@ -149,11 +149,16 @@ _version: 1 "Previous Tab": "Onglet précédent" "Reload Page": "Recharger la page" "Mission Control": "Mission Control" +"Activities Overview": "Activities Overview" "App Exposé": "App Exposé" +"App Windows": "App Windows" "Previous Desktop": "Bureau précédent" +"Previous Workspace": "Previous Workspace" "Next Desktop": "Bureau suivant" +"Next Workspace": "Next Workspace" "Show Desktop": "Afficher le bureau" "Launchpad": "Launchpad" +"Applications": "Applications" "Lock Screen": "Verrouiller l'écran" "Screenshot": "Capture d'écran" "Play / Pause": "Lecture / Pause" diff --git a/crates/openlogi-gui/locales/it.yml b/crates/openlogi-gui/locales/it.yml index 533761e6..aa463945 100644 --- a/crates/openlogi-gui/locales/it.yml +++ b/crates/openlogi-gui/locales/it.yml @@ -149,11 +149,16 @@ _version: 1 "Previous Tab": "Scheda precedente" "Reload Page": "Ricarica pagina" "Mission Control": "Mission Control" +"Activities Overview": "Activities Overview" "App Exposé": "App Exposé" +"App Windows": "App Windows" "Previous Desktop": "Scrivania precedente" +"Previous Workspace": "Previous Workspace" "Next Desktop": "Scrivania successiva" +"Next Workspace": "Next Workspace" "Show Desktop": "Mostra scrivania" "Launchpad": "Launchpad" +"Applications": "Applications" "Lock Screen": "Blocca schermo" "Screenshot": "Istantanea schermo" "Play / Pause": "Riproduci / Pausa" diff --git a/crates/openlogi-gui/locales/ja.yml b/crates/openlogi-gui/locales/ja.yml index 10c18ba1..3d66f260 100644 --- a/crates/openlogi-gui/locales/ja.yml +++ b/crates/openlogi-gui/locales/ja.yml @@ -149,11 +149,16 @@ _version: 1 "Previous Tab": "前のタブ" "Reload Page": "ページを再読み込み" "Mission Control": "Mission Control" +"Activities Overview": "Activities Overview" "App Exposé": "App Exposé" +"App Windows": "App Windows" "Previous Desktop": "前のデスクトップ" +"Previous Workspace": "Previous Workspace" "Next Desktop": "次のデスクトップ" +"Next Workspace": "Next Workspace" "Show Desktop": "デスクトップを表示" "Launchpad": "Launchpad" +"Applications": "Applications" "Lock Screen": "画面をロック" "Screenshot": "スクリーンショット" "Play / Pause": "再生 / 一時停止" diff --git a/crates/openlogi-gui/locales/ko.yml b/crates/openlogi-gui/locales/ko.yml index e882ec11..5c342591 100644 --- a/crates/openlogi-gui/locales/ko.yml +++ b/crates/openlogi-gui/locales/ko.yml @@ -149,11 +149,16 @@ _version: 1 "Previous Tab": "이전 탭" "Reload Page": "페이지 새로고침" "Mission Control": "미션 컨트롤" +"Activities Overview": "Activities Overview" "App Exposé": "앱 Exposé" +"App Windows": "App Windows" "Previous Desktop": "이전 데스크탑" +"Previous Workspace": "Previous Workspace" "Next Desktop": "다음 데스크탑" +"Next Workspace": "Next Workspace" "Show Desktop": "데스크탑 보기" "Launchpad": "런치패드" +"Applications": "Applications" "Lock Screen": "화면 잠금" "Screenshot": "스크린샷" "Play / Pause": "재생 / 일시정지" diff --git a/crates/openlogi-gui/locales/nb.yml b/crates/openlogi-gui/locales/nb.yml index ac62e481..59f5a8a4 100644 --- a/crates/openlogi-gui/locales/nb.yml +++ b/crates/openlogi-gui/locales/nb.yml @@ -149,11 +149,16 @@ _version: 1 "Previous Tab": "Forrige fane" "Reload Page": "Last inn siden på nytt" "Mission Control": "Mission Control" +"Activities Overview": "Activities Overview" "App Exposé": "App Exposé" +"App Windows": "App Windows" "Previous Desktop": "Forrige skrivebord" +"Previous Workspace": "Previous Workspace" "Next Desktop": "Neste skrivebord" +"Next Workspace": "Next Workspace" "Show Desktop": "Vis skrivebord" "Launchpad": "Launchpad" +"Applications": "Applications" "Lock Screen": "Lås skjerm" "Screenshot": "Skjermbilde" "Play / Pause": "Spill av / pause" diff --git a/crates/openlogi-gui/locales/nl.yml b/crates/openlogi-gui/locales/nl.yml index 517db2c2..1b77bc92 100644 --- a/crates/openlogi-gui/locales/nl.yml +++ b/crates/openlogi-gui/locales/nl.yml @@ -149,11 +149,16 @@ _version: 1 "Previous Tab": "Vorig tabblad" "Reload Page": "Pagina herladen" "Mission Control": "Mission Control" +"Activities Overview": "Activities Overview" "App Exposé": "App Exposé" +"App Windows": "App Windows" "Previous Desktop": "Vorig bureaublad" +"Previous Workspace": "Previous Workspace" "Next Desktop": "Volgend bureaublad" +"Next Workspace": "Next Workspace" "Show Desktop": "Bureaublad tonen" "Launchpad": "Launchpad" +"Applications": "Applications" "Lock Screen": "Scherm vergrendelen" "Screenshot": "Schermafbeelding" "Play / Pause": "Afspelen / pauzeren" diff --git a/crates/openlogi-gui/locales/pl.yml b/crates/openlogi-gui/locales/pl.yml index c3815377..b3d1274e 100644 --- a/crates/openlogi-gui/locales/pl.yml +++ b/crates/openlogi-gui/locales/pl.yml @@ -149,11 +149,16 @@ _version: 1 "Previous Tab": "Poprzednia karta" "Reload Page": "Załaduj stronę ponownie" "Mission Control": "Mission Control" +"Activities Overview": "Activities Overview" "App Exposé": "App Exposé" +"App Windows": "App Windows" "Previous Desktop": "Poprzedni pulpit" +"Previous Workspace": "Previous Workspace" "Next Desktop": "Następny pulpit" +"Next Workspace": "Next Workspace" "Show Desktop": "Pokaż biurko" "Launchpad": "Launchpad" +"Applications": "Applications" "Lock Screen": "Zablokuj ekran" "Screenshot": "Zrzut ekranu" "Play / Pause": "Odtwarzaj / Wstrzymaj" diff --git a/crates/openlogi-gui/locales/pt-BR.yml b/crates/openlogi-gui/locales/pt-BR.yml index ede6b227..808c6ffe 100644 --- a/crates/openlogi-gui/locales/pt-BR.yml +++ b/crates/openlogi-gui/locales/pt-BR.yml @@ -149,11 +149,16 @@ _version: 1 "Previous Tab": "Aba Anterior" "Reload Page": "Recarregar Página" "Mission Control": "Mission Control" +"Activities Overview": "Activities Overview" "App Exposé": "Exposé do App" +"App Windows": "App Windows" "Previous Desktop": "Mesa Anterior" +"Previous Workspace": "Previous Workspace" "Next Desktop": "Próxima Mesa" +"Next Workspace": "Next Workspace" "Show Desktop": "Mostrar Mesa" "Launchpad": "Launchpad" +"Applications": "Applications" "Lock Screen": "Bloquear Tela" "Screenshot": "Captura de Tela" "Play / Pause": "Reproduzir / Pausar" diff --git a/crates/openlogi-gui/locales/pt-PT.yml b/crates/openlogi-gui/locales/pt-PT.yml index 35bd019a..cdb50b49 100644 --- a/crates/openlogi-gui/locales/pt-PT.yml +++ b/crates/openlogi-gui/locales/pt-PT.yml @@ -149,11 +149,16 @@ _version: 1 "Previous Tab": "Separador anterior" "Reload Page": "Recarregar página" "Mission Control": "Mission Control" +"Activities Overview": "Activities Overview" "App Exposé": "App Exposé" +"App Windows": "App Windows" "Previous Desktop": "Secretária anterior" +"Previous Workspace": "Previous Workspace" "Next Desktop": "Secretária seguinte" +"Next Workspace": "Next Workspace" "Show Desktop": "Mostrar secretária" "Launchpad": "Launchpad" +"Applications": "Applications" "Lock Screen": "Bloquear ecrã" "Screenshot": "Captura de ecrã" "Play / Pause": "Reproduzir / Pausa" diff --git a/crates/openlogi-gui/locales/ru.yml b/crates/openlogi-gui/locales/ru.yml index 9b698131..5a054f94 100644 --- a/crates/openlogi-gui/locales/ru.yml +++ b/crates/openlogi-gui/locales/ru.yml @@ -149,11 +149,16 @@ _version: 1 "Previous Tab": "Предыдущая вкладка" "Reload Page": "Перезагрузить страницу" "Mission Control": "Mission Control" +"Activities Overview": "Activities Overview" "App Exposé": "App Exposé" +"App Windows": "App Windows" "Previous Desktop": "Предыдущий рабочий стол" +"Previous Workspace": "Previous Workspace" "Next Desktop": "Следующий рабочий стол" +"Next Workspace": "Next Workspace" "Show Desktop": "Показать рабочий стол" "Launchpad": "Launchpad" +"Applications": "Applications" "Lock Screen": "Заблокировать экран" "Screenshot": "Снимок экрана" "Play / Pause": "Воспроизведение / пауза" diff --git a/crates/openlogi-gui/locales/sv.yml b/crates/openlogi-gui/locales/sv.yml index ea685c79..2151ae8c 100644 --- a/crates/openlogi-gui/locales/sv.yml +++ b/crates/openlogi-gui/locales/sv.yml @@ -149,11 +149,16 @@ _version: 1 "Previous Tab": "Föregående flik" "Reload Page": "Läs in sidan på nytt" "Mission Control": "Mission Control" +"Activities Overview": "Activities Overview" "App Exposé": "App Exposé" +"App Windows": "App Windows" "Previous Desktop": "Föregående skrivbord" +"Previous Workspace": "Previous Workspace" "Next Desktop": "Nästa skrivbord" +"Next Workspace": "Next Workspace" "Show Desktop": "Visa skrivbordet" "Launchpad": "Launchpad" +"Applications": "Applications" "Lock Screen": "Lås skärmen" "Screenshot": "Skärmavbild" "Play / Pause": "Spela upp / Pausa" diff --git a/crates/openlogi-gui/locales/zh-CN.yml b/crates/openlogi-gui/locales/zh-CN.yml index b3fc51bf..7597198e 100644 --- a/crates/openlogi-gui/locales/zh-CN.yml +++ b/crates/openlogi-gui/locales/zh-CN.yml @@ -149,11 +149,16 @@ _version: 1 "Previous Tab": "上一个标签页" "Reload Page": "刷新页面" "Mission Control": "调度中心" +"Activities Overview": "活动概览" "App Exposé": "应用程序窗口" +"App Windows": "应用窗口" "Previous Desktop": "上一个桌面" +"Previous Workspace": "上一个工作区" "Next Desktop": "下一个桌面" +"Next Workspace": "下一个工作区" "Show Desktop": "显示桌面" "Launchpad": "启动台" +"Applications": "应用程序" "Lock Screen": "锁定屏幕" "Screenshot": "截屏" "Play / Pause": "播放 / 暂停" diff --git a/crates/openlogi-gui/locales/zh-HK.yml b/crates/openlogi-gui/locales/zh-HK.yml index df1e0cbd..ef888a11 100644 --- a/crates/openlogi-gui/locales/zh-HK.yml +++ b/crates/openlogi-gui/locales/zh-HK.yml @@ -149,11 +149,16 @@ _version: 1 "Previous Tab": "上一個分頁" "Reload Page": "重新載入頁面" "Mission Control": "調度中心" +"Activities Overview": "Activities Overview" "App Exposé": "應用程式視窗" +"App Windows": "App Windows" "Previous Desktop": "上一個桌面" +"Previous Workspace": "Previous Workspace" "Next Desktop": "下一個桌面" +"Next Workspace": "Next Workspace" "Show Desktop": "顯示桌面" "Launchpad": "啟動台" +"Applications": "Applications" "Lock Screen": "鎖定螢幕" "Screenshot": "截圖" "Play / Pause": "播放 / 暫停" diff --git a/crates/openlogi-gui/locales/zh-TW.yml b/crates/openlogi-gui/locales/zh-TW.yml index 58ff5aa5..965be1b4 100644 --- a/crates/openlogi-gui/locales/zh-TW.yml +++ b/crates/openlogi-gui/locales/zh-TW.yml @@ -149,11 +149,16 @@ _version: 1 "Previous Tab": "上一個分頁" "Reload Page": "重新載入頁面" "Mission Control": "Mission Control" +"Activities Overview": "Activities Overview" "App Exposé": "App Exposé" +"App Windows": "App Windows" "Previous Desktop": "上一個桌面" +"Previous Workspace": "Previous Workspace" "Next Desktop": "下一個桌面" +"Next Workspace": "Next Workspace" "Show Desktop": "顯示桌面" "Launchpad": "啟動台" +"Applications": "Applications" "Lock Screen": "鎖定螢幕" "Screenshot": "截圖" "Play / Pause": "播放 / 暫停" diff --git a/crates/openlogi-gui/src/app.rs b/crates/openlogi-gui/src/app.rs index a7209501..34d119b4 100644 --- a/crates/openlogi-gui/src/app.rs +++ b/crates/openlogi-gui/src/app.rs @@ -372,15 +372,30 @@ impl Render for AppView { let status = match link { AgentLink::Connecting => { window.set_window_title("OpenLogi"); - return root.child(connecting_body(pal)).into_any_element(); + return crate::window_chrome::frame( + "OpenLogi", + root.child(connecting_body(pal)), + window, + cx, + ); } AgentLink::Unreachable => { window.set_window_title("OpenLogi"); - return root.child(unreachable_body(pal)).into_any_element(); + return crate::window_chrome::frame( + "OpenLogi", + root.child(unreachable_body(pal)), + window, + cx, + ); } AgentLink::OutdatedGui => { window.set_window_title("OpenLogi"); - return root.child(outdated_gui_body(pal)).into_any_element(); + return crate::window_chrome::frame( + "OpenLogi", + root.child(outdated_gui_body(pal)), + window, + cx, + ); } AgentLink::Ready(status) => status, }; @@ -388,9 +403,12 @@ impl Render for AppView { let granted = status.accessibility_granted; if !granted && !self.accessibility_dismissed { window.set_window_title("OpenLogi"); - return root - .child(Self::accessibility_gate(pal, cx)) - .into_any_element(); + return crate::window_chrome::frame( + "OpenLogi", + root.child(Self::accessibility_gate(pal, cx)), + window, + cx, + ); } Self::ensure_glow(cx); @@ -415,7 +433,8 @@ impl Render for AppView { self.route = Route::Home; } - window.set_window_title(&main_window_title(show_device, cx)); + let title = main_window_title(show_device, cx); + window.set_window_title(&title); let (header_el, content_el) = if show_device { // Resolve the active section once and share it between the header @@ -464,10 +483,14 @@ impl Render for AppView { ) }; - root.child(header_el) - .child(content_el) - .child(footer(pal, granted)) - .into_any_element() + crate::window_chrome::frame( + title, + root.child(header_el) + .child(content_el) + .child(footer(pal, granted)), + window, + cx, + ) } } diff --git a/crates/openlogi-gui/src/app_assets.rs b/crates/openlogi-gui/src/app_assets.rs index 6d50ac1a..d40616de 100644 --- a/crates/openlogi-gui/src/app_assets.rs +++ b/crates/openlogi-gui/src/app_assets.rs @@ -7,6 +7,7 @@ //! would not. use std::borrow::Cow; +use std::sync::{Arc, LazyLock}; use gpui::{AssetSource, Result, SharedString}; @@ -14,11 +15,22 @@ use gpui::{AssetSource, Result, SharedString}; pub const LOGO: &str = "openlogi.png"; /// The 1024×1024 app icon, embedded into the binary. -const LOGO_BYTES: &[u8] = include_bytes!(concat!( +pub const LOGO_BYTES: &[u8] = include_bytes!(concat!( env!("CARGO_MANIFEST_DIR"), "/../../design/icon/openlogi.png" )); +static APP_ICON: LazyLock>> = LazyLock::new(|| { + image::load_from_memory(LOGO_BYTES) + .ok() + .map(|img| Arc::new(img.to_rgba8())) +}); + +/// The decoded app icon for native window metadata. +pub fn app_icon() -> Option> { + APP_ICON.clone() +} + /// Vendored [lucide](https://lucide.dev) icons (ISC license) for the binding /// menus, embedded so they resolve identically in a packaged `.app` and a dev /// build. Served under the `action-icons/` path prefix and rendered by diff --git a/crates/openlogi-gui/src/ipc_client.rs b/crates/openlogi-gui/src/ipc_client.rs index 3e4acd41..6542595f 100644 --- a/crates/openlogi-gui/src/ipc_client.rs +++ b/crates/openlogi-gui/src/ipc_client.rs @@ -11,8 +11,8 @@ //! runs at [`STARTUP_POLL_PERIOD`] until the agent's first completed //! enumeration and again after any disconnect (an agent self-exec on update, a //! crash), and [`spawn_agent`] relaunches the binary when the socket stays -//! down — there is no launchd dependency here (`KeepAlive` only acts when the -//! agent *exits*, and autostart may be off entirely). When the agent stays +//! down — preferably through the platform's service manager when one is +//! configured, otherwise as a direct child process. When the agent stays //! unreachable or answers with a newer protocol, that is pushed to the GUI as //! a [`GuiUpdate`] so the window can say so instead of spinning forever. @@ -495,10 +495,15 @@ async fn poll_pairing_once( /// the binary can't be found / started — the user may start it via launchd or by /// hand, and the poll loop keeps retrying the connection regardless. fn spawn_agent() { + #[cfg(target_os = "linux")] + if start_systemd_agent() { + return; + } + let Some(path) = agent_binary_path() else { warn!( "agent not reachable and its binary wasn't found next to the GUI — \ - start it via launchd or by hand" + start it via the service manager or by hand" ); return; }; @@ -508,6 +513,33 @@ fn spawn_agent() { } } +#[cfg(target_os = "linux")] +fn start_systemd_agent() -> bool { + let status = std::process::Command::new("systemctl") + .args(["--user", "start", "openlogi-agent.service"]) + .status(); + match status { + Ok(status) if status.success() => { + info!("agent not running — started openlogi-agent.service"); + true + } + Ok(status) => { + debug!( + code = status.code(), + "could not start openlogi-agent.service; falling back to direct agent launch" + ); + false + } + Err(e) => { + debug!( + error = %e, + "could not run systemctl --user; falling back to direct agent launch" + ); + false + } + } +} + /// Resolve the agent executable relative to the running GUI: a sibling in the /// cargo target dir (dev, and the flat Windows install layout), else the /// embedded `OpenLogiAgent.app` login-item helper (packaged macOS build). diff --git a/crates/openlogi-gui/src/main.rs b/crates/openlogi-gui/src/main.rs index b1e31318..cf7a4c02 100644 --- a/crates/openlogi-gui/src/main.rs +++ b/crates/openlogi-gui/src/main.rs @@ -42,6 +42,7 @@ mod mouse_model; mod platform; mod state; mod theme; +mod window_chrome; mod windows; // Loads the Crowdin-managed `crates/openlogi-gui/locales/*.yml` files at compile @@ -68,6 +69,8 @@ use tracing_subscriber::EnvFilter; use crate::app::AppView; use crate::state::AppState; +pub(crate) const APP_ID: &str = "openlogi"; + fn dispatch_gui_command(command: DeeplinkCommand, cx: &mut gpui::App) { use DeeplinkCommand as Cmd; match command { @@ -432,6 +435,8 @@ fn main_window_options(cx: &mut gpui::App) -> WindowOptions { appears_transparent: false, traffic_light_position: None, }), + app_id: Some(APP_ID.to_string()), + icon: app_assets::app_icon(), ..WindowOptions::default() } } diff --git a/crates/openlogi-gui/src/platform/permissions.rs b/crates/openlogi-gui/src/platform/permissions.rs index 4d165b34..c9029bf3 100644 --- a/crates/openlogi-gui/src/platform/permissions.rs +++ b/crates/openlogi-gui/src/platform/permissions.rs @@ -20,6 +20,8 @@ //! the evdev/uinput hook. //! - **Read/write access to `/dev/hidraw*`** — to communicate with the Logitech //! Bolt receiver or directly-connected devices over HID++. +//! - **Read access to Logitech `/dev/input/event*` mouse nodes** — to capture +//! remappable button events through evdev. //! //! Both are granted by installing the OpenLogi udev rules (see the Linux //! install guide). @@ -63,21 +65,23 @@ pub fn bluetooth() -> PermissionStatus { macos::bluetooth() } -/// Probe Linux input-device access: `/dev/uinput` (write) and at least one -/// Logitech `/dev/hidraw*` (read/write). +/// Probe Linux input-device access: `/dev/uinput` (write), at least one +/// Logitech `/dev/hidraw*` (read/write), and at least one Logitech mouse +/// `/dev/input/event*` node (read). /// /// Returns: -/// - `Granted` — both uinput and at least one Logitech hidraw are accessible. -/// - `Denied` — uinput is inaccessible, or a Logitech hidraw exists but is +/// - `Granted` — all three required node classes are accessible. +/// - `Denied` — a required node exists but is inaccessible, or uinput is /// inaccessible. -/// - `Unknown` — uinput is accessible but no Logitech hidraw device is -/// currently connected (nothing to report yet). +/// - `Unknown` — uinput is accessible but no Logitech device node is currently +/// connected (nothing to report yet). #[cfg(target_os = "linux")] #[must_use] pub fn input_device_access() -> PermissionStatus { let uinput_ok = linux::probe_uinput(); let hidraw_ok = linux::probe_logitech_hidraw(); - classify(uinput_ok, hidraw_ok) + let mouse_event_ok = linux::probe_logitech_mouse_event(); + classify(uinput_ok, hidraw_ok, mouse_event_ok) } /// Pure classification logic, factored out so it is testable without device nodes. @@ -86,12 +90,19 @@ pub fn input_device_access() -> PermissionStatus { /// - `hidraw_ok`: `Some(true)` = Logitech hidraw accessible, `Some(false)` = /// Logitech hidraw present but not accessible, `None` = no Logitech hidraw /// present at all. +/// - `mouse_event_ok`: `Some(true)` = Logitech mouse event accessible, +/// `Some(false)` = Logitech mouse event present but not accessible, `None` = +/// no Logitech mouse event present at all. #[cfg(target_os = "linux")] -pub(crate) fn classify(uinput_ok: bool, hidraw_ok: Option) -> PermissionStatus { - match (uinput_ok, hidraw_ok) { - (true, Some(true)) => PermissionStatus::Granted, - (false, _) | (_, Some(false)) => PermissionStatus::Denied, - (true, None) => PermissionStatus::Unknown, +pub(crate) fn classify( + uinput_ok: bool, + hidraw_ok: Option, + mouse_event_ok: Option, +) -> PermissionStatus { + match (uinput_ok, hidraw_ok, mouse_event_ok) { + (true, Some(true), Some(true)) => PermissionStatus::Granted, + (false, _, _) | (_, Some(false), _) | (_, _, Some(false)) => PermissionStatus::Denied, + (true, _, _) => PermissionStatus::Unknown, } } @@ -242,6 +253,50 @@ pub(crate) mod linux { } } + /// Probe Logitech mouse event nodes. + /// + /// Returns: + /// - `Some(true)` — at least one Logitech mouse event node is present and + /// readable. + /// - `Some(false)` — at least one Logitech mouse event node is present but + /// permission is denied. + /// - `None` — no Logitech mouse event node found (nothing connected). + pub(crate) fn probe_logitech_mouse_event() -> Option { + let mut any_accessible = false; + let mut any_denied = false; + + for entry in fs::read_dir("/sys/class/input") + .ok()? + .filter_map(Result::ok) + { + let Ok(name) = entry.file_name().into_string() else { + continue; + }; + if !name.starts_with("event") || !is_logitech_mouse_event(&name) { + continue; + } + match fs::OpenOptions::new() + .read(true) + .open(Path::new("/dev/input").join(&name)) + { + Ok(_) => { + any_accessible = true; + break; + } + Err(e) if matches!(e.kind(), ErrorKind::PermissionDenied) => any_denied = true, + Err(_) => {} + } + } + + if any_accessible { + Some(true) + } else if any_denied { + Some(false) + } else { + None + } + } + /// Check whether a hidraw device belongs to Logitech by reading the HID_ID /// field from its sysfs uevent file. /// @@ -263,6 +318,18 @@ pub(crate) mod linux { .is_some_and(|vid| vid == LOGITECH_VID) }) } + + fn is_logitech_mouse_event(event_name: &str) -> bool { + let base = format!("/sys/class/input/{event_name}"); + let Ok(vendor) = fs::read_to_string(format!("{base}/device/id/vendor")) else { + return false; + }; + let Ok(uevent) = fs::read_to_string(format!("{base}/uevent")) else { + return false; + }; + vendor.trim().eq_ignore_ascii_case("046d") + && uevent.lines().any(|line| line == "ID_INPUT_MOUSE=1") + } } // ── Tests ────────────────────────────────────────────────────────────────────── @@ -273,23 +340,45 @@ mod tests { #[test] fn classify_granted_when_both_ok() { - assert_eq!(classify(true, Some(true)), PermissionStatus::Granted); + assert_eq!( + classify(true, Some(true), Some(true)), + PermissionStatus::Granted + ); } #[test] fn classify_denied_when_uinput_not_ok() { - assert_eq!(classify(false, Some(true)), PermissionStatus::Denied); - assert_eq!(classify(false, Some(false)), PermissionStatus::Denied); - assert_eq!(classify(false, None), PermissionStatus::Denied); + assert_eq!( + classify(false, Some(true), Some(true)), + PermissionStatus::Denied + ); + assert_eq!( + classify(false, Some(false), Some(true)), + PermissionStatus::Denied + ); + assert_eq!(classify(false, None, None), PermissionStatus::Denied); } #[test] fn classify_denied_when_hidraw_denied() { - assert_eq!(classify(true, Some(false)), PermissionStatus::Denied); + assert_eq!( + classify(true, Some(false), Some(true)), + PermissionStatus::Denied + ); + } + + #[test] + fn classify_denied_when_mouse_event_denied() { + assert_eq!( + classify(true, Some(true), Some(false)), + PermissionStatus::Denied + ); } #[test] fn classify_unknown_when_no_logitech_device_connected() { - assert_eq!(classify(true, None), PermissionStatus::Unknown); + assert_eq!(classify(true, None, None), PermissionStatus::Unknown); + assert_eq!(classify(true, Some(true), None), PermissionStatus::Unknown); + assert_eq!(classify(true, None, Some(true)), PermissionStatus::Unknown); } } diff --git a/crates/openlogi-gui/src/window_chrome.rs b/crates/openlogi-gui/src/window_chrome.rs new file mode 100644 index 00000000..73c49094 --- /dev/null +++ b/crates/openlogi-gui/src/window_chrome.rs @@ -0,0 +1,157 @@ +use gpui::{ + AnyElement, App, Decorations, InteractiveElement as _, IntoElement, MouseButton, + ParentElement as _, SharedString, StatefulInteractiveElement as _, Styled as _, Window, + WindowButton, WindowButtonLayout, WindowControlArea, div, px, +}; +use gpui_component::{Icon, IconName, h_flex, tooltip::Tooltip, v_flex}; + +/// Wrap a window body with app-rendered controls when Linux client-side +/// decorations are active. Other platforms keep the native titlebar. +pub fn frame( + title: impl Into, + body: impl IntoElement, + window: &mut Window, + cx: &mut App, +) -> AnyElement { + #[cfg(target_os = "linux")] + { + if matches!(window.window_decorations(), Decorations::Client { .. }) { + return linux_frame(title.into(), body, window, cx); + } + } + + body.into_any_element() +} + +#[cfg(target_os = "linux")] +fn linux_frame( + title: SharedString, + body: impl IntoElement, + window: &mut Window, + cx: &mut App, +) -> AnyElement { + let pal = crate::theme::palette(cx); + + v_flex() + .size_full() + .bg(pal.bg) + .text_color(pal.text_primary) + .child(linux_titlebar(title, window, cx)) + .child(div().flex_1().min_h_0().child(body)) + .into_any_element() +} + +#[cfg(target_os = "linux")] +fn linux_titlebar(title: SharedString, window: &mut Window, cx: &mut App) -> impl IntoElement { + let pal = crate::theme::palette(cx); + let layout = cx + .button_layout() + .unwrap_or_else(WindowButtonLayout::linux_default); + + h_flex() + .id("linux-titlebar") + .window_control_area(WindowControlArea::Drag) + .h(px(38.)) + .w_full() + .flex_shrink_0() + .items_center() + .border_b_1() + .border_color(pal.border) + .bg(pal.surface) + .on_mouse_down(MouseButton::Left, |event, window, _| { + if event.click_count == 1 { + window.start_window_move(); + } + }) + .on_click(|event, window, _| { + if event.click_count() == 2 { + window.zoom_window(); + } + }) + .on_mouse_down(MouseButton::Right, |event, window, _| { + window.show_window_menu(event.position); + }) + .child(linux_window_controls( + "linux-titlebar-left-controls", + layout.left, + window, + pal.surface_hover, + )) + .child( + div() + .flex_1() + .min_w_0() + .text_sm() + .text_color(pal.text_muted) + .child(title), + ) + .child(linux_window_controls( + "linux-titlebar-right-controls", + layout.right, + window, + pal.surface_hover, + )) +} + +#[cfg(target_os = "linux")] +fn linux_window_controls( + id: &'static str, + buttons: [Option; gpui::MAX_BUTTONS_PER_SIDE], + window: &mut Window, + hover_bg: gpui::Hsla, +) -> impl IntoElement { + let controls = window.window_controls(); + let is_maximized = window.is_maximized(); + let rendered = buttons.into_iter().flatten().filter_map(move |button| { + match button { + WindowButton::Minimize if !controls.minimize => return None, + WindowButton::Maximize if !controls.maximize => return None, + _ => {} + } + + Some(linux_window_button(button, is_maximized, hover_bg)) + }); + + h_flex() + .id(id) + .h_full() + .min_w(px(8.)) + .items_center() + .gap_2() + .px_3() + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .children(rendered) +} + +#[cfg(target_os = "linux")] +fn linux_window_button( + button: WindowButton, + is_maximized: bool, + hover_bg: gpui::Hsla, +) -> impl IntoElement { + let (icon, label) = match button { + WindowButton::Minimize => (IconName::Minimize, tr!("Minimize")), + WindowButton::Maximize if is_maximized => (IconName::Maximize, tr!("Restore")), + WindowButton::Maximize => (IconName::Maximize, tr!("Maximize")), + WindowButton::Close => (IconName::Close, tr!("Close")), + }; + + h_flex() + .id(format!("linux-window-control-{}", button.id())) + .size(px(24.)) + .items_center() + .justify_center() + .rounded_md() + .cursor_pointer() + .hover(move |s| s.bg(hover_bg)) + .tooltip(move |window, cx| Tooltip::new(label.clone()).build(window, cx)) + .child(Icon::new(icon).size_4()) + .on_click(move |_, window, cx| { + cx.stop_propagation(); + match button { + WindowButton::Minimize => window.minimize_window(), + WindowButton::Maximize => window.zoom_window(), + WindowButton::Close => window.remove_window(), + } + }) +} diff --git a/crates/openlogi-gui/src/windows/about.rs b/crates/openlogi-gui/src/windows/about.rs index 5ad7ac21..037f82dc 100644 --- a/crates/openlogi-gui/src/windows/about.rs +++ b/crates/openlogi-gui/src/windows/about.rs @@ -175,74 +175,81 @@ pub fn open(cx: &mut App) { } impl Render for AboutView { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let pal = theme::palette(cx); - v_flex() - .size_full() - .bg(pal.bg) - .text_color(pal.text_primary) - .on_action(|_: &CloseWindow, window, _| window.remove_window()) - .on_action(|_: &Minimize, window, _| window.minimize_window()) - .on_action(|_: &Zoom, window, _| window.zoom_window()) - .items_center() - .justify_center() - .gap_3() - .p_8() - .child(img(crate::app_assets::LOGO).w(px(72.)).h(px(72.))) - .child( - div() - .text_2xl() - .font_weight(FontWeight::BOLD) - .child("OpenLogi"), - ) - .child( - div() - .id("about-version") - .text_sm() - .text_color(pal.text_muted) - .cursor_pointer() - .hover(|s| s.text_color(pal.text_primary)) - .child(concat!("v", env!("CARGO_PKG_VERSION"))) - .on_click(|_, _, cx| cx.open_url(&release_tag_url(env!("CARGO_PKG_VERSION")))), - ) - .child( - div() - .max_w(px(280.)) - .text_sm() - .text_center() - .text_color(pal.text_muted) - .child(tr!( - "Open-source Logitech mouse configuration — DPI, SmartShift, button \ + crate::window_chrome::frame( + "About OpenLogi", + v_flex() + .size_full() + .bg(pal.bg) + .text_color(pal.text_primary) + .on_action(|_: &CloseWindow, window, _| window.remove_window()) + .on_action(|_: &Minimize, window, _| window.minimize_window()) + .on_action(|_: &Zoom, window, _| window.zoom_window()) + .items_center() + .justify_center() + .gap_3() + .p_8() + .child(img(crate::app_assets::LOGO).w(px(72.)).h(px(72.))) + .child( + div() + .text_2xl() + .font_weight(FontWeight::BOLD) + .child("OpenLogi"), + ) + .child( + div() + .id("about-version") + .text_sm() + .text_color(pal.text_muted) + .cursor_pointer() + .hover(|s| s.text_color(pal.text_primary)) + .child(concat!("v", env!("CARGO_PKG_VERSION"))) + .on_click(|_, _, cx| { + cx.open_url(&release_tag_url(env!("CARGO_PKG_VERSION"))) + }), + ) + .child( + div() + .max_w(px(280.)) + .text_sm() + .text_center() + .text_color(pal.text_muted) + .child(tr!( + "Open-source Logitech mouse configuration — DPI, SmartShift, button \ bindings, and gestures." - )), - ) - .child( - h_flex() - .gap_3() - .pt_2() - .child( - Button::new("about-repo") - .outline() - .icon(IconName::Github) - .label("GitHub") - .on_click(|_, _, cx| cx.open_url(REPO_URL)), - ) - .child( - Button::new("about-releases") - .outline() - .icon(IconName::ExternalLink) - .label("Releases") - .on_click(|_, _, cx| cx.open_url(RELEASES_URL)), - ), - ) - .child(self.update_section(cx)) - .child(self.diagnostics_button(cx)) - .child( - div() - .text_xs() - .text_color(pal.text_muted) - .child("Licensed under MIT OR Apache-2.0"), - ) + )), + ) + .child( + h_flex() + .gap_3() + .pt_2() + .child( + Button::new("about-repo") + .outline() + .icon(IconName::Github) + .label("GitHub") + .on_click(|_, _, cx| cx.open_url(REPO_URL)), + ) + .child( + Button::new("about-releases") + .outline() + .icon(IconName::ExternalLink) + .label("Releases") + .on_click(|_, _, cx| cx.open_url(RELEASES_URL)), + ), + ) + .child(self.update_section(cx)) + .child(self.diagnostics_button(cx)) + .child( + div() + .text_xs() + .text_color(pal.text_muted) + .child("Licensed under MIT OR Apache-2.0"), + ), + window, + cx, + ) } } diff --git a/crates/openlogi-gui/src/windows/add_device.rs b/crates/openlogi-gui/src/windows/add_device.rs index b66d2f5c..7c91b127 100644 --- a/crates/openlogi-gui/src/windows/add_device.rs +++ b/crates/openlogi-gui/src/windows/add_device.rs @@ -131,26 +131,31 @@ impl AuxWindow for AddDeviceView { } impl Render for AddDeviceView { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let pal = theme::palette(cx); let state = cx.try_global::().cloned().unwrap_or_default(); - v_flex() - .size_full() - .bg(pal.bg) - .text_color(pal.text_primary) - .on_action(|_: &CloseWindow, window, _| window.remove_window()) - .on_action(|_: &Minimize, window, _| window.minimize_window()) - .on_action(|_: &Zoom, window, _| window.zoom_window()) - .p_6() - .gap_5() - .child( - div() - .text_lg() - .font_weight(FontWeight::SEMIBOLD) - .child(tr!("Add Device")), - ) - .child(body(&state, pal)) + crate::window_chrome::frame( + "Add Device", + v_flex() + .size_full() + .bg(pal.bg) + .text_color(pal.text_primary) + .on_action(|_: &CloseWindow, window, _| window.remove_window()) + .on_action(|_: &Minimize, window, _| window.minimize_window()) + .on_action(|_: &Zoom, window, _| window.zoom_window()) + .p_6() + .gap_5() + .child( + div() + .text_lg() + .font_weight(FontWeight::SEMIBOLD) + .child(tr!("Add Device")), + ) + .child(body(&state, pal)), + window, + cx, + ) } } diff --git a/crates/openlogi-gui/src/windows/mod.rs b/crates/openlogi-gui/src/windows/mod.rs index 1d1c47fc..cb652161 100644 --- a/crates/openlogi-gui/src/windows/mod.rs +++ b/crates/openlogi-gui/src/windows/mod.rs @@ -78,6 +78,8 @@ pub fn open_or_focus( appears_transparent: false, traffic_light_position: None, }), + app_id: Some(crate::APP_ID.to_string()), + icon: crate::app_assets::app_icon(), ..WindowOptions::default() }; diff --git a/crates/openlogi-gui/src/windows/settings.rs b/crates/openlogi-gui/src/windows/settings.rs index c87d637f..ba6628c5 100644 --- a/crates/openlogi-gui/src/windows/settings.rs +++ b/crates/openlogi-gui/src/windows/settings.rs @@ -142,23 +142,28 @@ pub fn open(cx: &mut App) { } impl Render for SettingsView { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let pal = theme::palette(cx); - div() - .size_full() - .bg(pal.bg) - .text_color(pal.text_primary) - .on_action(|_: &CloseWindow, window, _| window.remove_window()) - .on_action(|_: &Minimize, window, _| window.minimize_window()) - .on_action(|_: &Zoom, window, _| window.zoom_window()) - .child( - Settings::new("settings") - .sidebar_width(px(210.)) - .page(general_page(self.sensitivity_slider.clone())) - .page(permissions_page(pal)) - .page(language_page(self.language_select.clone())), - ) + crate::window_chrome::frame( + "Settings", + div() + .size_full() + .bg(pal.bg) + .text_color(pal.text_primary) + .on_action(|_: &CloseWindow, window, _| window.remove_window()) + .on_action(|_: &Minimize, window, _| window.minimize_window()) + .on_action(|_: &Zoom, window, _| window.zoom_window()) + .child( + Settings::new("settings") + .sidebar_width(px(210.)) + .page(general_page(self.sensitivity_slider.clone())) + .page(permissions_page(pal)) + .page(language_page(self.language_select.clone())), + ), + window, + cx, + ) } } @@ -302,10 +307,10 @@ fn permissions_page(pal: Palette) -> SettingPage { let field = gpui_component::v_flex().gap_1().child(status_badge(status)); let hint = match status { PermissionStatus::Denied => Some(tr!( - "OpenLogi needs write access to /dev/uinput (for button \ - remapping) and read/write access to /dev/hidraw* (for HID++ \ - communication). Install the OpenLogi udev rules to grant \ - access — see the Linux install guide." + "OpenLogi needs write access to /dev/uinput, read/write \ + access to /dev/hidraw*, and read access to Logitech \ + /dev/input/event* mouse nodes. Install the OpenLogi udev \ + rules to grant access — see the Linux install guide." )), PermissionStatus::Unknown => Some(tr!( "No Logitech device detected. Connect your device or verify \ diff --git a/crates/openlogi-gui/src/windows/update_consent.rs b/crates/openlogi-gui/src/windows/update_consent.rs index fe51f6b0..da1de68b 100644 --- a/crates/openlogi-gui/src/windows/update_consent.rs +++ b/crates/openlogi-gui/src/windows/update_consent.rs @@ -62,54 +62,59 @@ fn answer(enabled: bool, window: &mut Window, cx: &mut App) { } impl Render for UpdateConsentView { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let pal = theme::palette(cx); - v_flex() - .size_full() - .bg(pal.bg) - .text_color(pal.text_primary) - .on_action(|_: &CloseWindow, window, _| window.remove_window()) - .on_action(|_: &Minimize, window, _| window.minimize_window()) - .on_action(|_: &Zoom, window, _| window.zoom_window()) - .items_center() - .justify_center() - .gap_4() - .p_6() - .child( - div() - .text_lg() - .font_weight(FontWeight::SEMIBOLD) - .child(tr!("Check for updates?")), - ) - .child( - div() - .max_w(px(320.)) - .text_sm() - .text_center() - .text_color(pal.text_muted) - .child(tr!( - "OpenLogi can check GitHub once per launch for a new version — query \ + crate::window_chrome::frame( + "OpenLogi", + v_flex() + .size_full() + .bg(pal.bg) + .text_color(pal.text_primary) + .on_action(|_: &CloseWindow, window, _| window.remove_window()) + .on_action(|_: &Minimize, window, _| window.minimize_window()) + .on_action(|_: &Zoom, window, _| window.zoom_window()) + .items_center() + .justify_center() + .gap_4() + .p_6() + .child( + div() + .text_lg() + .font_weight(FontWeight::SEMIBOLD) + .child(tr!("Check for updates?")), + ) + .child( + div() + .max_w(px(320.)) + .text_sm() + .text_center() + .text_color(pal.text_muted) + .child(tr!( + "OpenLogi can check GitHub once per launch for a new version — query \ only, no automatic download or telemetry. You can change this anytime \ in Settings." - )), - ) - .child( - h_flex() - .gap_3() - .pt_2() - .child( - Button::new("update-consent-decline") - .outline() - .label(tr!("Not now")) - .on_click(|_, window, cx| answer(false, window, cx)), - ) - .child( - Button::new("update-consent-accept") - .primary() - .label(tr!("Enable")) - .on_click(|_, window, cx| answer(true, window, cx)), - ), - ) + )), + ) + .child( + h_flex() + .gap_3() + .pt_2() + .child( + Button::new("update-consent-decline") + .outline() + .label(tr!("Not now")) + .on_click(|_, window, cx| answer(false, window, cx)), + ) + .child( + Button::new("update-consent-accept") + .primary() + .label(tr!("Enable")) + .on_click(|_, window, cx| answer(true, window, cx)), + ), + ), + window, + cx, + ) } } diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 8b4d549d..1ce38577 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -121,17 +121,17 @@ layout: a 760×480 background image in a 760×512 Finder window, with 128px icon positioned at `(212, 250)` for `OpenLogi.app` and `(548, 250)` for `Applications`. -## Packaging Linux `.deb` / `.rpm` - -Requires [nfpm](https://nfpm.goreleaser.com/) on `PATH`; the package arch is -derived from the host (override with `PKG_ARCH`): +## Packaging Linux Releases ```sh -cargo run -p xtask -- package-linux # → target/release/openlogi_*.deb / .rpm +cargo run -p xtask -- package-linux ``` -The package contents (binaries, udev rules, systemd user unit, desktop entry, -icon) are declared in `packaging/linux/nfpm.yaml`. +The Linux packaging task builds the three release binaries, writes a portable +`openlogi--linux-.tar.gz`, and uses `nfpm` to create matching +`.deb` and `.rpm` packages in `target/release/`. + +Use `--no-build` to package an existing `target/release` build. ## Release updater publishing diff --git a/docs/INSTALL-linux.md b/docs/INSTALL-linux.md index ee4f0144..38fb5c5b 100644 --- a/docs/INSTALL-linux.md +++ b/docs/INSTALL-linux.md @@ -13,12 +13,52 @@ distros). - `systemd` + `udev` (standard on Ubuntu, Fedora, Arch, Debian, openSUSE, …). +## Install from a release + +Pre-built `.deb`, `.rpm`, and portable `.tar.gz` packages are available on the +[releases page](https://github.com/AprilNEA/OpenLogi/releases/latest). + +Use the native package when your distro supports it: + +```sh +# Debian / Ubuntu +sudo dpkg -i openlogi_*.deb + +# Fedora / RHEL / openSUSE +sudo rpm -i openlogi-*.rpm +``` + +For other distributions, use the portable tarball. It contains the three +prebuilt binaries plus the same installer metadata used by the distro packages: + +```sh +tar -xzf openlogi-*-linux-*.tar.gz +cd openlogi-*-linux-* +sudo packaging/linux/install.sh --prefix=/usr +``` + +The install script copies the binaries, udev rules, systemd user unit, desktop +entry, and icon into system paths. To remove a tarball install: + +```sh +sudo packaging/linux/uninstall.sh --prefix=/usr +``` + +After installing by any release package format, enable the background agent: + +```sh +systemctl --user enable --now openlogi-agent.service +``` + +Then launch **OpenLogi** from your desktop launcher, or run: + +```sh +openlogi-gui +``` + ## Build from source -Pre-built `.deb` and `.rpm` packages are available on the -[releases page](https://github.com/AprilNEA/OpenLogi/releases/latest) — see -the main [README](../README.md#linux) for the package-based install. To build -from source instead, use the stable Rust toolchain: +To build from source, use the stable Rust toolchain: ```sh git clone https://github.com/AprilNEA/OpenLogi @@ -42,6 +82,8 @@ OpenLogi needs: button remapping. - **Read/write access to `/dev/hidraw*`** — to send HID++ commands to the Bolt receiver. +- **Read access to Logitech `/dev/input/event*` mouse nodes** — to capture + remappable button events through evdev. Install the bundled udev rules to grant access to the active-seat user without requiring `sudo` or group membership (requires `systemd-logind`): @@ -61,6 +103,9 @@ openlogi-agent --check-uinput 2>/dev/null || \ # Check a hidraw node ls -la /dev/hidraw* + +# Check Logitech mouse event ACLs +getfacl /dev/input/event* | grep -A5 "$USER" || true ``` The GUI Settings → Permissions page shows a live `Granted` / `Not granted` @@ -69,8 +114,8 @@ indicator; check it after installing the rules (no restart needed). > **Device already connected?** `udevadm trigger` re-evaluates rules but does > not re-grant `uaccess` ACLs on nodes that were already open when the rules > were installed. If access is still denied, unplug and replug your receiver or -> mouse (or power-cycle for wireless devices) to let udev apply the new rules on -> reconnect. +> mouse, or disconnect and reconnect the Bluetooth device, to let udev apply the +> new rules on reconnect. ### Non-systemd systems (SysV init, OpenRC) @@ -82,7 +127,7 @@ sudo usermod -aG input "$USER" # Re-login for the group change to take effect. ``` -## Install with the script +## Install a source build with the script The `packaging/linux/install.sh` script copies the binaries, udev rules, systemd unit, desktop entry, and icon to system paths, then reloads `udevadm`. @@ -100,6 +145,24 @@ To remove: packaging/linux/uninstall.sh ``` +## Build Linux release packages + +Maintainers can build all Linux release artifacts from the repo root: + +```sh +cargo run -p xtask -- package-linux +``` + +The command builds release binaries, creates a portable +`openlogi--linux-.tar.gz`, and then uses `nfpm` to create `.deb` +and `.rpm` packages in `target/release/`. + +For local testing without rebuilding binaries: + +```sh +cargo run -p xtask -- package-linux --no-build +``` + ## Autostart (launch at login) The background agent (`openlogi-agent`) must be running for the GUI and CLI to diff --git a/packaging/linux/desktop/openlogi.desktop b/packaging/linux/desktop/openlogi.desktop index 35117cad..db76aa03 100644 --- a/packaging/linux/desktop/openlogi.desktop +++ b/packaging/linux/desktop/openlogi.desktop @@ -7,4 +7,5 @@ Icon=openlogi Terminal=false Categories=Settings;HardwareSettings; Keywords=logitech;mouse;hid;remap;dpi; +StartupWMClass=openlogi StartupNotify=true diff --git a/packaging/linux/install.sh b/packaging/linux/install.sh index 23ee35cc..117880cd 100755 --- a/packaging/linux/install.sh +++ b/packaging/linux/install.sh @@ -54,14 +54,19 @@ BINDIR="${PREFIX}/bin" # ── locate build output ──────────────────────────────────────────────────────── # Prefer a release build next to the script (typical: run from the repo root -# after `cargo build --release`). +# after `cargo build --release`). Release tarballs place the same binaries under +# ./bin so the archive can be installed without a Cargo checkout. REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" BUILD_DIR="${REPO_ROOT}/target/release" +if [ ! -x "${BUILD_DIR}/openlogi" ] && [ -x "${REPO_ROOT}/bin/openlogi" ]; then + BUILD_DIR="${REPO_ROOT}/bin" +fi for bin in openlogi openlogi-gui openlogi-agent; do if [ ! -x "${BUILD_DIR}/${bin}" ]; then echo "Error: ${BUILD_DIR}/${bin} not found." >&2 - echo "Build first: cargo build --release" >&2 + echo "Build first with: cargo build --release" >&2 + echo "Or install from an OpenLogi Linux release tarball." >&2 exit 1 fi done diff --git a/packaging/linux/udev/70-openlogi.rules b/packaging/linux/udev/70-openlogi.rules index 07c33df3..f94d9143 100644 --- a/packaging/linux/udev/70-openlogi.rules +++ b/packaging/linux/udev/70-openlogi.rules @@ -1,8 +1,8 @@ # OpenLogi udev rules # -# Grants the active-seat user read/write access to Logitech HID devices and the -# uinput kernel module — no group membership required on systems running -# systemd-logind (uaccess tag). +# Grants the active-seat user access to Logitech HID devices, Logitech input +# event nodes, and the uinput kernel module — no group membership required on +# systems running systemd-logind (uaccess tag). # # Install: # sudo cp 70-openlogi.rules /etc/udev/rules.d/ @@ -21,6 +21,11 @@ SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", TAG+="uaccess" SUBSYSTEM=="hidraw", KERNELS=="*:046D:*", TAG+="uaccess" +# Logitech mouse event nodes — needed for the evdev hook to grab side-button +# events before the desktop sees them. Bluetooth devices expose vendor/product +# on the parent input device, so match through ATTRS rather than ID_VENDOR_ID. +SUBSYSTEM=="input", ENV{ID_INPUT_MOUSE}=="1", ATTRS{id/vendor}=="046d", TAG+="uaccess" + # uinput virtual device node — needed for the evdev/uinput input hook. # OPTIONS+="static_node=uinput" creates the node at boot even before any device # is plugged in, so the agent can open it without a trigger. diff --git a/xtask/src/linux.rs b/xtask/src/linux.rs index 5f4bd56a..c0c1c26a 100644 --- a/xtask/src/linux.rs +++ b/xtask/src/linux.rs @@ -1,14 +1,15 @@ -use std::path::PathBuf; +use std::fs; +use std::path::{Path, PathBuf}; use std::process::Command as ProcessCommand; -use anyhow::Result; +use anyhow::{Context as _, Result}; use clap::Parser; -use crate::util::{absolutize, ensure_command, ensure_file, repo_root, run}; +use crate::util::{TempDir, absolutize, ensure_command, ensure_file, repo_root, run}; #[derive(Parser)] pub(crate) struct PackageLinux { - /// Output directory for .deb and .rpm packages (default: target/release). + /// Output directory for .deb, .rpm, and .tar.gz packages (default: target/release). #[arg(long, default_value = "target/release")] output: PathBuf, /// Skip the cargo build step (binaries must already exist in target/release). @@ -39,9 +40,9 @@ pub(crate) fn package_linux(args: &PackageLinux) -> Result<()> { ensure_file(&root.join("target/release").join(bin))?; } - ensure_command("nfpm")?; - let output = absolutize(&root, &args.output); + fs::create_dir_all(&output) + .with_context(|| format!("could not create output directory {}", output.display()))?; let config = root.join("packaging/linux/nfpm.yaml"); // nfpm stamps this into the package metadata and filename. The release CI @@ -53,6 +54,10 @@ pub(crate) fn package_linux(args: &PackageLinux) -> Result<()> { other => anyhow::bail!("unsupported Linux package architecture: {other}"), }; + build_tarball(&root, &output, pkg_arch)?; + + ensure_command("nfpm")?; + for packager in ["deb", "rpm"] { println!("==> nfpm {packager} ({pkg_arch})"); run(ProcessCommand::new("nfpm") @@ -69,3 +74,103 @@ pub(crate) fn package_linux(args: &PackageLinux) -> Result<()> { println!("Linux packages written to {}", output.display()); Ok(()) } + +fn build_tarball(root: &Path, output: &Path, pkg_arch: &str) -> Result<()> { + ensure_command("tar")?; + + let version = env!("CARGO_PKG_VERSION"); + let package_dir_name = format!("openlogi-{version}-linux-{pkg_arch}"); + let tmp = TempDir::new("openlogi-linux-package")?; + let package_dir = tmp.path().join(&package_dir_name); + + println!("==> tar.gz ({pkg_arch})"); + + fs::create_dir_all(package_dir.join("bin")) + .with_context(|| format!("could not create {}", package_dir.join("bin").display()))?; + fs::create_dir_all(package_dir.join("packaging/linux")).with_context(|| { + format!( + "could not create {}", + package_dir.join("packaging/linux").display() + ) + })?; + fs::create_dir_all(package_dir.join("design/icon")).with_context(|| { + format!( + "could not create {}", + package_dir.join("design/icon").display() + ) + })?; + fs::create_dir_all(package_dir.join("docs")) + .with_context(|| format!("could not create {}", package_dir.join("docs").display()))?; + + for bin in ["openlogi", "openlogi-gui", "openlogi-agent"] { + copy_file( + &root.join("target/release").join(bin), + &package_dir.join("bin").join(bin), + )?; + } + + copy_dir( + &root.join("packaging/linux"), + &package_dir.join("packaging/linux"), + )?; + copy_file( + &root.join("design/icon/openlogi.png"), + &package_dir.join("design/icon/openlogi.png"), + )?; + + for file in [ + "README.md", + "CHANGELOG.md", + "LICENSE-APACHE", + "LICENSE-MIT", + "docs/INSTALL-linux.md", + ] { + copy_file(&root.join(file), &package_dir.join(file))?; + } + + let archive = output.join(format!("{package_dir_name}.tar.gz")); + run(ProcessCommand::new("tar") + .arg("-czf") + .arg(&archive) + .arg("-C") + .arg(tmp.path()) + .arg(&package_dir_name))?; + + Ok(()) +} + +fn copy_dir(src: &Path, dst: &Path) -> Result<()> { + fs::create_dir_all(dst).with_context(|| format!("could not create {}", dst.display()))?; + + for entry in fs::read_dir(src).with_context(|| format!("could not read {}", src.display()))? { + let entry = entry.with_context(|| format!("could not read entry in {}", src.display()))?; + let file_type = entry + .file_type() + .with_context(|| format!("could not inspect {}", entry.path().display()))?; + let target = dst.join(entry.file_name()); + if file_type.is_dir() { + copy_dir(&entry.path(), &target)?; + } else if file_type.is_file() { + copy_file(&entry.path(), &target)?; + } + } + + Ok(()) +} + +fn copy_file(src: &Path, dst: &Path) -> Result<()> { + if let Some(parent) = dst.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("could not create {}", parent.display()))?; + } + fs::copy(src, dst) + .with_context(|| format!("could not copy {} to {}", src.display(), dst.display()))?; + + let permissions = fs::metadata(src) + .with_context(|| format!("could not read metadata for {}", src.display()))? + .permissions(); + fs::set_permissions(dst, permissions) + .with_context(|| format!("could not set permissions on {}", dst.display()))?; + + Ok(()) +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index f5538f3f..c45b09d1 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -28,7 +28,7 @@ enum Command { DmgMacos(DmgMacos), /// Build the app bundle and package it into the branded macOS DMG. PackageMacos(DmgMacos), - /// Build release binaries and package them into .deb and .rpm (Linux). + /// Build release binaries and package them into .deb, .rpm, and .tar.gz (Linux). PackageLinux(PackageLinux), }