From bc748eacf92aa2229fb600a86f5ede0c9f5e5430 Mon Sep 17 00:00:00 2001 From: JFly02 Date: Thu, 9 Apr 2026 23:50:33 +0200 Subject: [PATCH 1/2] Added custom button mapping --- Mouser.spec | 94 ++++++++++++++++++++++++++++++++++++++++++++++++++--- README.md | 2 +- 2 files changed, 90 insertions(+), 6 deletions(-) diff --git a/Mouser.spec b/Mouser.spec index dc457e5..ae44991 100644 --- a/Mouser.spec +++ b/Mouser.spec @@ -14,16 +14,96 @@ block_cipher = None ROOT = os.path.abspath(".") PYSIDE6_DIR = os.path.dirname(PySide6.__file__) + +def _dir_if_exists(src, dest): + if os.path.isdir(src): + return [(src, dest)] + return [] + + +def _file_if_exists(src, dest): + if os.path.isfile(src): + return [(src, dest)] + return [] + + +_manual_qt_datas = [] +for rel in ( + os.path.join("plugins", "platforms"), + os.path.join("plugins", "imageformats"), + os.path.join("plugins", "iconengines"), + os.path.join("plugins", "styles"), + os.path.join("plugins", "platforminputcontexts"), + os.path.join("qml", "Qt"), + os.path.join("qml", "QtCore"), + os.path.join("qml", "QtNetwork"), + os.path.join("qml", "QtQml"), + os.path.join("qml", "QtQuick"), +): + _manual_qt_datas += _dir_if_exists( + os.path.join(PYSIDE6_DIR, rel), + os.path.join("PySide6", rel), + ) + +_manual_qt_binaries = [] +for name in ( + "MSVCP140.dll", + "MSVCP140_1.dll", + "MSVCP140_2.dll", + "VCRUNTIME140.dll", + "VCRUNTIME140_1.dll", + "opengl32sw.dll", + "pyside6.abi3.dll", + "pyside6qml.abi3.dll", + "shiboken6.abi3.dll", + "Qt6Core.dll", + "Qt6Gui.dll", + "Qt6Network.dll", + "Qt6OpenGL.dll", + "Qt6Qml.dll", + "Qt6QmlCore.dll", + "Qt6QmlMeta.dll", + "Qt6QmlModels.dll", + "Qt6QmlNetwork.dll", + "Qt6QmlWorkerScript.dll", + "Qt6Quick.dll", + "Qt6QuickControls2.dll", + "Qt6QuickControls2Basic.dll", + "Qt6QuickControls2BasicStyleImpl.dll", + "Qt6QuickControls2Impl.dll", + "Qt6QuickControls2Material.dll", + "Qt6QuickControls2MaterialStyleImpl.dll", + "Qt6QuickControls2WindowsStyleImpl.dll", + "Qt6QuickEffects.dll", + "Qt6QuickLayouts.dll", + "Qt6QuickShapes.dll", + "Qt6QuickTemplates2.dll", + "Qt6ShaderTools.dll", + "Qt6Svg.dll", + "Qt6Widgets.dll", + "Qt6LabsAnimation.dll", + "Qt6LabsFolderListModel.dll", + "Qt6LabsPlatform.dll", + "Qt6LabsQmlModels.dll", + "Qt6LabsSettings.dll", + "Qt6LabsSharedImage.dll", + "Qt6LabsWavefrontMesh.dll", +): + _manual_qt_binaries += _file_if_exists( + os.path.join(PYSIDE6_DIR, name), + "PySide6", + ) + a = Analysis( ["main_qml.py"], pathex=[ROOT], - binaries=[], + binaries=_manual_qt_binaries, datas=[ # QML UI files (os.path.join(ROOT, "ui", "qml"), os.path.join("ui", "qml")), # Image assets (os.path.join(ROOT, "images"), "images"), - ], + ] + _manual_qt_datas, hiddenimports=[ # conditional / lazy imports PyInstaller may miss "hid", @@ -119,10 +199,14 @@ _qt_keep = { "Qt6Quick", "Qt6QuickControls2", "Qt6QuickControls2Impl", "Qt6QuickControls2Basic", "Qt6QuickControls2BasicStyleImpl", "Qt6QuickControls2Material", "Qt6QuickControls2MaterialStyleImpl", + "Qt6QuickControls2WindowsStyleImpl", "Qt6QuickTemplates2", "Qt6QuickLayouts", "Qt6QuickEffects", "Qt6QuickShapes", + "Qt6LabsAnimation", "Qt6LabsFolderListModel", "Qt6LabsPlatform", + "Qt6LabsQmlModels", "Qt6LabsSettings", "Qt6LabsSharedImage", + "Qt6LabsWavefrontMesh", # Rendering - "Qt6ShaderTools", "Qt6Svg", + "Qt6ShaderTools", "Qt6Svg", "opengl32sw", # PySide6 runtime "pyside6.abi3", "pyside6qml.abi3", "shiboken6.abi3", # VC runtime @@ -150,7 +234,7 @@ def _should_keep(name): if keep in name: return True # Keep QML dirs we need - for keep_qml in ("QtCore", "QtQml", "QtQuick", "QtNetwork"): + for keep_qml in ("Qt", "QtCore", "QtQml", "QtQuick", "QtNetwork"): pat = os.path.join("qml", keep_qml) if pat in name.replace("/", os.sep): return True @@ -194,7 +278,7 @@ _dist = os.path.join("dist", "Mouser", "_internal", "PySide6") # QML dirs to KEEP (everything else under qml/ is deleted) _keep_qml = { - "QtCore", "QtQml", "QtQuick", "QtNetwork", + "Qt", "QtCore", "QtQml", "QtQuick", "QtNetwork", } # Under QtQuick, keep only what the app uses diff --git a/README.md b/README.md index 9f21cd1..04ae8ae 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ Action labels adapt by platform. For example, Windows exposes `Win+D` and `Task | Category | Actions | |---|---| -| **Navigation** | Alt+Tab, Alt+Shift+Tab, Show Desktop, Previous Desktop, Next Desktop, Task View (Windows), Mission Control (macOS), App Expose (macOS), Launchpad (macOS) | +| **Navigation** | Alt+Tab, Alt+Shift+Tab, Show Desktop, Previous Desktop, Next Desktop, Task View (Windows), Snipping Tool (Windows), Mission Control (macOS), App Expose (macOS), Launchpad (macOS) | | **Browser** | Back, Forward, Close Tab (Ctrl+W), New Tab (Ctrl+T), Next Tab (Ctrl+Tab), Previous Tab (Ctrl+Shift+Tab) | | **Editing** | Copy, Paste, Cut, Undo, Select All, Save, Find | | **Media** | Volume Up, Volume Down, Volume Mute, Play/Pause, Next Track, Previous Track | From 2749f4a832f1125c78e7dce42ab8a8451b0622b8 Mon Sep 17 00:00:00 2001 From: Jan Hanewinkel Date: Sun, 19 Apr 2026 13:58:40 +0200 Subject: [PATCH 2/2] Improve Linux device support and startup integration --- README.md | 91 ++++++++++++++++++++++-- core/hid_gesture.py | 143 +++++++++++++++++++++++++++++++++++++- core/mouse_hook.py | 34 ++++++--- core/startup.py | 73 +++++++++++++++++-- main_qml.py | 5 +- tests/test_hid_gesture.py | 51 ++++++++++++++ tests/test_mouse_hook.py | 46 ++++++++++++ tests/test_startup.py | 72 +++++++++++++++++++ ui/backend.py | 7 +- ui/qml/ScrollPage.qml | 2 +- 10 files changed, 496 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 04ae8ae..d85e446 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ No telemetry. No cloud. No Logitech account required. ### 🖥️ Cross-Platform - **Windows, macOS, and Linux** — native hooks on each platform (WH_MOUSE_LL, CGEventTap, evdev/uinput) -- **Start at login** — Windows registry and macOS LaunchAgent, with an independent "Start minimized" tray-only option +- **Start at login** — Windows registry, macOS LaunchAgent, and Linux XDG autostart, with an independent "Start minimized" tray-only option - **Single instance guard** — launching a second copy brings the existing window to the front ### 🔌 Smart Connectivity @@ -175,6 +175,60 @@ pip install -r requirements.txt | `pyobjc-framework-Cocoa` | macOS app detection and media-key support | | `evdev` | Linux mouse grab and virtual device forwarding (uinput) | +### Fedora / GNOME Setup + +Fedora needs two extra pieces beyond `pip install -r requirements.txt`: + +1. Device permissions for `hidraw` and `uinput` +2. A fresh login session after adding your user to the `input` group + +Install the Fedora system package used to build `evdev`: + +```bash +sudo dnf install -y python3-devel xdotool +``` + +Create the udev rule that grants Mouser access to Logitech `hidraw` devices +and `/dev/uinput`, then add your user to the `input` group: + +```bash +sudo tee /etc/udev/rules.d/99-mouser-fedora-input.rules >/dev/null <<'EOF' +KERNEL=="uinput", MODE="0660", GROUP="input", OPTIONS+="static_node=uinput" +SUBSYSTEM=="hidraw", KERNELS=="*046D:*", MODE="0660", GROUP="input" +EOF + +sudo usermod -aG input "$USER" +sudo modprobe uinput +sudo udevadm control --reload-rules +sudo udevadm trigger +``` + +> **Important:** launching Mouser from a terminal after `newgrp input` is not +> enough for the desktop launcher or autostart entry. You must fully log out +> and back in (or reboot) so your GNOME session, app launcher, and autostarted +> processes all inherit the new `input` group membership. + +After logging back in, verify the device access before troubleshooting Mouser: + +```bash +id + +python - <<'PY' +import os +for path in ('/dev/hidraw8', '/dev/uinput'): + try: + fd = os.open(path, os.O_RDWR | os.O_NONBLOCK) + except Exception as exc: + print(path, 'FAIL', repr(exc)) + else: + print(path, 'OK') + os.close(fd) +PY +``` + +If both devices report `OK`, Mouser should be able to connect to supported +Logitech mice from both the terminal and the app menu. + ### Running ```bash @@ -206,6 +260,33 @@ Use this only for troubleshooting. On macOS, Mouser now defaults to `iokit`; `hidapi` and `auto` remain available as manual overrides for debugging. Other platforms continue to default to `auto`. +### Linux App Launcher + +Linux autostart writes an entry under `~/.config/autostart`, which does **not** +make Mouser show up in the desktop app grid. To add a regular launcher entry on +GNOME/KDE, create a `.desktop` file in `~/.local/share/applications`: + +```bash +mkdir -p ~/.local/share/applications + +cat > ~/.local/share/applications/io.github.tombadash.mouser.desktop <<'EOF' +[Desktop Entry] +Type=Application +Version=1.0 +Name=Mouser +Comment=Logitech mouse remapper +Exec=/path/to/mouser/.venv/bin/python /path/to/mouser/main_qml.py +Path=/path/to/mouser +Icon=/path/to/mouser/images/logo_icon.png +Terminal=false +StartupNotify=false +Categories=Utility;Settings; +EOF +``` + +Replace `/path/to/mouser` with your checkout path. After that, Mouser appears +in the app menu and can be pinned like a normal desktop app. + ### Creating a Desktop Shortcut A `Mouser.lnk` shortcut is included. To create one manually: @@ -373,7 +454,7 @@ mouser/ │ ├── logi_devices.py # Known Logitech device catalog + connected-device metadata │ ├── device_layouts.py # Device-family layout registry for QML overlays │ ├── key_simulator.py # Platform-specific action simulator -│ ├── startup.py # Cross-platform login startup (Windows registry + macOS LaunchAgent) +│ ├── startup.py # Cross-platform login startup (Windows registry + macOS LaunchAgent + Linux autostart) │ ├── config.py # Config manager (JSON load/save/migrate) │ └── app_detector.py # Foreground app polling │ @@ -415,7 +496,7 @@ The app has two pages accessible from a slim sidebar: - **DPI slider:** 200–8000 with quick presets (400, 800, 1000, 1600, 2400, 4000, 6000, 8000). Reads the current DPI from the device on startup. - **Scroll inversion:** Independent toggles for vertical and horizontal scroll direction. - **Smart Shift:** Toggle Logitech Smart Shift (ratchet-to-free-spin scroll mode switching) on or off. -- **Startup controls:** **Start at login** (Windows and macOS) and **Start minimized** (all platforms) to launch directly into the system tray. +- **Startup controls:** **Start at login** (Windows, macOS, and Linux) and **Start minimized** (all platforms) to launch directly into the system tray. --- @@ -436,7 +517,7 @@ The app has two pages accessible from a slim sidebar: - [ ] **True per-device config** — separate mappings and layout state cleanly when multiple Logitech mice are used on the same machine - [ ] **Dynamic button inventory** — build button lists from discovered `REPROG_CONTROLS_V4` controls instead of relying on the current fixed mapping set - [x] **Custom key combos** — user-defined arbitrary key sequences (e.g., Ctrl+Shift+P) -- [x] **Windows login item support** — cross-platform login startup via Windows registry and macOS LaunchAgent +- [x] **Desktop login item support** — cross-platform login startup via Windows registry, macOS LaunchAgent, and Linux autostart - [ ] **Improved scroll inversion** — explore driver-level or interception-driver approaches - [ ] **Gesture swipe tuning** — improve swipe reliability and defaults across more Logitech devices - [ ] **Per-app profile auto-creation** — detect new apps and prompt to create a profile @@ -484,7 +565,7 @@ This project is licensed under the [MIT License](LICENSE). - **[@andrew-sz](https://github.com/andrew-sz)** — macOS port: CGEventTap mouse hooking, Quartz key simulation, NSWorkspace app detection, and NSEvent media key support - **[@thisislvca](https://github.com/thisislvca)** — significant expansion of the project including macOS compatibility improvements, multi-device support, new UI features, and active involvement in triaging and resolving open issues -- **[@awkure](https://github.com/awkure)** — cross-platform login startup (Windows registry + macOS LaunchAgent), single-instance guard, start minimized option, and MX Master 4 detection +- **[@awkure](https://github.com/awkure)** — cross-platform login startup (Windows registry + macOS LaunchAgent, later extended to Linux autostart), single-instance guard, start minimized option, and MX Master 4 detection - **[@hieshima](https://github.com/hieshima)** — Linux support (evdev + HID++ + uinput), mode shift button mapping, Smart Shift toggle, and custom keyboard shortcut support - **[@pavelzaichyk](https://github.com/pavelzaichyk)** — Next Tab and Previous Tab browser actions diff --git a/core/hid_gesture.py b/core/hid_gesture.py index 56c5db5..c78441d 100644 --- a/core/hid_gesture.py +++ b/core/hid_gesture.py @@ -11,10 +11,13 @@ Falls back gracefully if the package or device are unavailable. """ +import os +import select import sys import queue import threading import time +from pathlib import Path from core.logi_devices import ( DEFAULT_GESTURE_CIDS, @@ -64,6 +67,45 @@ def read(self, size, timeout_ms=0): def close(self): self._dev.close() + +class _LinuxHidrawDevice: + """Minimal hidraw transport used when hidapi can't open a Linux node.""" + + def __init__(self, path): + if isinstance(path, memoryview): + path = bytes(path) + if isinstance(path, bytes): + path = path.decode("utf-8", errors="replace") + self._path = path + self._fd = None + + def open(self): + self._fd = os.open(self._path, os.O_RDWR | os.O_NONBLOCK) + + def set_nonblocking(self, enabled): + # The fd stays non-blocking; read() below implements timeout semantics. + return None + + def write(self, data): + return os.write(self._fd, bytes(data)) + + def read(self, size, timeout_ms=0): + if self._fd is None: + return None + timeout = None if timeout_ms is None else max(timeout_ms, 0) / 1000.0 + readable, _, _ = select.select([self._fd], [], [], timeout) + if not readable: + return None + try: + return os.read(self._fd, size) + except BlockingIOError: + return None + + def close(self): + if self._fd is not None: + os.close(self._fd) + self._fd = None + _MAC_NATIVE_OK = False if sys.platform == "darwin": try: @@ -658,11 +700,18 @@ def add_info(info): if HIDAPI_OK and _BACKEND_PREFERENCE in ("auto", "hidapi"): try: for info in _hid.enumerate(LOGI_VID, 0): - if info.get("usage_page", 0) >= 0xFF00: + usage_page = int(info.get("usage_page", 0) or 0) + # Linux hidapi often reports usage_page=0 even for usable + # Logitech receiver interfaces, so do not filter those out. + if sys.platform == "linux" or usage_page >= 0xFF00: add_info(dict(info, source="hidapi-enumerate")) except Exception as exc: print(f"[HidGesture] hidapi enumerate error: {exc}") + if sys.platform == "linux": + for info in HidGestureListener._linux_hidraw_infos(): + add_info(info) + if ( sys.platform == "darwin" and _MAC_NATIVE_OK @@ -671,8 +720,88 @@ def add_info(info): for info in _MacNativeHidDevice.enumerate_infos(): add_info(info) + out.sort(key=HidGestureListener._candidate_sort_key, reverse=True) + return out + + @staticmethod + def _linux_hidraw_infos(): + """Discover Logitech hidraw nodes that hidapi may miss on Linux.""" + out = [] + transport_by_bus = { + 0x0003: "USB", + 0x0005: "Bluetooth Low Energy", + } + for hidraw_dir in sorted(Path("/sys/class/hidraw").glob("hidraw*")): + uevent_path = hidraw_dir / "device" / "uevent" + try: + lines = uevent_path.read_text(encoding="utf-8").splitlines() + except OSError: + continue + props = {} + for line in lines: + key, _, value = line.partition("=") + if key: + props[key] = value + try: + bus_hex, vid_hex, pid_hex = props["HID_ID"].split(":") + bus = int(bus_hex, 16) + vendor_id = int(vid_hex, 16) + product_id = int(pid_hex, 16) + except (KeyError, ValueError): + continue + if vendor_id != LOGI_VID: + continue + devnode = f"/dev/{hidraw_dir.name}" + if not os.path.exists(devnode): + continue + out.append({ + "path": devnode.encode("utf-8"), + "vendor_id": vendor_id, + "product_id": product_id, + "usage_page": 0, + "usage": 0, + "transport": transport_by_bus.get(bus, ""), + "product_string": props.get("HID_NAME", ""), + "serial_number": props.get("HID_UNIQ", ""), + "source": "linux-hidraw-enumerate", + }) return out + @staticmethod + def _candidate_sort_key(info): + pid = int(info.get("product_id", 0) or 0) + product = info.get("product_string") or "" + transport = (info.get("transport") or "").lower() + source = info.get("source") or "" + path = info.get("path") or b"" + if isinstance(path, bytes): + path = path.decode("utf-8", errors="replace") + spec = resolve_device(product_id=pid, product_name=product) + product_lower = product.lower() + return ( + int(spec is not None), + int("bluetooth" in transport), + int("receiver" not in product_lower), + int(source == "linux-hidraw-enumerate"), + int(path.startswith("/dev/hidraw")), + pid, + product_lower, + ) + + @staticmethod + def _open_linux_hidraw(info): + path = info.get("path") or b"" + if isinstance(path, bytes): + path_str = path.decode("utf-8", errors="replace") + else: + path_str = str(path) + if not path_str.startswith("/dev/hidraw"): + return None + dev = _LinuxHidrawDevice(path_str) + dev.open() + dev.set_nonblocking(False) + return dev + # ── low-level HID++ I/O ─────────────────────────────────────── def _tx(self, report_id, feat, func, params): @@ -1279,11 +1408,19 @@ def _try_connect(self): else: if not HIDAPI_OK: continue - if _HID_API_STYLE == "hidapi": + if sys.platform == "linux": + try: + d = self._open_linux_hidraw(open_info) + except Exception: + d = None + else: + d = None + if d is None and _HID_API_STYLE == "hidapi": d = _hid.device() d.open_path(open_info["path"]) else: - d = _HidDeviceCompat(open_info["path"]) + if d is None: + d = _HidDeviceCompat(open_info["path"]) d.set_nonblocking(False) self._dev = d print(f"[HidGesture] Opened PID=0x{pid:04X} via {transport}") diff --git a/core/mouse_hook.py b/core/mouse_hook.py index 5fa3b29..d83735d 100644 --- a/core/mouse_hook.py +++ b/core/mouse_hook.py @@ -1573,6 +1573,7 @@ def stop(self): import select as _select_mod import evdev as _evdev_mod from evdev import ecodes as _ecodes, UInput as _UInput, InputDevice as _InputDevice + from core.logi_devices import resolve_device as _resolve_logi_device _EVDEV_OK = True except ImportError: _EVDEV_OK = False @@ -1950,8 +1951,7 @@ def _on_hid_disconnect(self): def _find_mouse_device(self): """Find the best mouse evdev device (prefer Logitech).""" - logi_mice = [] - other_mice = [] + candidates = [] for path in _evdev_mod.list_devices(): try: dev = _InputDevice(path) @@ -1978,16 +1978,28 @@ def _find_mouse_device(self): except Exception: dev.close() continue - if dev.info.vendor == _LOGI_VENDOR: - logi_mice.append((dev, has_side)) - else: - other_mice.append((dev, has_side)) + candidates.append((dev, has_side)) - ordered = sorted( - logi_mice, key=lambda x: -x[1] - ) + sorted( - other_mice, key=lambda x: -x[1] - ) + def _event_num(dev): + try: + return int(str(dev.path).rsplit("event", 1)[1]) + except (IndexError, ValueError): + return -1 + + def _sort_key(item): + dev, has_side = item + spec = _resolve_logi_device( + product_id=getattr(dev.info, "product", None), + product_name=dev.name, + ) + return ( + int(dev.info.vendor == _LOGI_VENDOR), + int(spec is not None), + int(has_side), + _event_num(dev), + ) + + ordered = sorted(candidates, key=_sort_key, reverse=True) if ordered: chosen = ordered[0][0] for dev, _ in ordered[1:]: diff --git a/core/startup.py b/core/startup.py index 563c089..905cd42 100644 --- a/core/startup.py +++ b/core/startup.py @@ -1,7 +1,8 @@ -"""Cross-platform login startup: Windows HKCU Run and macOS LaunchAgent.""" +"""Cross-platform login startup: Windows HKCU Run, macOS LaunchAgent, Linux autostart.""" import os import plistlib +import shlex import subprocess import sys @@ -13,9 +14,12 @@ MACOS_LAUNCH_AGENT_LABEL = "io.github.tombadash.mouser" MACOS_PLIST_NAME = f"{MACOS_LAUNCH_AGENT_LABEL}.plist" +# Linux +LINUX_AUTOSTART_NAME = "io.github.tombadash.mouser.desktop" + def supports_login_startup(): - return sys.platform in ("win32", "darwin") + return sys.platform in ("win32", "darwin", "linux") def _quote_arg(s: str) -> str: @@ -32,7 +36,7 @@ def build_run_command() -> str: exe_q = _quote_arg(exe) if getattr(sys, "frozen", False): return exe_q - script = os.path.abspath(sys.argv[0]) + script = _entry_script_path() return f"{exe_q} {_quote_arg(script)}" @@ -41,7 +45,51 @@ def _program_arguments(): exe = os.path.abspath(sys.executable) if getattr(sys, "frozen", False): return [exe] - return [exe, os.path.abspath(sys.argv[0])] + return [exe, _entry_script_path()] + + +def _entry_script_path() -> str: + raw_argv0 = (sys.argv[0] or "").strip() + argv0 = os.path.abspath(raw_argv0) + if raw_argv0 and os.path.basename(raw_argv0) not in {"-", "-c"}: + return argv0 + return os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "main_qml.py")) + + +def _linux_autostart_dir() -> str: + config_home = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config") + return os.path.join(config_home, "autostart") + + +def _linux_desktop_path() -> str: + return os.path.join(_linux_autostart_dir(), LINUX_AUTOSTART_NAME) + + +def _linux_exec_line() -> str: + return " ".join(shlex.quote(arg) for arg in _program_arguments()) + + +def _linux_desktop_entry() -> str: + icon_path = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "images", "logo_icon.png") + ) + working_dir = os.path.dirname(os.path.abspath(sys.argv[0])) or os.getcwd() + lines = [ + "[Desktop Entry]", + "Type=Application", + "Version=1.0", + "Name=Mouser", + "Comment=Logitech mouse remapper", + f"Exec={_linux_exec_line()}", + f"Path={working_dir}", + f"Icon={icon_path}", + "Terminal=false", + "StartupNotify=false", + "Categories=Utility;", + "X-GNOME-Autostart-enabled=true", + "", + ] + return "\n".join(lines) def _get_winreg(): @@ -126,6 +174,21 @@ def _apply_macos(enabled: bool) -> None: ) +def _apply_linux(enabled: bool) -> None: + if sys.platform != "linux": + return + desktop_path = _linux_desktop_path() + if enabled: + os.makedirs(os.path.dirname(desktop_path), exist_ok=True) + with open(desktop_path, "w", encoding="utf-8") as f: + f.write(_linux_desktop_entry()) + return + try: + os.remove(desktop_path) + except FileNotFoundError: + pass + + def apply_login_startup(enabled: bool) -> None: if not supports_login_startup(): return @@ -133,6 +196,8 @@ def apply_login_startup(enabled: bool) -> None: _apply_windows(enabled) elif sys.platform == "darwin": _apply_macos(enabled) + elif sys.platform == "linux": + _apply_linux(enabled) def sync_from_config(enabled: bool) -> None: diff --git a/main_qml.py b/main_qml.py index e7301ef..3f3c60b 100644 --- a/main_qml.py +++ b/main_qml.py @@ -387,8 +387,9 @@ def main(): _configure_macos_app_mode() ui_state = UiState(app) - # macOS: allow Ctrl+C in terminal to quit the app - signal.signal(signal.SIGINT, signal.SIG_DFL) + # Allow Ctrl+C to terminate cleanly when launched from a terminal. + if sys.platform != "win32": + signal.signal(signal.SIGINT, signal.SIG_DFL) if sys.platform == "darwin": # SIGUSR1 thread dump (useful for debugging on macOS) diff --git a/tests/test_hid_gesture.py b/tests/test_hid_gesture.py index f10766d..3a79ee1 100644 --- a/tests/test_hid_gesture.py +++ b/tests/test_hid_gesture.py @@ -1,4 +1,5 @@ import unittest +from unittest.mock import patch from core import hid_gesture @@ -48,5 +49,55 @@ def test_choose_gesture_candidates_falls_back_to_defaults(self): ) +class LinuxHidDiscoveryTests(unittest.TestCase): + def test_vendor_hid_infos_prefers_known_bluetooth_hidraw_device(self): + receiver_info = { + "path": b"3-9:1.2", + "vendor_id": 0x046D, + "product_id": 0xC52B, + "usage_page": 0, + "usage": 0, + "product_string": "", + "transport": "", + } + mx_master_info = { + "path": b"/dev/hidraw8", + "vendor_id": 0x046D, + "product_id": 0xB034, + "usage_page": 0, + "usage": 0, + "product_string": "Logitech MX Master 3S", + "transport": "Bluetooth Low Energy", + "source": "linux-hidraw-enumerate", + } + + with ( + patch.object(hid_gesture.sys, "platform", "linux"), + patch.object(hid_gesture, "HIDAPI_OK", True), + patch.object(hid_gesture._hid, "enumerate", return_value=[receiver_info]), + patch.object( + hid_gesture.HidGestureListener, + "_linux_hidraw_infos", + return_value=[mx_master_info], + ), + ): + infos = hid_gesture.HidGestureListener._vendor_hid_infos() + + self.assertEqual(infos[0]["product_id"], 0xB034) + self.assertEqual(infos[1]["product_id"], 0xC52B) + + def test_open_linux_hidraw_uses_raw_device_wrapper(self): + info = {"path": b"/dev/hidraw8"} + + with patch.object(hid_gesture, "_LinuxHidrawDevice") as raw_dev: + device = raw_dev.return_value + result = hid_gesture.HidGestureListener._open_linux_hidraw(info) + + raw_dev.assert_called_once_with("/dev/hidraw8") + device.open.assert_called_once_with() + device.set_nonblocking.assert_called_once_with(False) + self.assertIs(result, device) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_mouse_hook.py b/tests/test_mouse_hook.py index e4d400b..ebd085b 100644 --- a/tests/test_mouse_hook.py +++ b/tests/test_mouse_hook.py @@ -39,6 +39,52 @@ def test_hid_reconnect_does_not_rescan_when_evdev_already_grabs_logitech(self): self.assertTrue(hook.device_connected) self.assertFalse(hook._rescan_requested.is_set()) + def test_find_mouse_device_prefers_known_logitech_model_over_legacy_logitech(self): + module = self._reload_for_linux() + + class FakeDevice: + def __init__(self, path, name, vendor, product): + self.path = path + self.name = name + self.info = SimpleNamespace(vendor=vendor, product=product) + self.closed = False + + def capabilities(self, absinfo=False): + return { + module._ecodes.EV_REL: [ + module._ecodes.REL_X, + module._ecodes.REL_Y, + ], + module._ecodes.EV_KEY: [ + module._ecodes.BTN_LEFT, + module._ecodes.BTN_RIGHT, + module._ecodes.BTN_MIDDLE, + module._ecodes.BTN_SIDE, + module._ecodes.BTN_EXTRA, + ], + } + + def close(self): + self.closed = True + + legacy = FakeDevice("/dev/input/event11", "Logitech Performance MX", module._LOGI_VENDOR, 0x101A) + modern = FakeDevice("/dev/input/event22", "Logitech MX Master 3S", module._LOGI_VENDOR, 0xB034) + devices = { + legacy.path: legacy, + modern.path: modern, + } + hook = module.MouseHook() + + with ( + patch.object(module._evdev_mod, "list_devices", return_value=list(devices)), + patch.object(module, "_InputDevice", side_effect=lambda path: devices[path]), + ): + chosen = hook._find_mouse_device() + + self.assertIs(chosen, modern) + self.assertTrue(legacy.closed) + self.assertFalse(modern.closed) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_startup.py b/tests/test_startup.py index 85ad06a..948fd09 100644 --- a/tests/test_startup.py +++ b/tests/test_startup.py @@ -230,6 +230,78 @@ def test_macos_disable_uses_label_bootout_when_no_plist(self): ) +class ApplyLoginStartupLinuxTests(unittest.TestCase): + def test_supports_login_startup_on_linux(self): + with patch.object(sys, "platform", "linux"): + self.assertTrue(st.supports_login_startup()) + + def test_linux_exec_line_uses_program_arguments(self): + with patch.object( + st, + "_program_arguments", + return_value=["/opt/Mouser App/python", "/tmp/Mouser/main_qml.py"], + ): + exec_line = st._linux_exec_line() + self.assertEqual(exec_line, "'/opt/Mouser App/python' /tmp/Mouser/main_qml.py") + + def test_linux_desktop_entry_contains_exec_path_and_icon(self): + with ( + patch.object(sys, "argv", ["/tmp/Mouser/main_qml.py"]), + patch.object( + st, + "_program_arguments", + return_value=["/tmp/Mouser/.venv/bin/python", "/tmp/Mouser/main_qml.py"], + ), + ): + entry = st._linux_desktop_entry() + + self.assertIn("Exec=/tmp/Mouser/.venv/bin/python /tmp/Mouser/main_qml.py", entry) + self.assertIn("Name=Mouser", entry) + self.assertIn("X-GNOME-Autostart-enabled=true", entry) + + def test_linux_enable_writes_desktop_entry(self): + desktop_path = "/tmp/autostart/io.github.tombadash.mouser.desktop" + + with ( + patch.object(sys, "platform", "linux"), + patch.object(st, "supports_login_startup", return_value=True), + patch.object(st, "_linux_desktop_path", return_value=desktop_path), + patch.object(st, "_linux_desktop_entry", return_value="[Desktop Entry]\n"), + patch("os.makedirs") as m_makedirs, + patch("builtins.open", mock_open()) as m_open, + ): + st.apply_login_startup(True) + + m_makedirs.assert_called_once_with("/tmp/autostart", exist_ok=True) + m_open.assert_called_once_with(desktop_path, "w", encoding="utf-8") + + def test_linux_disable_removes_desktop_entry(self): + desktop_path = "/tmp/autostart/io.github.tombadash.mouser.desktop" + + with ( + patch.object(sys, "platform", "linux"), + patch.object(st, "supports_login_startup", return_value=True), + patch.object(st, "_linux_desktop_path", return_value=desktop_path), + patch("os.remove") as m_remove, + ): + st.apply_login_startup(False) + + m_remove.assert_called_once_with(desktop_path) + + def test_linux_disable_ignores_missing_desktop_entry(self): + with ( + patch.object(sys, "platform", "linux"), + patch.object(st, "supports_login_startup", return_value=True), + patch.object( + st, + "_linux_desktop_path", + return_value="/tmp/autostart/io.github.tombadash.mouser.desktop", + ), + patch("os.remove", side_effect=FileNotFoundError()), + ): + st.apply_login_startup(False) + + class SyncFromConfigTests(unittest.TestCase): def test_delegates_to_apply(self): with patch.object(st, "apply_login_startup") as mock_apply: diff --git a/ui/backend.py b/ui/backend.py index c8f04a6..5eecd72 100644 --- a/ui/backend.py +++ b/ui/backend.py @@ -117,7 +117,10 @@ def __init__(self, engine=None, parent=None): engine.set_debug_enabled(self.debugMode) self._mouse_connected = bool(getattr(engine, "device_connected", False)) if supports_login_startup(): - sync_login_startup_from_config(self.startAtLogin) + try: + sync_login_startup_from_config(self.startAtLogin) + except Exception as exc: + print(f"[Backend] Failed to sync login startup: {exc}") else: self._cfg.setdefault("settings", {})["start_at_login"] = False self._apply_device_layout( @@ -465,7 +468,7 @@ def setStartAtLogin(self, value): enabled = bool(value) if not supports_login_startup(): self.statusMessage.emit( - "Start at login is only available on Windows and macOS" + "Start at login is not available on this platform" ) return if self.startAtLogin == enabled: diff --git a/ui/qml/ScrollPage.qml b/ui/qml/ScrollPage.qml index 06f893c..e47a507 100644 --- a/ui/qml/ScrollPage.qml +++ b/ui/qml/ScrollPage.qml @@ -461,7 +461,7 @@ Item { } Text { - text: "Start Mouser at login on Windows and macOS, and choose whether the settings window opens on launch or Mouser stays in the system tray." + text: "Start Mouser at login and choose whether the settings window opens on launch or Mouser stays in the system tray." font { family: uiState.fontFamily pixelSize: 12