From ae90587dae9377cb0c65e65095c6f25a1f5fbf4e Mon Sep 17 00:00:00 2001 From: ravibrock <66334356+ravibrock@users.noreply.github.com> Date: Wed, 15 Apr 2026 10:31:05 -0500 Subject: [PATCH 01/17] Add initial cli --- main_cli.py | 147 ++++++++++++++++++++++++++++++++++++++++++++++ tests/test_cli.py | 120 +++++++++++++++++++++++++++++++++++++ 2 files changed, 267 insertions(+) create mode 100644 main_cli.py create mode 100644 tests/test_cli.py diff --git a/main_cli.py b/main_cli.py new file mode 100644 index 00000000..432013ba --- /dev/null +++ b/main_cli.py @@ -0,0 +1,147 @@ +""" +Simple command-line interface for exporting and loading Mouser configs. +""" + +from __future__ import annotations + +import argparse +import json +import signal +import sys +import threading +from pathlib import Path +from typing import Any + +import yaml + +from core.config import ( + DEFAULT_CONFIG, + _merge_defaults, + _migrate, + _validate_types, + load_config, + save_config, +) +from core.log_setup import setup_logging + + +def normalize_config(raw_cfg: Any) -> dict[str, Any]: + """Return a migrated, default-filled config dict.""" + if not isinstance(raw_cfg, dict): + raise ValueError("Config JSON must be an object") + cfg = json.loads(json.dumps(raw_cfg)) + cfg = _migrate(cfg) + cfg = _merge_defaults(cfg, DEFAULT_CONFIG) + cfg = _validate_types(cfg, DEFAULT_CONFIG) + return cfg + + +def export_config(*, stdout=None) -> int: + stdout = stdout or sys.stdout + json.dump(load_config(), stdout, indent=2) + stdout.write("\n") + return 0 + + +def _read_config_json(path: str, ft: str=None) -> dict[str, Any]: + if path == "-": + raw = sys.stdin + else: + with open(path, "r", encoding="utf-8") as f: + raw = f + + ft = ft or (Path(path).suffix.lower().lstrip(".") if path != "-" else "json") + + # load path into json + if ft == "json": + processed = json.load(raw) + elif ft == "yaml": + processed = yaml.safe_load(raw) + else: + raise ValueError(f"Unsupported config file type: {ft}") + + return normalize_config(processed) + + +def run_headless_instance(*, stop_event: threading.Event | None = None, engine_factory=None) -> int: + if engine_factory is None: + from core.engine import Engine + + engine_factory = Engine + + stop_event = stop_event or threading.Event() + engine = engine_factory() + + def _request_stop(_signum, _frame): + stop_event.set() + + previous_handlers: list[tuple[int, Any]] = [] + for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): + try: + previous_handlers.append((sig, signal.getsignal(sig))) + signal.signal(sig, _request_stop) + except (AttributeError, ValueError): + continue + + try: + engine.start() + while not stop_event.wait(0.5): + pass + return 0 + finally: + engine.stop() + for sig, previous in previous_handlers: + try: + signal.signal(sig, previous) + except (AttributeError, ValueError): + pass + + +def load_config_and_start(path: str, *, stop_event: threading.Event | None = None, engine_factory=None) -> int: + cfg = _read_config_json(path) + save_config(cfg) + return run_headless_instance(stop_event=stop_event, engine_factory=engine_factory) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Mouser command line interface") + subparsers = parser.add_subparsers(dest="command", required=True) + + subparsers.add_parser( + "export", + help="Print the current Mouser config as JSON", + ) + + load_parser = subparsers.add_parser( + "load", + help="Load a JSON config and start Mouser headlessly", + ) + + load_parser.add_argument( + "config", + help="Path to a Mouser config JSON file, or '-' to read from stdin", + ) + load_parser.add_argument( + "-t", + "--filetype", + choices=["json", "yaml"], + help="Override config file type detection", + ) + + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + if args.command == "export": + return export_config() + if args.command == "load": + setup_logging() + return load_config_and_start(args.config) + parser.error(f"Unknown command: {args.command}") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 00000000..19b14da1 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,120 @@ +import io +import json +import tempfile +import threading +import unittest +from pathlib import Path +from unittest.mock import patch + +import main_cli + + +class CliTests(unittest.TestCase): + def test_export_prints_current_config_as_json(self): + buf = io.StringIO() + cfg = {"version": 8, "profiles": {}, "settings": {}} + + with patch("main_cli.load_config", return_value=cfg): + rc = main_cli.export_config(stdout=buf) + + self.assertEqual(rc, 0) + self.assertEqual(json.loads(buf.getvalue()), cfg) + + def test_normalize_config_migrates_and_fills_defaults(self): + legacy = { + "version": 1, + "active_profile": "default", + "profiles": { + "default": { + "label": "Default", + "mappings": {"xbutton1": "browser_back"}, + } + }, + "settings": {}, + } + + normalized = main_cli.normalize_config(legacy) + + self.assertEqual(normalized["version"], 8) + self.assertEqual(normalized["profiles"]["default"]["apps"], []) + self.assertEqual( + normalized["profiles"]["default"]["mappings"]["mode_shift"], + "switch_scroll_mode", + ) + self.assertIn("language", normalized["settings"]) + + def test_load_persists_normalized_config_then_starts_engine(self): + raw = { + "version": 1, + "active_profile": "default", + "profiles": {"default": {"label": "Default", "mappings": {}}}, + "settings": {}, + } + + saved = {} + started = {} + + class _FakeEngine: + def start(self): + started["start"] = started.get("start", 0) + 1 + + def stop(self): + started["stop"] = started.get("stop", 0) + 1 + + with tempfile.TemporaryDirectory() as tmp_dir: + config_path = Path(tmp_dir) / "import.json" + config_path.write_text(json.dumps(raw), encoding="utf-8") + + stop_event = threading.Event() + stop_event.set() + + with patch("main_cli.save_config", side_effect=lambda cfg: saved.setdefault("cfg", cfg)): + rc = main_cli.load_config_and_start( + str(config_path), + stop_event=stop_event, + engine_factory=_FakeEngine, + ) + + self.assertEqual(rc, 0) + self.assertEqual(saved["cfg"]["version"], 8) + self.assertEqual( + saved["cfg"]["profiles"]["default"]["mappings"]["mode_shift"], + "switch_scroll_mode", + ) + self.assertEqual(started, {"start": 1, "stop": 1}) + + def test_load_accepts_stdin_marker(self): + raw = { + "version": 8, + "active_profile": "default", + "profiles": {"default": {"label": "Default", "apps": [], "mappings": {}}}, + "settings": {}, + } + saved = {} + + class _FakeEngine: + def start(self): + pass + + def stop(self): + pass + + stop_event = threading.Event() + stop_event.set() + + with ( + patch("sys.stdin", io.StringIO(json.dumps(raw))), + patch("main_cli.save_config", side_effect=lambda cfg: saved.setdefault("cfg", cfg)), + ): + rc = main_cli.load_config_and_start( + "-", + stop_event=stop_event, + engine_factory=_FakeEngine, + ) + + self.assertEqual(rc, 0) + self.assertEqual(saved["cfg"]["version"], 8) + + +if __name__ == "__main__": + unittest.main() From 152af098dbf5381d9efcbacca6d583833378b01e Mon Sep 17 00:00:00 2001 From: ravibrock <66334356+ravibrock@users.noreply.github.com> Date: Thu, 16 Apr 2026 16:56:07 -0500 Subject: [PATCH 02/17] Get CLI working --- main_cli.py | 126 ++++++++++++++++++++++++++++++++++++++++++--- tests/test_cli.py | 128 ++++++++++++++++++++++++++++++++++++---------- 2 files changed, 218 insertions(+), 36 deletions(-) diff --git a/main_cli.py b/main_cli.py index 432013ba..a6bd8f46 100644 --- a/main_cli.py +++ b/main_cli.py @@ -6,12 +6,16 @@ import argparse import json +import os +import plistlib import signal +import subprocess import sys import threading from pathlib import Path from typing import Any +import Quartz import yaml from core.config import ( @@ -24,6 +28,9 @@ ) from core.log_setup import setup_logging +CLI_SERVICE_LABEL = "io.github.tombadash.mouser.headless" +CLI_SERVICE_PLIST_NAME = f"{CLI_SERVICE_LABEL}.plist" + def normalize_config(raw_cfg: Any) -> dict[str, Any]: """Return a migrated, default-filled config dict.""" @@ -85,8 +92,8 @@ def _request_stop(_signum, _frame): try: engine.start() - while not stop_event.wait(0.5): - pass + while not stop_event.is_set(): + _wait_for_headless_activity(stop_event, timeout_s=0.5) return 0 finally: engine.stop() @@ -97,10 +104,89 @@ def _request_stop(_signum, _frame): pass -def load_config_and_start(path: str, *, stop_event: threading.Event | None = None, engine_factory=None) -> int: - cfg = _read_config_json(path) +def _wait_for_headless_activity( + stop_event: threading.Event, + *, + timeout_s: float, +) -> None: + """Keep the process responsive while the headless engine is running. + + On macOS, the mouse hook installs a CGEventTap onto the current CFRunLoop. + The Qt app naturally pumps that run loop, but the CLI path does not, so the + tap would otherwise remain idle even when Accessibility permission is + granted. + """ + remaining = max(float(timeout_s), 0.0) + slice_s = 0.1 + while remaining > 0 and not stop_event.is_set(): + step = min(slice_s, remaining) + Quartz.CFRunLoopRunInMode(Quartz.kCFRunLoopDefaultMode, step, False) + remaining -= step + + +def load_config_and_start( + path: str, + *, + filetype: str | None = None, +) -> int: + cfg = _read_config_json(path, ft=filetype) save_config(cfg) - return run_headless_instance(stop_event=stop_event, engine_factory=engine_factory) + return start_background_service() + + +def _service_program_arguments() -> list[str]: + exe = os.path.abspath(sys.executable) + if getattr(sys, "frozen", False): + return [exe, "_run"] + return [exe, os.path.abspath(__file__), "_run"] + + +def _service_plist_path() -> str: + return os.path.expanduser(os.path.join("~/Library/LaunchAgents", CLI_SERVICE_PLIST_NAME)) + + +def _launchctl_run(args: list[str]) -> subprocess.CompletedProcess: + return subprocess.run(args, capture_output=True, text=True) + + +def start_background_service() -> int: + plist_path = _service_plist_path() + launch_agents_dir = os.path.dirname(plist_path) + domain = f"gui/{os.getuid()}" + + os.makedirs(launch_agents_dir, exist_ok=True) + if os.path.isfile(plist_path): + _launchctl_run(["launchctl", "bootout", domain, plist_path]) + + payload = { + "Label": CLI_SERVICE_LABEL, + "ProgramArguments": _service_program_arguments(), + "RunAtLoad": True, + "KeepAlive": True, + "ProcessType": "Background", + } + with open(plist_path, "wb") as f: + plistlib.dump(payload, f, fmt=plistlib.FMT_XML) + + result = _launchctl_run(["launchctl", "bootstrap", domain, plist_path]) + if result.returncode != 0: + raise RuntimeError(f"launchctl bootstrap failed: {result.stderr.strip()}") + return 0 + + +def stop_background_service() -> int: + plist_path = _service_plist_path() + domain = f"gui/{os.getuid()}" + + if os.path.isfile(plist_path): + _launchctl_run(["launchctl", "bootout", domain, plist_path]) + try: + os.remove(plist_path) + except OSError: + pass + else: + _launchctl_run(["launchctl", "bootout", domain, CLI_SERVICE_LABEL]) + return 0 def build_parser() -> argparse.ArgumentParser: @@ -114,7 +200,7 @@ def build_parser() -> argparse.ArgumentParser: load_parser = subparsers.add_parser( "load", - help="Load a JSON config and start Mouser headlessly", + help="Load a config and delegate startup to the background service", ) load_parser.add_argument( @@ -128,18 +214,42 @@ def build_parser() -> argparse.ArgumentParser: help="Override config file type detection", ) + subparsers.add_parser( + "start", + help="Start Mouser headlessly in the background", + ) + + subparsers.add_parser( + "stop", + help="Stop the background Mouser headless service", + ) + + subparsers.add_parser( + "_run", + help=argparse.SUPPRESS, + ) + return parser def main(argv: list[str] | None = None) -> int: + if sys.platform != "darwin": + raise NotImplementedError("stop is currently only supported on macOS") + parser = build_parser() args = parser.parse_args(argv) if args.command == "export": return export_config() + setup_logging() if args.command == "load": - setup_logging() - return load_config_and_start(args.config) + return load_config_and_start(args.config, filetype=args.filetype) + if args.command == "start": + return start_background_service() + if args.command == "stop": + return stop_background_service() + if args.command == "_run": + return run_headless_instance() parser.error(f"Unknown command: {args.command}") diff --git a/tests/test_cli.py b/tests/test_cli.py index 19b14da1..1769098a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,7 +1,7 @@ import io import json +import plistlib import tempfile -import threading import unittest from pathlib import Path from unittest.mock import patch @@ -10,6 +10,26 @@ class CliTests(unittest.TestCase): + def test_wait_for_headless_activity_pumps_macos_run_loop(self): + stop_event = main_cli.threading.Event() + calls = [] + + class _QuartzStub: + kCFRunLoopDefaultMode = "default" + + @staticmethod + def CFRunLoopRunInMode(mode, seconds, return_after_source_handled): + calls.append((mode, seconds, return_after_source_handled)) + stop_event.set() + + with ( + patch("main_cli.sys.platform", "darwin"), + patch.object(main_cli, "Quartz", _QuartzStub), + ): + main_cli._wait_for_headless_activity(stop_event, timeout_s=0.5) + + self.assertEqual(calls, [("default", 0.1, False)]) + def test_export_prints_current_config_as_json(self): buf = io.StringIO() cfg = {"version": 8, "profiles": {}, "settings": {}} @@ -52,27 +72,17 @@ def test_load_persists_normalized_config_then_starts_engine(self): } saved = {} - started = {} - - class _FakeEngine: - def start(self): - started["start"] = started.get("start", 0) + 1 - - def stop(self): - started["stop"] = started.get("stop", 0) + 1 with tempfile.TemporaryDirectory() as tmp_dir: config_path = Path(tmp_dir) / "import.json" config_path.write_text(json.dumps(raw), encoding="utf-8") - stop_event = threading.Event() - stop_event.set() - - with patch("main_cli.save_config", side_effect=lambda cfg: saved.setdefault("cfg", cfg)): + with ( + patch("main_cli.save_config", side_effect=lambda cfg: saved.setdefault("cfg", cfg)), + patch("main_cli.start_background_service", return_value=0) as start_background_service, + ): rc = main_cli.load_config_and_start( str(config_path), - stop_event=stop_event, - engine_factory=_FakeEngine, ) self.assertEqual(rc, 0) @@ -81,7 +91,7 @@ def stop(self): saved["cfg"]["profiles"]["default"]["mappings"]["mode_shift"], "switch_scroll_mode", ) - self.assertEqual(started, {"start": 1, "stop": 1}) + start_background_service.assert_called_once_with() def test_load_accepts_stdin_marker(self): raw = { @@ -92,28 +102,90 @@ def test_load_accepts_stdin_marker(self): } saved = {} - class _FakeEngine: - def start(self): - pass - - def stop(self): - pass - - stop_event = threading.Event() - stop_event.set() - with ( patch("sys.stdin", io.StringIO(json.dumps(raw))), patch("main_cli.save_config", side_effect=lambda cfg: saved.setdefault("cfg", cfg)), + patch("main_cli.start_background_service", return_value=0) as start_background_service, ): rc = main_cli.load_config_and_start( "-", - stop_event=stop_event, - engine_factory=_FakeEngine, ) self.assertEqual(rc, 0) self.assertEqual(saved["cfg"]["version"], 8) + start_background_service.assert_called_once_with() + + def test_start_writes_launch_agent_and_bootstraps_it(self): + with tempfile.TemporaryDirectory() as tmp_dir: + plist_path = Path(tmp_dir) / main_cli.CLI_SERVICE_PLIST_NAME + launchctl_calls = [] + + def _fake_launchctl(args): + launchctl_calls.append(args) + return type("Result", (), {"returncode": 0, "stderr": ""})() + + with ( + patch("sys.platform", "darwin"), + patch("main_cli._service_plist_path", return_value=str(plist_path)), + patch("main_cli._launchctl_run", side_effect=_fake_launchctl), + ): + rc = main_cli.start_background_service() + + self.assertEqual(rc, 0) + self.assertEqual( + launchctl_calls, + [["launchctl", "bootstrap", f"gui/{main_cli.os.getuid()}", str(plist_path)]], + ) + payload = plistlib.loads(plist_path.read_bytes()) + self.assertEqual(payload["Label"], main_cli.CLI_SERVICE_LABEL) + self.assertEqual(payload["ProgramArguments"], main_cli._service_program_arguments()) + self.assertTrue(payload["RunAtLoad"]) + self.assertTrue(payload["KeepAlive"]) + + def test_stop_boots_out_and_removes_launch_agent(self): + with tempfile.TemporaryDirectory() as tmp_dir: + plist_path = Path(tmp_dir) / main_cli.CLI_SERVICE_PLIST_NAME + plist_path.write_text("placeholder", encoding="utf-8") + launchctl_calls = [] + + def _fake_launchctl(args): + launchctl_calls.append(args) + return type("Result", (), {"returncode": 0, "stderr": ""})() + + with ( + patch("sys.platform", "darwin"), + patch("main_cli._service_plist_path", return_value=str(plist_path)), + patch("main_cli._launchctl_run", side_effect=_fake_launchctl), + ): + rc = main_cli.stop_background_service() + + self.assertEqual(rc, 0) + self.assertEqual( + launchctl_calls, + [["launchctl", "bootout", f"gui/{main_cli.os.getuid()}", str(plist_path)]], + ) + self.assertFalse(plist_path.exists()) + + def test_main_start_and_stop_dispatch_to_service_helpers(self): + with ( + patch("main_cli.setup_logging"), + patch("main_cli.start_background_service", return_value=0) as start_background_service, + patch("main_cli.stop_background_service", return_value=0) as stop_background_service, + ): + self.assertEqual(main_cli.main(["start"]), 0) + self.assertEqual(main_cli.main(["stop"]), 0) + + start_background_service.assert_called_once_with() + stop_background_service.assert_called_once_with() + + def test_main_internal_run_dispatches_to_headless_runner(self): + with ( + patch("main_cli.setup_logging"), + patch("main_cli.run_headless_instance", return_value=0) as run_headless_instance, + ): + self.assertEqual(main_cli.main(["_run"]), 0) + + run_headless_instance.assert_called_once_with() if __name__ == "__main__": From f5cb3f113f945143fc5bba6a44aef1989704779a Mon Sep 17 00:00:00 2001 From: ravibrock <66334356+ravibrock@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:11:38 -0500 Subject: [PATCH 03/17] update load --- main_cli.py | 48 ++++++++++++++++++++++++++++++++++++------------ 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/main_cli.py b/main_cli.py index a6bd8f46..4b65df9e 100644 --- a/main_cli.py +++ b/main_cli.py @@ -51,21 +51,22 @@ def export_config(*, stdout=None) -> int: def _read_config_json(path: str, ft: str=None) -> dict[str, Any]: + ft = ft or (Path(path).suffix.lower().lstrip(".") if path != "-" else "json") + if ft not in ("json", "yaml"): + raise ValueError(f"Unsupported config file type: {ft}") + if path == "-": raw = sys.stdin + if ft == "json": + processed = json.load(raw) + elif ft == "yaml": + processed = yaml.safe_load(raw) else: - with open(path, "r", encoding="utf-8") as f: - raw = f - - ft = ft or (Path(path).suffix.lower().lstrip(".") if path != "-" else "json") - - # load path into json - if ft == "json": - processed = json.load(raw) - elif ft == "yaml": - processed = yaml.safe_load(raw) - else: - raise ValueError(f"Unsupported config file type: {ft}") + with open(path, "r", encoding="utf-8") as raw: + if ft == "json": + processed = json.load(raw) + elif ft == "yaml": + processed = yaml.safe_load(raw) return normalize_config(processed) @@ -130,6 +131,7 @@ def load_config_and_start( filetype: str | None = None, ) -> int: cfg = _read_config_json(path, ft=filetype) + cfg = assemble_full_config(cfg) save_config(cfg) return start_background_service() @@ -189,6 +191,28 @@ def stop_background_service() -> int: return 0 +def assemble_full_config(config: dict[str, Any]): + print(json.dumps(config, indent=4)) + try: + active_profile = config["active_profile"] + if active_profile not in config["profiles"]: + raise ValueError(f"Active profile '{active_profile}' not found in profiles") + if config["profiles"][active_profile]["apps"] != []: + raise ValueError("Active profile must have an empty `apps` list") + except KeyError: + raise ValueError("Config must specify an `active_profile`") + + default_mappings = config["profiles"][active_profile]["mappings"] + for profile_name, profile in config["profiles"].items(): + if profile_name == active_profile: + continue + for mapping in default_mappings: + if mapping not in profile["mappings"]: + profile["mappings"][mapping] = default_mappings[mapping] + + return config + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Mouser command line interface") subparsers = parser.add_subparsers(dest="command", required=True) From 8b48a6c4e8d7177fd32c783859362fa5139979c8 Mon Sep 17 00:00:00 2001 From: ravibrock <66334356+ravibrock@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:12:37 -0500 Subject: [PATCH 04/17] remove print --- main_cli.py | 1 - 1 file changed, 1 deletion(-) diff --git a/main_cli.py b/main_cli.py index 4b65df9e..a654f382 100644 --- a/main_cli.py +++ b/main_cli.py @@ -192,7 +192,6 @@ def stop_background_service() -> int: def assemble_full_config(config: dict[str, Any]): - print(json.dumps(config, indent=4)) try: active_profile = config["active_profile"] if active_profile not in config["profiles"]: From e2be00cf8fa1dbd97cc41f338ee494b8e8b0acf9 Mon Sep 17 00:00:00 2001 From: ravibrock <66334356+ravibrock@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:10:54 -0500 Subject: [PATCH 05/17] Fix hscroll sensitivity --- core/config.py | 1 + core/engine.py | 12 +++++++++- core/key_simulator.py | 48 +++++++++++++++++++++++++++---------- tests/test_engine.py | 29 ++++++++++++++++++++-- tests/test_key_simulator.py | 20 ++++++++++++++++ 5 files changed, 94 insertions(+), 16 deletions(-) diff --git a/core/config.py b/core/config.py index 6b3ca47e..e5b73cf2 100644 --- a/core/config.py +++ b/core/config.py @@ -92,6 +92,7 @@ "start_minimized": True, "start_at_login": False, "hscroll_threshold": 1, + "hscroll_cooldown_ms": 350, "invert_hscroll": False, # swap horizontal scroll directions "invert_vscroll": False, # swap vertical scroll directions "dpi": 1000, # pointer speed / DPI setting diff --git a/core/engine.py b/core/engine.py index 0f13b7f8..8a4551f7 100644 --- a/core/engine.py +++ b/core/engine.py @@ -326,7 +326,11 @@ def handler(event): threshold = self._hscroll_threshold() now = getattr(event, "timestamp", None) or time.time() - cooldown = HSCROLL_VOLUME_COOLDOWN_S if action_id in _VOLUME_ACTIONS else HSCROLL_ACTION_COOLDOWN_S + cooldown = ( + HSCROLL_VOLUME_COOLDOWN_S + if action_id in _VOLUME_ACTIONS + else self._hscroll_action_cooldown() + ) if now - state["last_fire_at"] < cooldown: state["accum"] = 0.0 return @@ -358,6 +362,12 @@ def _hscroll_threshold(self): float(self.cfg.get("settings", {}).get("hscroll_threshold", 1)), ) + def _hscroll_action_cooldown(self): + return max( + 0.0, + float(self.cfg.get("settings", {}).get("hscroll_cooldown_ms", 350)) / 1000.0, + ) + # ------------------------------------------------------------------ # Per-app auto-switching # ------------------------------------------------------------------ diff --git a/core/key_simulator.py b/core/key_simulator.py index 7a11fd53..838469b7 100644 --- a/core/key_simulator.py +++ b/core/key_simulator.py @@ -786,28 +786,50 @@ def is_mouse_button_action(action_id): kVK_Control: Quartz.kCGEventFlagMaskControl if _QUARTZ_OK else 0, } + def _split_modifier_keys(keys): + modifiers = [] + normals = [] + for key in keys: + if key in _MOD_FLAGS: + modifiers.append(key) + else: + normals.append(key) + return modifiers, normals + def send_key_combo(keys, hold_ms=50): """Press and release a combination of CGKeyCodes.""" if not _QUARTZ_OK: return - # Compute modifier flags - flags = 0 - for k in keys: - flags |= _MOD_FLAGS.get(k, 0) - - # Press all - for k in keys: - ev = Quartz.CGEventCreateKeyboardEvent(None, k, True) - if flags: - Quartz.CGEventSetFlags(ev, flags) + modifiers, normals = _split_modifier_keys(keys) + active_flags = 0 + + for key in modifiers: + active_flags |= _MOD_FLAGS[key] + ev = Quartz.CGEventCreateKeyboardEvent(None, key, True) + if active_flags: + Quartz.CGEventSetFlags(ev, active_flags) + Quartz.CGEventPost(Quartz.kCGHIDEventTap, ev) + + for key in normals: + ev = Quartz.CGEventCreateKeyboardEvent(None, key, True) + if active_flags: + Quartz.CGEventSetFlags(ev, active_flags) Quartz.CGEventPost(Quartz.kCGHIDEventTap, ev) if hold_ms: time.sleep(hold_ms / 1000.0) - # Release in reverse - for k in reversed(keys): - ev = Quartz.CGEventCreateKeyboardEvent(None, k, False) + for key in reversed(normals): + ev = Quartz.CGEventCreateKeyboardEvent(None, key, False) + if active_flags: + Quartz.CGEventSetFlags(ev, active_flags) + Quartz.CGEventPost(Quartz.kCGHIDEventTap, ev) + + for key in reversed(modifiers): + active_flags &= ~_MOD_FLAGS[key] + ev = Quartz.CGEventCreateKeyboardEvent(None, key, False) + if active_flags: + Quartz.CGEventSetFlags(ev, active_flags) Quartz.CGEventPost(Quartz.kCGHIDEventTap, ev) def send_key_press(vk): diff --git a/tests/test_engine.py b/tests/test_engine.py index 6bfb42d3..227042e9 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -89,11 +89,13 @@ def run_target(self): class EngineHorizontalScrollTests(unittest.TestCase): - def _make_engine(self): + def _make_engine(self, *, hscroll_threshold=1, hscroll_cooldown_ms=None): from core.engine import Engine cfg = copy.deepcopy(DEFAULT_CONFIG) - cfg["settings"]["hscroll_threshold"] = 1 + cfg["settings"]["hscroll_threshold"] = hscroll_threshold + if hscroll_cooldown_ms is not None: + cfg["settings"]["hscroll_cooldown_ms"] = hscroll_cooldown_ms with ( patch("core.engine.MouseHook", _FakeMouseHook), @@ -148,6 +150,29 @@ def test_hscroll_accumulates_fractional_mac_deltas(self): self.assertEqual(execute_action_mock.call_count, 1) + def test_hscroll_uses_configured_cooldown(self): + engine = self._make_engine(hscroll_cooldown_ms=100) + handler = engine._make_hscroll_handler("space_left") + + with patch("core.engine.execute_action") as execute_action_mock: + handler(SimpleNamespace( + event_type=MouseEvent.HSCROLL_LEFT, + raw_data=1, + timestamp=1.00, + )) + handler(SimpleNamespace( + event_type=MouseEvent.HSCROLL_LEFT, + raw_data=1, + timestamp=1.05, + )) + handler(SimpleNamespace( + event_type=MouseEvent.HSCROLL_LEFT, + raw_data=1, + timestamp=1.15, + )) + + self.assertEqual(execute_action_mock.call_count, 2) + def test_connection_callback_receives_current_state_immediately(self): engine = self._make_engine() engine.hook.device_connected = True diff --git a/tests/test_key_simulator.py b/tests/test_key_simulator.py index fd5d89f1..679b59ae 100644 --- a/tests/test_key_simulator.py +++ b/tests/test_key_simulator.py @@ -196,5 +196,25 @@ def test_mouse_button_labels_are_non_empty_strings(self): self.assertTrue(len(label) > 0) +@unittest.skipUnless(sys.platform == "darwin", "macOS key ordering is platform-specific") +class MacKeyComboTests(unittest.TestCase): + def test_split_modifier_keys_preserves_modifier_first_order(self): + modifiers, normals = key_simulator._split_modifier_keys([ + key_simulator.kVK_Command, + key_simulator.kVK_ANSI_C, + key_simulator.kVK_Shift, + key_simulator.kVK_ANSI_W, + ]) + + self.assertEqual( + modifiers, + [key_simulator.kVK_Command, key_simulator.kVK_Shift], + ) + self.assertEqual( + normals, + [key_simulator.kVK_ANSI_C, key_simulator.kVK_ANSI_W], + ) + + if __name__ == "__main__": unittest.main() From e04437f541f2e907966181c5d1e470328c1729b9 Mon Sep 17 00:00:00 2001 From: ravibrock <66334356+ravibrock@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:38:18 -0500 Subject: [PATCH 06/17] Fix/improve mouse behavior --- core/mouse_hook.py | 148 ++++++++++++++++++++++++++++----------- tests/test_mouse_hook.py | 66 +++++++++++++++++ 2 files changed, 175 insertions(+), 39 deletions(-) diff --git a/core/mouse_hook.py b/core/mouse_hook.py index f766e99a..b42aa5aa 100644 --- a/core/mouse_hook.py +++ b/core/mouse_hook.py @@ -270,6 +270,7 @@ def __init__(self): self._gesture_delta_x = 0.0 self._gesture_delta_y = 0.0 self._gesture_cooldown_until = 0.0 + self._gesture_last_swipe_event = None self._gesture_input_source = None self._connected_device = None self._dispatch_queue = queue.Queue() @@ -415,24 +416,21 @@ def _detect_gesture_event(self): abs_x = abs(delta_x) abs_y = abs(delta_y) - dominant = max(abs_x, abs_y) - if dominant < self._gesture_threshold: - return None + threshold = self._gesture_threshold + deadzone = self._gesture_deadzone + axis_margin = max(5.0, threshold * 0.2) - cross_limit = max(self._gesture_deadzone, dominant * 0.35) - - if abs_x > abs_y: - if abs_y > cross_limit: - return None + if abs_x >= threshold and abs_y <= deadzone and abs_x >= abs_y + axis_margin: if delta_x > 0: return MouseEvent.GESTURE_SWIPE_RIGHT return MouseEvent.GESTURE_SWIPE_LEFT - if abs_x > cross_limit: - return None - if delta_y > 0: - return MouseEvent.GESTURE_SWIPE_DOWN - return MouseEvent.GESTURE_SWIPE_UP + if abs_y >= threshold and abs_x <= deadzone and abs_y >= abs_x + axis_margin: + if delta_y > 0: + return MouseEvent.GESTURE_SWIPE_DOWN + return MouseEvent.GESTURE_SWIPE_UP + + return None def _accumulate_gesture_delta(self, delta_x, delta_y, source): if not (self._gesture_direction_enabled and self._gesture_active): @@ -492,7 +490,30 @@ def _accumulate_gesture_delta(self, delta_x, delta_y, source): if not gesture_event: return + if ( + self._gesture_last_swipe_event is not None + and gesture_event != self._gesture_last_swipe_event + ): + self._emit_debug( + "Gesture rebound suppressed " + f"{gesture_event} after {self._gesture_last_swipe_event}" + ) + self._emit_gesture_event({ + "type": "rebound_suppressed", + "event_name": gesture_event, + "previous_event_name": self._gesture_last_swipe_event, + "source": source, + "dx": self._gesture_delta_x, + "dy": self._gesture_delta_y, + }) + self._gesture_cooldown_until = ( + time.monotonic() + self._gesture_cooldown_ms / 1000.0 + ) + self._finish_gesture_tracking() + return + self._gesture_triggered = True + self._gesture_last_swipe_event = gesture_event self._emit_debug( "Gesture detected " f"{gesture_event} source={source} " @@ -729,10 +750,12 @@ def _check_raw_mouse_gesture(self, hDevice, buf): if not self._gesture_active: self._gesture_active = True self._gesture_triggered = False + self._gesture_last_swipe_event = None print(f"[MouseHook] Gesture DOWN (rawBtns extra: 0x{extra_now:X})") elif not extra_now and extra_prev: if self._gesture_active: self._gesture_active = False + self._gesture_last_swipe_event = None print("[MouseHook] Gesture UP") self._dispatch(MouseEvent(MouseEvent.GESTURE_CLICK)) @@ -848,6 +871,7 @@ def _on_hid_gesture_down(self): if not self._gesture_active: self._gesture_active = True self._gesture_triggered = False + self._gesture_last_swipe_event = None self._emit_debug("HID gesture button down") self._emit_gesture_event({"type": "button_down"}) if self._gesture_direction_enabled and not self._gesture_cooldown_active(): @@ -862,6 +886,7 @@ def _on_hid_gesture_up(self): self._gesture_active = False self._finish_gesture_tracking() self._gesture_triggered = False + self._gesture_last_swipe_event = None self._emit_debug( f"HID gesture button up click_candidate={str(should_click).lower()}" ) @@ -1054,6 +1079,7 @@ def __init__(self): self._gesture_delta_x = 0.0 self._gesture_delta_y = 0.0 self._gesture_cooldown_until = 0.0 + self._gesture_last_swipe_event = None self._gesture_input_source = None self._connected_device = None @@ -1245,24 +1271,21 @@ def _detect_gesture_event(self): abs_x = abs(delta_x) abs_y = abs(delta_y) - dominant = max(abs_x, abs_y) - if dominant < self._gesture_threshold: - return None - - cross_limit = max(self._gesture_deadzone, dominant * 0.35) + threshold = self._gesture_threshold + deadzone = self._gesture_deadzone + axis_margin = max(5.0, threshold * 0.2) - if abs_x > abs_y: - if abs_y > cross_limit: - return None + if abs_x >= threshold and abs_y <= deadzone and abs_x >= abs_y + axis_margin: if delta_x > 0: return MouseEvent.GESTURE_SWIPE_RIGHT return MouseEvent.GESTURE_SWIPE_LEFT - if abs_x > cross_limit: - return None - if delta_y > 0: - return MouseEvent.GESTURE_SWIPE_DOWN - return MouseEvent.GESTURE_SWIPE_UP + if abs_y >= threshold and abs_x <= deadzone and abs_y >= abs_x + axis_margin: + if delta_y > 0: + return MouseEvent.GESTURE_SWIPE_DOWN + return MouseEvent.GESTURE_SWIPE_UP + + return None def _accumulate_gesture_delta(self, delta_x, delta_y, source): if not (self._gesture_direction_enabled and self._gesture_active): @@ -1335,7 +1358,30 @@ def _accumulate_gesture_delta(self, delta_x, delta_y, source): if not gesture_event: return + if ( + self._gesture_last_swipe_event is not None + and gesture_event != self._gesture_last_swipe_event + ): + self._emit_debug( + "Gesture rebound suppressed " + f"{gesture_event} after {self._gesture_last_swipe_event}" + ) + self._emit_gesture_event({ + "type": "rebound_suppressed", + "event_name": gesture_event, + "previous_event_name": self._gesture_last_swipe_event, + "source": source, + "dx": self._gesture_delta_x, + "dy": self._gesture_delta_y, + }) + self._gesture_cooldown_until = ( + time.monotonic() + self._gesture_cooldown_ms / 1000.0 + ) + self._finish_gesture_tracking() + return + self._gesture_triggered = True + self._gesture_last_swipe_event = gesture_event self._emit_debug( "Gesture detected " f"{gesture_event} source={source} " @@ -1535,6 +1581,7 @@ def _on_hid_gesture_down(self): if not self._gesture_active: self._gesture_active = True self._gesture_triggered = False + self._gesture_last_swipe_event = None self._emit_debug("HID gesture button down") self._emit_gesture_event({"type": "button_down"}) if self._gesture_direction_enabled and not self._gesture_cooldown_active(): @@ -1549,6 +1596,7 @@ def _on_hid_gesture_up(self): self._gesture_active = False self._finish_gesture_tracking() self._gesture_triggered = False + self._gesture_last_swipe_event = None self._emit_debug( f"HID gesture button up click_candidate={str(should_click).lower()}" ) @@ -1803,6 +1851,7 @@ def __init__(self): self._gesture_delta_x = 0.0 self._gesture_delta_y = 0.0 self._gesture_cooldown_until = 0.0 + self._gesture_last_swipe_event = None self._gesture_input_source = None self._gesture_lock = threading.Lock() # Linux-specific @@ -1988,24 +2037,21 @@ def _detect_gesture_event(self): abs_x = abs(delta_x) abs_y = abs(delta_y) - dominant = max(abs_x, abs_y) - if dominant < self._gesture_threshold: - return None - - cross_limit = max(self._gesture_deadzone, dominant * 0.35) + threshold = self._gesture_threshold + deadzone = self._gesture_deadzone + axis_margin = max(5.0, threshold * 0.2) - if abs_x > abs_y: - if abs_y > cross_limit: - return None + if abs_x >= threshold and abs_y <= deadzone and abs_x >= abs_y + axis_margin: if delta_x > 0: return MouseEvent.GESTURE_SWIPE_RIGHT return MouseEvent.GESTURE_SWIPE_LEFT - if abs_x > cross_limit: - return None - if delta_y > 0: - return MouseEvent.GESTURE_SWIPE_DOWN - return MouseEvent.GESTURE_SWIPE_UP + if abs_y >= threshold and abs_x <= deadzone and abs_y >= abs_x + axis_margin: + if delta_y > 0: + return MouseEvent.GESTURE_SWIPE_DOWN + return MouseEvent.GESTURE_SWIPE_UP + + return None def _accumulate_gesture_delta(self, delta_x, delta_y, source): dispatch_event = None @@ -2076,7 +2122,29 @@ def _accumulate_gesture_delta(self, delta_x, delta_y, source): if not gesture_event: return + if ( + self._gesture_last_swipe_event is not None + and gesture_event != self._gesture_last_swipe_event + ): + self._emit_debug( + "Gesture rebound suppressed " + f"{gesture_event} after {self._gesture_last_swipe_event}" + ) + self._emit_gesture_event({ + "type": "rebound_suppressed", + "event_name": gesture_event, + "previous_event_name": self._gesture_last_swipe_event, + "source": source, + "dx": self._gesture_delta_x, + "dy": self._gesture_delta_y, + }) + self._gesture_cooldown_until = ( + time.monotonic() + self._gesture_cooldown_ms / 1000.0 + ) + self._finish_gesture_tracking() + return self._gesture_triggered = True + self._gesture_last_swipe_event = gesture_event self._emit_debug( "Gesture detected " f"{gesture_event} source={source} " @@ -2122,6 +2190,7 @@ def _on_hid_gesture_down(self): if not self._gesture_active: self._gesture_active = True self._gesture_triggered = False + self._gesture_last_swipe_event = None self._emit_debug("HID gesture button down") self._emit_gesture_event({"type": "button_down"}) if self._gesture_direction_enabled and not self._gesture_cooldown_active(): @@ -2138,6 +2207,7 @@ def _on_hid_gesture_up(self): self._gesture_active = False self._finish_gesture_tracking() self._gesture_triggered = False + self._gesture_last_swipe_event = None self._emit_debug( f"HID gesture button up click_candidate={str(should_click).lower()}" ) diff --git a/tests/test_mouse_hook.py b/tests/test_mouse_hook.py index 96a7ce22..d31f9790 100644 --- a/tests/test_mouse_hook.py +++ b/tests/test_mouse_hook.py @@ -259,6 +259,72 @@ def fake_select(readable, writable, exceptional, timeout): self.assertEqual(select_calls, [0.5]) self.assertEqual(hook._evdev_device.read.call_count, 1) + +class LinuxGestureDetectionTests(unittest.TestCase): + def _reload_for_linux(self): + fake_evdev = SimpleNamespace( + ecodes=_FakeLinuxEcodes, + UInput=_FakeLinuxUInput, + InputDevice=Mock(name="InputDevice"), + ) + with ( + patch.object(sys, "platform", "linux"), + patch.dict(sys.modules, {"evdev": fake_evdev}), + ): + importlib.reload(mouse_hook) + self.addCleanup(importlib.reload, mouse_hook) + return mouse_hook + + def _make_hook(self): + module = self._reload_for_linux() + hook = module.MouseHook() + hook.configure_gestures(enabled=True, threshold=50, deadzone=40) + return module, hook + + def test_horizontal_swipe_with_vertical_noise_resolves_horizontal(self): + module, hook = self._make_hook() + hook._gesture_delta_x = -72 + hook._gesture_delta_y = -20 + + self.assertEqual( + hook._detect_gesture_event(), + module.MouseEvent.GESTURE_SWIPE_LEFT, + ) + + def test_equal_diagonal_stays_ambiguous_instead_of_becoming_vertical(self): + module, hook = self._make_hook() + hook._gesture_delta_x = -60 + hook._gesture_delta_y = -60 + + self.assertIsNone(hook._detect_gesture_event()) + + def test_repeated_left_swipes_ignore_rebound_right_within_same_hold(self): + module, hook = self._make_hook() + hook._gesture_cooldown_ms = 0 + seen = [] + hook.register( + module.MouseEvent.GESTURE_SWIPE_LEFT, + lambda event: seen.append(event.event_type), + ) + hook.register( + module.MouseEvent.GESTURE_SWIPE_RIGHT, + lambda event: seen.append(event.event_type), + ) + + hook._on_hid_gesture_down() + hook._accumulate_gesture_delta(-70, 0, "hid_rawxy") + hook._accumulate_gesture_delta(70, 0, "hid_rawxy") + hook._accumulate_gesture_delta(-70, 0, "hid_rawxy") + hook._on_hid_gesture_up() + + self.assertEqual( + seen, + [ + module.MouseEvent.GESTURE_SWIPE_LEFT, + module.MouseEvent.GESTURE_SWIPE_LEFT, + ], + ) + def test_evdev_loop_clears_rescan_and_retries_after_listen_returns(self): module = self._reload_for_linux() hook = module.MouseHook() From f788c9f3aaec75a1e6aeb7e1b554cbd0aaa8f067 Mon Sep 17 00:00:00 2001 From: ravibrock <66334356+ravibrock@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:13:19 -0500 Subject: [PATCH 07/17] Add validation --- core/config_validation.py | 279 ++++++++++++++++++++++++++++++++ main_cli.py | 90 +++++------ tests/test_cli.py | 127 ++++++++++++++- tests/test_config_validation.py | 139 ++++++++++++++++ 4 files changed, 580 insertions(+), 55 deletions(-) create mode 100644 core/config_validation.py create mode 100644 tests/test_config_validation.py diff --git a/core/config_validation.py b/core/config_validation.py new file mode 100644 index 00000000..542a8103 --- /dev/null +++ b/core/config_validation.py @@ -0,0 +1,279 @@ +""" +Schema-first config validation helpers shared by config import paths. +""" + +from __future__ import annotations + +import json +from functools import lru_cache +from typing import Any + +from jsonschema import Draft202012Validator +from core.config import ( + DEFAULT_CONFIG, + PROFILE_BUTTON_NAMES, + _merge_defaults, + _migrate, + _validate_types, +) +from core.device_layouts import get_manual_layout_choices +from core.logi_devices import DEFAULT_DPI_MAX, DEFAULT_DPI_MIN + +_VALID_BUTTON_KEYS = set(PROFILE_BUTTON_NAMES) +_VALID_LAYOUT_OVERRIDE_KEYS = { + choice["key"] for choice in get_manual_layout_choices() if choice["key"] +} + + +class ConfigValidationError(ValueError): + """Human-readable config validation error.""" + + +def _schema_path(path: str, key: str) -> str: + return f"{path}.{key}" if path else key + + +CONFIG_SCHEMA: dict[str, Any] = { + "type": "object", + "additionalProperties": False, + "required": ["version", "active_profile", "profiles", "settings"], + "properties": { + "version": {"type": "integer", "minimum": 1}, + "active_profile": {"type": "string", "minLength": 1}, + "profiles": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "type": "object", + "additionalProperties": False, + "required": ["label", "apps", "mappings"], + "properties": { + "label": {"type": "string", "minLength": 1}, + "apps": { + "type": "array", + "items": {"type": "string", "minLength": 1}, + }, + "mappings": { + "type": "object", + "additionalProperties": {"type": "string", "minLength": 1}, + }, + }, + }, + }, + "settings": { + "type": "object", + "additionalProperties": False, + "required": list(DEFAULT_CONFIG["settings"].keys()), + "properties": { + "start_minimized": {"type": "boolean"}, + "start_at_login": {"type": "boolean"}, + "hscroll_threshold": {"type": "number", "minimum": 0}, + "hscroll_cooldown_ms": {"type": "number", "minimum": 0}, + "invert_hscroll": {"type": "boolean"}, + "invert_vscroll": {"type": "boolean"}, + "dpi": { + "type": "integer", + "minimum": DEFAULT_DPI_MIN, + "maximum": DEFAULT_DPI_MAX, + }, + "smart_shift_mode": { + "type": "string", + "enum": ["ratchet", "freespin"], + }, + "smart_shift_enabled": {"type": "boolean"}, + "smart_shift_threshold": { + "type": "integer", + "minimum": 1, + "maximum": 50, + }, + "gesture_threshold": {"type": "integer", "minimum": 5}, + "gesture_deadzone": {"type": "integer", "minimum": 0}, + "gesture_timeout_ms": {"type": "integer", "minimum": 250}, + "gesture_cooldown_ms": {"type": "integer", "minimum": 0}, + "appearance_mode": { + "type": "string", + "enum": ["system", "light", "dark"], + }, + "debug_mode": {"type": "boolean"}, + "device_layout_overrides": { + "type": "object", + "additionalProperties": {"type": "string", "minLength": 1}, + }, + "language": {"type": "string", "minLength": 1}, + "dpi_presets": { + "type": "array", + "minItems": 1, + "items": { + "type": "integer", + "minimum": DEFAULT_DPI_MIN, + "maximum": DEFAULT_DPI_MAX, + }, + }, + }, + }, + }, +} + + +def _display_path(path: str) -> str: + return path or "config" + + +@lru_cache(maxsize=1) +def _schema_validator() -> Draft202012Validator: + return Draft202012Validator(CONFIG_SCHEMA) + + +def _error_path(error) -> str: + parts: list[str] = [] + for part in error.absolute_path: + if isinstance(part, int): + if parts: + parts[-1] = f"{parts[-1]}[{part}]" + else: + parts.append(f"[{part}]") + else: + parts.append(str(part)) + return ".".join(parts) + + +def _format_schema_error(error) -> str: + path = _display_path(_error_path(error)) + validator = error.validator + + if validator == "additionalProperties": + extras = sorted(set(error.instance) - set(error.schema.get("properties", {}))) + if extras: + return f"Unknown key at {_schema_path(_error_path(error), extras[0])}" + + if validator == "required": + missing = error.message.split("'")[1] + return f"{path} is missing required key '{missing}'" + + if validator == "type": + return f"{path} must be a {error.validator_value}" + + if validator == "enum": + allowed = ", ".join(repr(item) for item in error.validator_value) + return f"{path} must be one of: {allowed}" + + if validator == "minLength": + if error.validator_value == 1: + return f"{path} must not be empty" + return f"{path} must be at least {error.validator_value} characters" + + if validator == "minimum": + return f"{path} must be >= {error.validator_value}" + + if validator == "maximum": + return f"{path} must be <= {error.validator_value}" + + if validator == "minItems": + return ( + f"{path} must contain at least {error.validator_value} item" + f"{'' if error.validator_value == 1 else 's'}" + ) + + if validator == "minProperties": + return ( + f"{path} must contain at least {error.validator_value} entr" + f"{'y' if error.validator_value == 1 else 'ies'}" + ) + + return error.message + + +@lru_cache(maxsize=1) +def _action_metadata() -> tuple[set[str], set[str]]: + from core.key_simulator import ACTIONS, valid_custom_key_names + + return set(ACTIONS), set(valid_custom_key_names()) + + +def _validate_custom_action(action_id: str, path: str) -> None: + _, valid_custom_keys = _action_metadata() + parts = [part.strip().lower() for part in action_id[7:].split("+")] + if not parts or any(not part for part in parts): + raise ConfigValidationError( + f"{path} must contain at least one valid key in custom shortcut" + ) + invalid = [part for part in parts if part not in valid_custom_keys] + if invalid: + raise ConfigValidationError( + f"{path} contains unknown custom key(s): {', '.join(sorted(set(invalid)))}" + ) + + +def _validate_action_id(action_id: str, path: str) -> None: + valid_action_ids, _ = _action_metadata() + if action_id.startswith("custom:"): + _validate_custom_action(action_id, path) + return + if action_id not in valid_action_ids: + raise ConfigValidationError(f"{path} has unknown action '{action_id}'. Did you mean 'custom:{action_id}'?") + + +def validate_config(cfg: dict[str, Any]) -> None: + errors = sorted(_schema_validator().iter_errors(cfg), key=lambda e: (list(e.absolute_path), e.validator)) + if errors: + raise ConfigValidationError(_format_schema_error(errors[0])) + + active_profile = cfg["active_profile"] + profiles = cfg["profiles"] + if active_profile not in profiles: + raise ConfigValidationError( + f"Active profile '{active_profile}' not found in profiles" + ) + + for profile_name, profile in profiles.items(): + mappings = profile["mappings"] + for button_key, action_id in mappings.items(): + if button_key not in _VALID_BUTTON_KEYS: + raise ConfigValidationError( + f"profiles.{profile_name}.mappings.{button_key} is not a valid button mapping" + ) + _validate_action_id( + action_id, + f"profiles.{profile_name}.mappings.{button_key}", + ) + + overrides = cfg["settings"]["device_layout_overrides"] + for device_key, layout_key in overrides.items(): + if layout_key not in _VALID_LAYOUT_OVERRIDE_KEYS: + raise ConfigValidationError( + f"settings.device_layout_overrides.{device_key} has unknown layout '{layout_key}'" + ) + + +def normalize_config(raw_cfg: Any) -> dict[str, Any]: + """Return a migrated, default-filled, strictly validated config dict.""" + if not isinstance(raw_cfg, dict): + raise ConfigValidationError("Config document must be an object") + cfg = json.loads(json.dumps(raw_cfg)) + cfg = _migrate(cfg) + cfg = _merge_defaults(cfg, DEFAULT_CONFIG) + validate_config(cfg) + cfg = _validate_types(cfg, DEFAULT_CONFIG) + return cfg + + +def assemble_full_config(config: dict[str, Any]) -> dict[str, Any]: + active_profile = config.get("active_profile") + profiles = config.get("profiles") + if active_profile is None or not isinstance(profiles, dict): + raise ConfigValidationError("Config must specify an `active_profile`") + if active_profile not in profiles: + raise ConfigValidationError( + f"Active profile '{active_profile}' not found in profiles" + ) + if profiles[active_profile].get("apps") != []: + raise ConfigValidationError("Active profile must have an empty `apps` list") + + default_mappings = profiles[active_profile]["mappings"] + for profile_name, profile in profiles.items(): + if profile_name == active_profile: + continue + for mapping in default_mappings: + if mapping not in profile["mappings"]: + profile["mappings"][mapping] = default_mappings[mapping] + return config diff --git a/main_cli.py b/main_cli.py index a654f382..ffec7dec 100644 --- a/main_cli.py +++ b/main_cli.py @@ -19,30 +19,20 @@ import yaml from core.config import ( - DEFAULT_CONFIG, - _merge_defaults, - _migrate, - _validate_types, load_config, save_config, ) +from core.config_validation import ( + ConfigValidationError, + assemble_full_config, + normalize_config, +) from core.log_setup import setup_logging CLI_SERVICE_LABEL = "io.github.tombadash.mouser.headless" CLI_SERVICE_PLIST_NAME = f"{CLI_SERVICE_LABEL}.plist" -def normalize_config(raw_cfg: Any) -> dict[str, Any]: - """Return a migrated, default-filled config dict.""" - if not isinstance(raw_cfg, dict): - raise ValueError("Config JSON must be an object") - cfg = json.loads(json.dumps(raw_cfg)) - cfg = _migrate(cfg) - cfg = _merge_defaults(cfg, DEFAULT_CONFIG) - cfg = _validate_types(cfg, DEFAULT_CONFIG) - return cfg - - def export_config(*, stdout=None) -> int: stdout = stdout or sys.stdout json.dump(load_config(), stdout, indent=2) @@ -71,6 +61,21 @@ def _read_config_json(path: str, ft: str=None) -> dict[str, Any]: return normalize_config(processed) +def _format_cli_error(exc: Exception) -> str: + if isinstance(exc, ConfigValidationError): + return str(exc) + if isinstance(exc, json.JSONDecodeError): + return f"Invalid JSON: {exc.msg} at line {exc.lineno}, column {exc.colno}" + yaml_error = getattr(yaml, "YAMLError", None) + if yaml_error and isinstance(exc, yaml_error): + return f"Invalid YAML: {exc}" + if isinstance(exc, FileNotFoundError): + return f"File not found: {exc.filename}" + if isinstance(exc, OSError): + return str(exc) + return str(exc) or exc.__class__.__name__ + + def run_headless_instance(*, stop_event: threading.Event | None = None, engine_factory=None) -> int: if engine_factory is None: from core.engine import Engine @@ -191,27 +196,6 @@ def stop_background_service() -> int: return 0 -def assemble_full_config(config: dict[str, Any]): - try: - active_profile = config["active_profile"] - if active_profile not in config["profiles"]: - raise ValueError(f"Active profile '{active_profile}' not found in profiles") - if config["profiles"][active_profile]["apps"] != []: - raise ValueError("Active profile must have an empty `apps` list") - except KeyError: - raise ValueError("Config must specify an `active_profile`") - - default_mappings = config["profiles"][active_profile]["mappings"] - for profile_name, profile in config["profiles"].items(): - if profile_name == active_profile: - continue - for mapping in default_mappings: - if mapping not in profile["mappings"]: - profile["mappings"][mapping] = default_mappings[mapping] - - return config - - def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Mouser command line interface") subparsers = parser.add_subparsers(dest="command", required=True) @@ -262,18 +246,28 @@ def main(argv: list[str] | None = None) -> int: parser = build_parser() args = parser.parse_args(argv) - if args.command == "export": - return export_config() - setup_logging() - if args.command == "load": - return load_config_and_start(args.config, filetype=args.filetype) - if args.command == "start": - return start_background_service() - if args.command == "stop": - return stop_background_service() - if args.command == "_run": - return run_headless_instance() - parser.error(f"Unknown command: {args.command}") + try: + if args.command == "export": + return export_config() + setup_logging() + if args.command == "load": + return load_config_and_start(args.config, filetype=args.filetype) + if args.command == "start": + return start_background_service() + if args.command == "stop": + return stop_background_service() + if args.command == "_run": + return run_headless_instance() + parser.error(f"Unknown command: {args.command}") + except (ConfigValidationError, json.JSONDecodeError, OSError, RuntimeError) as exc: + print(f"Error: {_format_cli_error(exc)}", file=sys.stderr) + return 2 + except Exception as exc: + yaml_error = getattr(yaml, "YAMLError", None) + if yaml_error and isinstance(exc, yaml_error): + print(f"Error: {_format_cli_error(exc)}", file=sys.stderr) + return 2 + raise if __name__ == "__main__": diff --git a/tests/test_cli.py b/tests/test_cli.py index 1769098a..fba7facf 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,15 +1,65 @@ import io import json import plistlib +import sys import tempfile import unittest from pathlib import Path from unittest.mock import patch +sys.modules.setdefault("Quartz", type("QuartzStub", (), {})()) +sys.modules.setdefault("yaml", type("YamlStub", (), {"safe_load": staticmethod(lambda stream: None)})()) + +sys.platform = "linux" import main_cli class CliTests(unittest.TestCase): + def _valid_config(self): + return { + "version": 8, + "active_profile": "default", + "profiles": { + "default": { + "label": "Default", + "apps": [], + "mappings": { + "middle": "none", + "gesture": "none", + "gesture_left": "none", + "gesture_right": "none", + "gesture_up": "none", + "gesture_down": "none", + "xbutton1": "alt_tab", + "xbutton2": "alt_tab", + "hscroll_left": "browser_back", + "hscroll_right": "browser_forward", + "mode_shift": "switch_scroll_mode", + }, + } + }, + "settings": { + "start_minimized": True, + "start_at_login": False, + "hscroll_threshold": 1, + "hscroll_cooldown_ms": 350, + "invert_hscroll": False, + "invert_vscroll": False, + "dpi": 1000, + "smart_shift_mode": "ratchet", + "smart_shift_enabled": False, + "smart_shift_threshold": 25, + "gesture_threshold": 50, + "gesture_deadzone": 40, + "gesture_timeout_ms": 3000, + "gesture_cooldown_ms": 500, + "appearance_mode": "system", + "debug_mode": False, + "device_layout_overrides": {}, + "language": "en", + }, + } + def test_wait_for_headless_activity_pumps_macos_run_loop(self): stop_event = main_cli.threading.Event() calls = [] @@ -94,12 +144,7 @@ def test_load_persists_normalized_config_then_starts_engine(self): start_background_service.assert_called_once_with() def test_load_accepts_stdin_marker(self): - raw = { - "version": 8, - "active_profile": "default", - "profiles": {"default": {"label": "Default", "apps": [], "mappings": {}}}, - "settings": {}, - } + raw = self._valid_config() saved = {} with ( @@ -115,6 +160,72 @@ def test_load_accepts_stdin_marker(self): self.assertEqual(saved["cfg"]["version"], 8) start_background_service.assert_called_once_with() + def test_normalize_rejects_unknown_top_level_key(self): + raw = self._valid_config() + raw["bogus"] = True + + with self.assertRaisesRegex(ValueError, r"Unknown key at bogus"): + main_cli.normalize_config(raw) + + def test_normalize_rejects_unknown_mapping_key(self): + raw = self._valid_config() + raw["profiles"]["default"]["mappings"]["not_a_button"] = "none" + + with self.assertRaisesRegex(ValueError, r"not_a_button is not a valid button mapping"): + main_cli.normalize_config(raw) + + def test_normalize_rejects_unknown_action_id(self): + raw = self._valid_config() + raw["profiles"]["default"]["mappings"]["middle"] = "definitely_not_real" + + with self.assertRaisesRegex(ValueError, r"unknown action 'definitely_not_real'"): + main_cli.normalize_config(raw) + + def test_normalize_rejects_invalid_custom_shortcut(self): + raw = self._valid_config() + raw["profiles"]["default"]["mappings"]["middle"] = "custom:super+definitely_not_real" + + with self.assertRaisesRegex(ValueError, r"unknown custom key"): + main_cli.normalize_config(raw) + + def test_normalize_rejects_wrong_setting_type_instead_of_silently_resetting(self): + raw = self._valid_config() + raw["settings"]["start_minimized"] = "yes" + + with self.assertRaisesRegex(ValueError, r"settings.start_minimized must be a boolean"): + main_cli.normalize_config(raw) + + def test_load_rejects_active_profile_with_nonempty_apps(self): + raw = self._valid_config() + raw["profiles"]["default"]["apps"] = ["com.example.App"] + + with tempfile.TemporaryDirectory() as tmp_dir: + config_path = Path(tmp_dir) / "import.json" + config_path.write_text(json.dumps(raw), encoding="utf-8") + + with self.assertRaisesRegex(ValueError, r"Active profile must have an empty `apps` list"): + main_cli.load_config_and_start(str(config_path)) + + def test_main_load_invalid_config_prints_human_readable_error(self): + raw = self._valid_config() + raw["settings"]["start_minimized"] = "yes" + + with tempfile.TemporaryDirectory() as tmp_dir: + config_path = Path(tmp_dir) / "import.json" + config_path.write_text(json.dumps(raw), encoding="utf-8") + stderr = io.StringIO() + + with ( + patch("main_cli.sys.platform", "darwin"), + patch("main_cli.setup_logging"), + patch("sys.stderr", stderr), + ): + rc = main_cli.main(["load", str(config_path)]) + + self.assertEqual(rc, 2) + self.assertIn("Error:", stderr.getvalue()) + self.assertIn("settings.start_minimized must be a boolean", stderr.getvalue()) + def test_start_writes_launch_agent_and_bootstraps_it(self): with tempfile.TemporaryDirectory() as tmp_dir: plist_path = Path(tmp_dir) / main_cli.CLI_SERVICE_PLIST_NAME @@ -130,13 +241,13 @@ def _fake_launchctl(args): patch("main_cli._launchctl_run", side_effect=_fake_launchctl), ): rc = main_cli.start_background_service() + payload = plistlib.loads(plist_path.read_bytes()) self.assertEqual(rc, 0) self.assertEqual( launchctl_calls, [["launchctl", "bootstrap", f"gui/{main_cli.os.getuid()}", str(plist_path)]], ) - payload = plistlib.loads(plist_path.read_bytes()) self.assertEqual(payload["Label"], main_cli.CLI_SERVICE_LABEL) self.assertEqual(payload["ProgramArguments"], main_cli._service_program_arguments()) self.assertTrue(payload["RunAtLoad"]) @@ -168,6 +279,7 @@ def _fake_launchctl(args): def test_main_start_and_stop_dispatch_to_service_helpers(self): with ( + patch("main_cli.sys.platform", "darwin"), patch("main_cli.setup_logging"), patch("main_cli.start_background_service", return_value=0) as start_background_service, patch("main_cli.stop_background_service", return_value=0) as stop_background_service, @@ -180,6 +292,7 @@ def test_main_start_and_stop_dispatch_to_service_helpers(self): def test_main_internal_run_dispatches_to_headless_runner(self): with ( + patch("main_cli.sys.platform", "darwin"), patch("main_cli.setup_logging"), patch("main_cli.run_headless_instance", return_value=0) as run_headless_instance, ): diff --git a/tests/test_config_validation.py b/tests/test_config_validation.py new file mode 100644 index 00000000..b388601e --- /dev/null +++ b/tests/test_config_validation.py @@ -0,0 +1,139 @@ +import copy +import sys +import unittest + +sys.platform = "linux" + +from core.config import DEFAULT_CONFIG +from core.config_validation import ( + ConfigValidationError, + assemble_full_config, + normalize_config, + validate_config, +) + + +class ConfigValidationTests(unittest.TestCase): + def _valid_config(self): + return copy.deepcopy(DEFAULT_CONFIG) + + def test_validate_accepts_default_config(self): + cfg = self._valid_config() + + validate_config(cfg) + + def test_normalize_migrates_and_fills_defaults(self): + legacy = { + "version": 1, + "active_profile": "default", + "profiles": { + "default": { + "label": "Default", + "mappings": {"xbutton1": "browser_back"}, + } + }, + "settings": {}, + } + + normalized = normalize_config(legacy) + + self.assertEqual(normalized["version"], 8) + self.assertEqual(normalized["profiles"]["default"]["apps"], []) + self.assertEqual( + normalized["profiles"]["default"]["mappings"]["mode_shift"], + "switch_scroll_mode", + ) + self.assertIn("language", normalized["settings"]) + + def test_validate_rejects_unknown_top_level_key(self): + cfg = self._valid_config() + cfg["bogus"] = True + + with self.assertRaisesRegex(ConfigValidationError, r"Unknown key at bogus"): + validate_config(cfg) + + def test_validate_rejects_wrong_type_via_schema(self): + cfg = self._valid_config() + cfg["settings"]["start_minimized"] = "yes" + + with self.assertRaisesRegex( + ConfigValidationError, + r"settings.start_minimized must be a boolean", + ): + validate_config(cfg) + + def test_validate_rejects_unknown_mapping_key(self): + cfg = self._valid_config() + cfg["profiles"]["default"]["mappings"]["not_a_button"] = "none" + + with self.assertRaisesRegex( + ConfigValidationError, + r"not_a_button is not a valid button mapping", + ): + validate_config(cfg) + + def test_validate_rejects_unknown_action_id(self): + cfg = self._valid_config() + cfg["profiles"]["default"]["mappings"]["middle"] = "definitely_not_real" + + with self.assertRaisesRegex( + ConfigValidationError, + r"unknown action 'definitely_not_real'", + ): + validate_config(cfg) + + def test_validate_rejects_invalid_custom_shortcut(self): + cfg = self._valid_config() + cfg["profiles"]["default"]["mappings"]["middle"] = "custom:super+definitely_not_real" + + with self.assertRaisesRegex( + ConfigValidationError, + r"unknown custom key", + ): + validate_config(cfg) + + def test_validate_rejects_unknown_layout_override(self): + cfg = self._valid_config() + cfg["settings"]["device_layout_overrides"] = {"mx_master_3s": "not_a_layout"} + + with self.assertRaisesRegex( + ConfigValidationError, + r"unknown layout 'not_a_layout'", + ): + validate_config(cfg) + + def test_assemble_full_config_rejects_active_profile_with_apps(self): + cfg = self._valid_config() + cfg["profiles"]["default"]["apps"] = ["com.example.App"] + + with self.assertRaisesRegex( + ConfigValidationError, + r"Active profile must have an empty `apps` list", + ): + assemble_full_config(cfg) + + def test_assemble_full_config_fills_missing_profile_mappings_from_active_profile(self): + cfg = self._valid_config() + cfg["profiles"]["work"] = { + "label": "Work", + "apps": ["com.example.Work"], + "mappings": { + "middle": "copy", + }, + } + + assembled = assemble_full_config(cfg) + + self.assertEqual(assembled["profiles"]["work"]["mappings"]["middle"], "copy") + self.assertEqual( + assembled["profiles"]["work"]["mappings"]["xbutton1"], + cfg["profiles"]["default"]["mappings"]["xbutton1"], + ) + self.assertEqual( + assembled["profiles"]["work"]["mappings"]["mode_shift"], + cfg["profiles"]["default"]["mappings"]["mode_shift"], + ) + + +if __name__ == "__main__": + unittest.main() From 4d9a4593f7f9482551091aafd4078235424a74d6 Mon Sep 17 00:00:00 2001 From: ravibrock <66334356+ravibrock@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:58:26 -0500 Subject: [PATCH 08/17] Add basic docs/polish --- README.md | 154 ++++++++++++++++++++++++++++++++--------------- main_cli.py | 10 +-- requirements.txt | 18 ++++-- 3 files changed, 124 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 21acb2f9..5e43ad2b 100644 --- a/README.md +++ b/README.md @@ -40,10 +40,13 @@ No telemetry. No cloud. No Logitech account required. - **Device-aware UI** — interactive MX Master diagram with clickable hotspots; generic fallback for other models ### 🌐 Multi-Language UI -- **English / Simplified Chinese / Traditional Chinese** - switch instantly in-app, no restart required +- **English / Simplified Chinese / Traditional Chinese** — switch instantly in-app, no restart required - Language preference is automatically saved to `config.json` and restored on next launch - Covers all major UI surfaces: navigation, mouse page, settings page, dialogs, system tray/menu bar, and permission prompts +### 🤖 CLI +- **CLI for text-based configuration** — Run `python main_cli.py -h` for details + ### 🛡️ Privacy First - **Fully local** — config is a JSON file, all processing happens on your machine - **System tray / menu bar** — runs quietly in the background with quick access from the tray @@ -185,6 +188,15 @@ pip install -r requirements.txt | `pyobjc-framework-Quartz` | macOS CGEventTap / Quartz event support | | `pyobjc-framework-Cocoa` | macOS app detection and media-key support | | `evdev` | Linux mouse grab and virtual device forwarding (uinput) | +| `pyobjc-core` | Core PyObjC runtime required by the macOS framework bindings | +| `PyYAML` | YAML parsing for CLI config import | +| `jsonschema` | JSON Schema validation for imported CLI configs | +| `jsonschema-specifications` | Bundled JSON Schema metaschemas used by `jsonschema` | +| `referencing` | Reference resolution support used by `jsonschema` | +| `rpds-py` | Performance-oriented data structures used by `jsonschema` | +| `attrs` | Shared utility dependency used by the schema-validation stack | +| `typing-extensions` | Backported typing helpers required by some dependencies | +| `pyinstaller` | Build-time dependency for packaging standalone app bundles | ### Running @@ -200,6 +212,9 @@ Mouser.bat # Option D: Use the desktop shortcut (no console window) # Double-click Mouser.lnk + +# Option E: Use the CLI +python main_cli.py [...args] ``` > **Tip:** To run without a console window, use `pythonw.exe main_qml.py` or the `.lnk` shortcut. @@ -369,52 +384,95 @@ All settings are stored in `%APPDATA%\Mouser\config.json` (Windows) or `~/Librar ``` mouser/ -├── main_qml.py # Application entry point (PySide6 + QML) -├── Mouser.bat # Quick-launch batch file -├── Mouser-mac.spec # Native macOS app-bundle spec -├── Mouser-linux.spec # Linux PyInstaller spec -├── build_macos_app.sh # macOS bundle build + icon/signing flow -├── .github/workflows/ -│ ├── ci.yml # CI checks (compile, tests, QML lint) -│ └── release.yml # Automated release builds (Win/macOS/Linux) -├── README.md -├── readme_mac_osx.md -├── requirements.txt -├── .gitignore -│ -├── core/ # Backend logic -│ ├── accessibility.py # macOS Accessibility trust checks -│ ├── engine.py # Core engine — wires hook ↔ simulator ↔ config -│ ├── mouse_hook.py # Low-level mouse hook + HID++ gesture listener -│ ├── hid_gesture.py # HID++ 2.0 gesture button divert (Bluetooth + Logi Bolt) -│ ├── 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) -│ ├── config.py # Config manager (JSON load/save/migrate) -│ └── app_detector.py # Foreground app polling -│ -├── ui/ # UI layer -│ ├── backend.py # QML ↔ Python bridge (QObject with properties/slots) -│ └── qml/ -│ ├── Main.qml # App shell (sidebar + page stack + tray toast) -│ ├── MousePage.qml # Merged mouse diagram + profile manager -│ ├── ScrollPage.qml # DPI slider + scroll inversion toggles -│ ├── HotspotDot.qml # Interactive button overlay on mouse image -│ ├── ActionChip.qml # Selectable action pill -│ └── Theme.js # Shared colors and constants -│ -└── images/ - ├── AppIcon.icns # Committed macOS app-bundle icon - ├── mouse.png # MX Master 3S top-down diagram - ├── icons/mouse-simple.svg # Generic fallback device card artwork - ├── logo.png # Mouser logo (source) - ├── logo.ico # Multi-size icon for shortcuts - ├── logo_icon.png # Square icon with background - ├── chrom.png # App icon: Chrome - ├── VSCODE.png # App icon: VS Code - ├── VLC.png # App icon: VLC - └── media.webp # App icon: Windows Media Player +├── build_macos_app.sh # macOS bundle build + icon/signing flow +├── build.bat # Windows build helper with optional venv activation +├── CONTRIBUTING_DEVICES.md # Guide for adding/testing new device definitions +├── core # Backend logic +│   ├── __init__.py # Core package marker +│   ├── accessibility.py # macOS Accessibility trust checks +│   ├── app_catalog.py # Installed-app resolution + alias catalog helpers +│   ├── app_detector.py # Foreground app polling +│   ├── config_validation.py # Schema-first config normalization + validation helpers +│   ├── config.py # Config manager (JSON load/save/migrate) +│   ├── device_layouts.py # Device-family layout registry for QML overlays +│   ├── engine.py # Core engine — wires hook ↔ simulator ↔ config +│   ├── hid_gesture.py # HID++ 2.0 gesture button divert (Bluetooth + Logi Bolt) +│   ├── key_simulator.py # Platform-specific action simulator +│   ├── log_setup.py # Application logging setup +│   ├── logi_devices.py # Known Logitech device catalog + connected-device metadata +│   ├── mouse_hook.py # Low-level mouse hook + HID++ gesture listener +│   ├── startup.py # Cross-platform login startup (Windows registry + macOS LaunchAgent) +│   └── version.py # Build/version metadata helpers +├── DEVELOPMENT.md # Developer-oriented architecture and workflow notes +├── images # Raster/vector UI assets and app icons +│   ├── AppIcon.icns # Committed macOS app-bundle icon +│   ├── chrom.png # App icon: Chrome +│   ├── icons # Small reusable UI glyphs +│   │   ├── battery-high.svg # Battery status icon +│   │   ├── circle.svg # Generic circular indicator icon +│   │   ├── info.svg # Informational icon +│   │   ├── mouse-simple.svg # Generic fallback device card artwork +│   │   ├── plus.svg # Add/create icon +│   │   ├── sliders-horizontal.svg # Settings/sliders icon +│   │   ├── trash.svg # Delete icon +│   │   ├── warning.svg # Warning icon +│   │   └── x.svg # Close/remove icon +│   ├── logo_icon.png # Square icon with background +│   ├── logo.icns # Alternate macOS icon asset +│   ├── logo.ico # Multi-size icon for shortcuts +│   ├── logo.png # Mouser logo (source) +│   ├── media.webp # App icon: Windows Media Player +│   ├── mouse_mx_anywhere_3s.png # MX Anywhere family diagram +│   ├── mouse.png # MX Master 3S top-down diagram +│   ├── mx_vertical.png # MX Vertical diagram +│   ├── Screenshot_mouse.png # Documentation screenshot: Mouse page +│   ├── Screenshot_settings.png # Documentation screenshot: Settings page +│   ├── Screenshot.png # Documentation screenshot: app overview +│   ├── VLC.png # App icon: VLC +│   └── VSCODE.png # App icon: VS Code +├── LICENSE # Project license +├── main_cli.py # CLI entry point for config import/export + headless service control +├── main_qml.py # Application entry point (PySide6 + QML) +├── Mouser-linux.spec # Linux PyInstaller spec +├── Mouser-mac.spec # Native macOS app-bundle spec +├── Mouser.bat # Quick-launch batch file +├── Mouser.spec # Cross-platform/default PyInstaller spec +├── README_CN.md # Chinese-language project README +├── readme_mac_osx.md # macOS-specific setup and usage notes +├── README.md # Primary project documentation +├── requirements.txt # Python dependency list +├── test_config.json # Example/test JSON config input +├── test_config.yaml # Example/test YAML config input +├── tests # Automated test suite +│   ├── test_accessibility.py # Tests for macOS accessibility helpers +│   ├── test_app_detector.py # Tests for foreground-app detection +│   ├── test_backend.py # Tests for QML backend bridge behavior +│   ├── test_cli.py # Tests for CLI import/export and service commands +│   ├── test_config_validation.py # Tests for standalone core config validation +│   ├── test_config.py # Tests for config migration/load/save helpers +│   ├── test_device_layouts.py # Tests for device-layout registry lookups +│   ├── test_engine.py # Tests for engine dispatch and scroll behavior +│   ├── test_hid_gesture.py # Tests for HID++ gesture listener logic +│   ├── test_key_simulator.py # Tests for action simulation and custom shortcuts +│   ├── test_log_setup.py # Tests for logging setup +│   ├── test_logi_devices.py # Tests for Logitech device catalog helpers +│   ├── test_mouse_hook.py # Tests for low-level mouse hook + gesture detection +│   ├── test_single_instance.py # Tests for single-instance behavior +│   ├── test_smart_shift.py # Tests for Smart Shift config/engine/backend behavior +│   └── test_startup.py # Tests for login-startup integration +└── ui # UI layer + ├── __init__.py # UI package marker + ├── backend.py # QML ↔ Python bridge (QObject with properties/slots) + ├── locale_manager.py # UI string catalog + localization helpers + └── qml # QML component tree + ├── ActionChip.qml # Selectable action pill + ├── AppIcon.qml # Reusable app icon component + ├── HotspotDot.qml # Interactive button overlay on mouse image + ├── KeyCaptureDialog.qml # Custom shortcut capture dialog + ├── Main.qml # App shell (sidebar + page stack + tray toast) + ├── MousePage.qml # Merged mouse diagram + profile manager + ├── ScrollPage.qml # DPI slider + scroll inversion toggles + └── Theme.js # Shared colors and constants ``` ## UI Overview @@ -467,7 +525,7 @@ The app has two pages accessible from a slim sidebar: - [ ] **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 -- [ ] **Export/import config** — share configurations between machines +- [x] **Export/import config** — share configurations between machines (CLI only for now) - [ ] **Tray icon badge** — show active profile name in tray tooltip - [x] **macOS support** — added via CGEventTap, Quartz CGEvent, and NSWorkspace - [ ] **Broader Wayland support and Linux validation** — extend app detection beyond KDE Wayland / X11 and validate across more distros and desktop environments diff --git a/main_cli.py b/main_cli.py index ffec7dec..97bffe42 100644 --- a/main_cli.py +++ b/main_cli.py @@ -144,8 +144,8 @@ def load_config_and_start( def _service_program_arguments() -> list[str]: exe = os.path.abspath(sys.executable) if getattr(sys, "frozen", False): - return [exe, "_run"] - return [exe, os.path.abspath(__file__), "_run"] + return [exe, "run"] + return [exe, os.path.abspath(__file__), "run"] def _service_plist_path() -> str: @@ -232,8 +232,8 @@ def build_parser() -> argparse.ArgumentParser: ) subparsers.add_parser( - "_run", - help=argparse.SUPPRESS, + "run", + help="Run the headless Mouser engine in the foreground (for testing and debugging)", ) return parser @@ -256,7 +256,7 @@ def main(argv: list[str] | None = None) -> int: return start_background_service() if args.command == "stop": return stop_background_service() - if args.command == "_run": + if args.command == "run": return run_headless_instance() parser.error(f"Unknown command: {args.command}") except (ConfigValidationError, json.JSONDecodeError, OSError, RuntimeError) as exc: diff --git a/requirements.txt b/requirements.txt index 2660c83f..df5fc7fd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,15 @@ -pyinstaller>=6.0 -hidapi>=0.14 -PySide6>=6.6 Pillow>=10.0 -pyobjc-framework-Quartz>=10.0; sys_platform == "darwin" -pyobjc-framework-Cocoa>=10.0; sys_platform == "darwin" +PySide6>=6.6 +attrs==26.1.0 evdev>=1.6; sys_platform == "linux" +hidapi>=0.14 +jsonschema-specifications==2025.9.1 +jsonschema==4.26.0 +pyinstaller>=6.0 +pyobjc-core==12.1 +pyobjc-framework-Cocoa>=10.0; sys_platform == "darwin" +pyobjc-framework-Quartz>=10.0; sys_platform == "darwin" +pyyaml==6.0.3 +referencing==0.37.0 +rpds-py==0.30.0 +typing-extensions==4.15.0 From 1689ffb0c81ea23173b7d77b8ff68e790752d68a Mon Sep 17 00:00:00 2001 From: ravibrock <66334356+ravibrock@users.noreply.github.com> Date: Tue, 28 Apr 2026 19:31:10 -0500 Subject: [PATCH 09/17] Final fixes --- core/config_validation.py | 18 +++++-- tests/test_cli.py | 26 ++++++++--- tests/test_config_validation.py | 83 +++++++++++++++++++++++++++++++-- 3 files changed, 113 insertions(+), 14 deletions(-) diff --git a/core/config_validation.py b/core/config_validation.py index 542a8103..c95c40eb 100644 --- a/core/config_validation.py +++ b/core/config_validation.py @@ -100,6 +100,7 @@ def _schema_path(path: str, key: str) -> str: "additionalProperties": {"type": "string", "minLength": 1}, }, "language": {"type": "string", "minLength": 1}, + "ignore_trackpad": {"type": "boolean"}, "dpi_presets": { "type": "array", "minItems": 1, @@ -220,6 +221,8 @@ def validate_config(cfg: dict[str, Any]) -> None: active_profile = cfg["active_profile"] profiles = cfg["profiles"] + if "default" not in profiles: + raise ConfigValidationError("Config must define a `default` profile") if active_profile not in profiles: raise ConfigValidationError( f"Active profile '{active_profile}' not found in profiles" @@ -250,6 +253,11 @@ def normalize_config(raw_cfg: Any) -> dict[str, Any]: if not isinstance(raw_cfg, dict): raise ConfigValidationError("Config document must be an object") cfg = json.loads(json.dumps(raw_cfg)) + profiles = cfg.get("profiles") + if isinstance(profiles, dict) and "default" not in profiles: + raise ConfigValidationError("Config must define a `default` profile") + if "version" not in cfg: + cfg["version"] = DEFAULT_CONFIG["version"] cfg = _migrate(cfg) cfg = _merge_defaults(cfg, DEFAULT_CONFIG) validate_config(cfg) @@ -266,12 +274,14 @@ def assemble_full_config(config: dict[str, Any]) -> dict[str, Any]: raise ConfigValidationError( f"Active profile '{active_profile}' not found in profiles" ) - if profiles[active_profile].get("apps") != []: - raise ConfigValidationError("Active profile must have an empty `apps` list") + if "default" not in profiles: + raise ConfigValidationError("Config must define a `default` profile") + if profiles["default"].get("apps") != []: + raise ConfigValidationError("Default profile must have an empty `apps` list") - default_mappings = profiles[active_profile]["mappings"] + default_mappings = profiles["default"]["mappings"] for profile_name, profile in profiles.items(): - if profile_name == active_profile: + if profile_name == "default": continue for mapping in default_mappings: if mapping not in profile["mappings"]: diff --git a/tests/test_cli.py b/tests/test_cli.py index fba7facf..ae2a56d9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -57,6 +57,7 @@ def _valid_config(self): "debug_mode": False, "device_layout_overrides": {}, "language": "en", + "ignore_trackpad": True, }, } @@ -105,7 +106,6 @@ def test_normalize_config_migrates_and_fills_defaults(self): normalized = main_cli.normalize_config(legacy) - self.assertEqual(normalized["version"], 8) self.assertEqual(normalized["profiles"]["default"]["apps"], []) self.assertEqual( normalized["profiles"]["default"]["mappings"]["mode_shift"], @@ -136,7 +136,6 @@ def test_load_persists_normalized_config_then_starts_engine(self): ) self.assertEqual(rc, 0) - self.assertEqual(saved["cfg"]["version"], 8) self.assertEqual( saved["cfg"]["profiles"]["default"]["mappings"]["mode_shift"], "switch_scroll_mode", @@ -157,7 +156,6 @@ def test_load_accepts_stdin_marker(self): ) self.assertEqual(rc, 0) - self.assertEqual(saved["cfg"]["version"], 8) start_background_service.assert_called_once_with() def test_normalize_rejects_unknown_top_level_key(self): @@ -167,6 +165,22 @@ def test_normalize_rejects_unknown_top_level_key(self): with self.assertRaisesRegex(ValueError, r"Unknown key at bogus"): main_cli.normalize_config(raw) + def test_normalize_rejects_missing_default_profile(self): + raw = self._valid_config() + raw["profiles"] = { + "work": { + "label": "Work", + "apps": ["com.example.Work"], + "mappings": { + "middle": "copy", + }, + } + } + raw["active_profile"] = "work" + + with self.assertRaisesRegex(ValueError, r"Config must define a `default` profile"): + main_cli.normalize_config(raw) + def test_normalize_rejects_unknown_mapping_key(self): raw = self._valid_config() raw["profiles"]["default"]["mappings"]["not_a_button"] = "none" @@ -195,7 +209,7 @@ def test_normalize_rejects_wrong_setting_type_instead_of_silently_resetting(self with self.assertRaisesRegex(ValueError, r"settings.start_minimized must be a boolean"): main_cli.normalize_config(raw) - def test_load_rejects_active_profile_with_nonempty_apps(self): + def test_load_rejects_default_profile_with_nonempty_apps(self): raw = self._valid_config() raw["profiles"]["default"]["apps"] = ["com.example.App"] @@ -203,7 +217,7 @@ def test_load_rejects_active_profile_with_nonempty_apps(self): config_path = Path(tmp_dir) / "import.json" config_path.write_text(json.dumps(raw), encoding="utf-8") - with self.assertRaisesRegex(ValueError, r"Active profile must have an empty `apps` list"): + with self.assertRaisesRegex(ValueError, r"Default profile must have an empty `apps` list"): main_cli.load_config_and_start(str(config_path)) def test_main_load_invalid_config_prints_human_readable_error(self): @@ -296,7 +310,7 @@ def test_main_internal_run_dispatches_to_headless_runner(self): patch("main_cli.setup_logging"), patch("main_cli.run_headless_instance", return_value=0) as run_headless_instance, ): - self.assertEqual(main_cli.main(["_run"]), 0) + self.assertEqual(main_cli.main(["run"]), 0) run_headless_instance.assert_called_once_with() diff --git a/tests/test_config_validation.py b/tests/test_config_validation.py index b388601e..c72da85c 100644 --- a/tests/test_config_validation.py +++ b/tests/test_config_validation.py @@ -37,7 +37,6 @@ def test_normalize_migrates_and_fills_defaults(self): normalized = normalize_config(legacy) - self.assertEqual(normalized["version"], 8) self.assertEqual(normalized["profiles"]["default"]["apps"], []) self.assertEqual( normalized["profiles"]["default"]["mappings"]["mode_shift"], @@ -45,6 +44,40 @@ def test_normalize_migrates_and_fills_defaults(self): ) self.assertIn("language", normalized["settings"]) + def test_normalize_unversioned_config_treats_it_as_current_schema(self): + raw = { + "active_profile": "default", + "profiles": { + "default": { + "label": "Default", + "apps": [], + "mappings": { + "mode_shift": "custom:super+shift+4", + }, + }, + "finder": { + "label": "Finder", + "apps": ["com.apple.finder"], + "mappings": { + "xbutton1": "custom:tab", + }, + }, + }, + "settings": {}, + } + + normalized = normalize_config(raw) + + self.assertEqual(normalized["version"], DEFAULT_CONFIG["version"]) + self.assertNotIn("mode_shift", normalized["profiles"]["finder"]["mappings"]) + + assembled = assemble_full_config(normalized) + + self.assertEqual( + assembled["profiles"]["finder"]["mappings"]["mode_shift"], + "custom:super+shift+4", + ) + def test_validate_rejects_unknown_top_level_key(self): cfg = self._valid_config() cfg["bogus"] = True @@ -52,6 +85,25 @@ def test_validate_rejects_unknown_top_level_key(self): with self.assertRaisesRegex(ConfigValidationError, r"Unknown key at bogus"): validate_config(cfg) + def test_validate_rejects_missing_default_profile(self): + cfg = self._valid_config() + cfg["profiles"] = { + "work": { + "label": "Work", + "apps": ["com.example.Work"], + "mappings": { + "middle": "copy", + }, + } + } + cfg["active_profile"] = "work" + + with self.assertRaisesRegex( + ConfigValidationError, + r"Config must define a `default` profile", + ): + validate_config(cfg) + def test_validate_rejects_wrong_type_via_schema(self): cfg = self._valid_config() cfg["settings"]["start_minimized"] = "yes" @@ -102,17 +154,17 @@ def test_validate_rejects_unknown_layout_override(self): ): validate_config(cfg) - def test_assemble_full_config_rejects_active_profile_with_apps(self): + def test_assemble_full_config_rejects_default_profile_with_apps(self): cfg = self._valid_config() cfg["profiles"]["default"]["apps"] = ["com.example.App"] with self.assertRaisesRegex( ConfigValidationError, - r"Active profile must have an empty `apps` list", + r"Default profile must have an empty `apps` list", ): assemble_full_config(cfg) - def test_assemble_full_config_fills_missing_profile_mappings_from_active_profile(self): + def test_assemble_full_config_fills_missing_profile_mappings_from_default_profile(self): cfg = self._valid_config() cfg["profiles"]["work"] = { "label": "Work", @@ -134,6 +186,29 @@ def test_assemble_full_config_fills_missing_profile_mappings_from_active_profile cfg["profiles"]["default"]["mappings"]["mode_shift"], ) + def test_assemble_full_config_uses_default_profile_even_when_active_profile_is_app_specific(self): + cfg = self._valid_config() + cfg["active_profile"] = "ghostty" + cfg["profiles"]["default"]["mappings"]["mode_shift"] = "custom:super+shift+4" + cfg["profiles"]["ghostty"] = { + "label": "Ghostty", + "apps": ["com.mitchellh.ghostty"], + "mappings": { + "hscroll_left": "next_tab", + }, + } + + assembled = assemble_full_config(cfg) + + self.assertEqual( + assembled["profiles"]["ghostty"]["mappings"]["mode_shift"], + "custom:super+shift+4", + ) + self.assertEqual( + assembled["profiles"]["ghostty"]["mappings"]["xbutton1"], + cfg["profiles"]["default"]["mappings"]["xbutton1"], + ) + if __name__ == "__main__": unittest.main() From 03fe6a7e9389e769486714af2e776e32fa3fe14d Mon Sep 17 00:00:00 2001 From: Luca <100935601+thisislvca@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:32:26 +0200 Subject: [PATCH 10/17] Fix Linux Bluetooth Logitech detection and permissions setup (#128) * fix: probe known Logitech HID devices without usage metadata * fix: preserve Linux PySide runtime libraries * fix: add repo root to Linux spec imports * fix: narrow Linux bundle dependency audit * fix: keep core Linux Qt dependencies * ci: install Linux xcb runtime dependencies * ci: force kill Linux smoke test * ci: accept forced Linux smoke test timeout * fix: use hidraw for Linux Bluetooth HID * fix: improve Linux device access diagnostics * fix: add Linux permission setup helper from PR #115 * fix: prefer supported Logitech evdev devices from PR #115 --- .github/workflows/release.yml | 112 +++++++++- Mouser-linux.spec | 123 ++--------- README.md | 39 +++- README_CN.md | 28 ++- build_support.py | 116 ++++++++++ core/engine.py | 15 ++ core/hid_gesture.py | 210 ++++++++++++++++++- core/linux_permissions.py | 171 +++++++++++++++ core/mouse_hook.py | 147 ++++++++++++- packaging/linux/69-mouser-logitech.rules | 18 ++ packaging/linux/install-linux-permissions.sh | 43 ++++ tests/test_build_support.py | 69 ++++++ tests/test_hid_gesture.py | 197 +++++++++++++++++ tests/test_linux_permissions.py | 100 +++++++++ tests/test_mouse_hook.py | 83 +++++++- 15 files changed, 1339 insertions(+), 132 deletions(-) create mode 100644 build_support.py create mode 100644 core/linux_permissions.py create mode 100644 packaging/linux/69-mouser-logitech.rules create mode 100755 packaging/linux/install-linux-permissions.sh create mode 100644 tests/test_build_support.py create mode 100644 tests/test_linux_permissions.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 196cd2ac..9801f037 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -93,7 +93,20 @@ jobs: python-version: "3.12" - name: Install system dependencies - run: sudo apt-get update && sudo apt-get install -y libhidapi-dev + run: | + sudo apt-get update + sudo apt-get install -y \ + libhidapi-dev \ + libegl1 \ + libxkbcommon-x11-0 \ + libxcb-cursor0 \ + libxcb-icccm4 \ + libxcb-util1 \ + libxcb-image0 \ + libxcb-keysyms1 \ + libxcb-render-util0 \ + libxcb-shape0 \ + libxcb-xkb1 - name: Install dependencies run: pip install -r requirements.txt @@ -101,6 +114,103 @@ jobs: - name: Build with PyInstaller run: pyinstaller Mouser-linux.spec --noconfirm + - name: Audit Linux bundle dependencies + run: | + python - <<'PY' + import os + import pathlib + import stat + import subprocess + + bundle_root = os.path.join("dist", "Mouser") + if not os.path.isdir(bundle_root): + raise SystemExit(f"Missing bundle directory: {bundle_root}") + + bundle_root_path = pathlib.Path(bundle_root).resolve() + internal_root = bundle_root_path / "_internal" + pyside_root = internal_root / "PySide6" + hidapi_root = internal_root / "hidapi.libs" + + def is_elf(path: str) -> bool: + try: + mode = os.stat(path).st_mode + except OSError: + return False + if not stat.S_ISREG(mode): + return False + with open(path, "rb") as fh: + return fh.read(4) == b"\x7fELF" + + library_dirs = [] + for root, _, files in os.walk(bundle_root): + if any(".so" in name for name in files): + library_dirs.append(root) + + ld_library_path = ":".join(dict.fromkeys(library_dirs)) + env = os.environ.copy() + if ld_library_path: + env["LD_LIBRARY_PATH"] = ld_library_path + + def should_audit(path: pathlib.Path) -> bool: + if path == bundle_root_path / "Mouser": + return True + try: + relative = path.relative_to(pyside_root) + return "plugins" not in relative.parts and "qml" not in relative.parts + except ValueError: + pass + try: + path.relative_to(hidapi_root) + return True + except ValueError: + return False + + missing = [] + for root, _, files in os.walk(bundle_root): + for name in files: + path = pathlib.Path(root, name).resolve() + if not is_elf(str(path)) or not should_audit(path): + continue + result = subprocess.run( + ["ldd", str(path)], + check=False, + capture_output=True, + text=True, + env=env, + ) + output = result.stdout + result.stderr + unresolved = [ + line.strip() + for line in output.splitlines() + if "not found" in line + ] + if unresolved: + missing.append((path, unresolved)) + + if missing: + for path, unresolved in missing: + print(path) + for line in unresolved: + print(f" {line}") + raise SystemExit("Linux bundle contains unresolved shared-library dependencies") + PY + + - name: Smoke test Linux bundle startup + run: | + set +e + QT_QPA_PLATFORM=offscreen timeout --kill-after=5s 15s ./dist/Mouser/Mouser + status=$? + set -e + if [ "$status" -ne 0 ] && [ "$status" -ne 124 ] && [ "$status" -ne 137 ]; then + exit "$status" + fi + + - name: Add Linux permission helper + run: | + cp packaging/linux/69-mouser-logitech.rules dist/Mouser/ + cp packaging/linux/install-linux-permissions.sh dist/Mouser/ + chmod +x dist/Mouser/install-linux-permissions.sh + - name: Create archive run: cd dist && zip -r -y ../Mouser-Linux.zip Mouser diff --git a/Mouser-linux.spec b/Mouser-linux.spec index c7f471df..ec41eec3 100644 --- a/Mouser-linux.spec +++ b/Mouser-linux.spec @@ -11,8 +11,14 @@ Output: dist/Mouser/ (directory with Mouser executable + dependencies) import os import json import subprocess +import sys ROOT = os.path.abspath(".") +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + +from build_support import should_keep_linux_qt_asset + BUILD_INFO_PATH = os.path.join(ROOT, "build", "mouser_build_info.json") @@ -84,10 +90,19 @@ a = Analysis( datas=[ (os.path.join(ROOT, "ui", "qml"), os.path.join("ui", "qml")), (os.path.join(ROOT, "images"), "images"), + ( + os.path.join(ROOT, "packaging", "linux", "69-mouser-logitech.rules"), + os.path.join("linux", "69-mouser-logitech.rules"), + ), + ( + os.path.join(ROOT, "packaging", "linux", "install-linux-permissions.sh"), + os.path.join("linux", "install-linux-permissions.sh"), + ), (BUILD_INFO_DATA, "."), ], hiddenimports=[ "hid", + "hidraw", "logging.handlers", "evdev", "ui.locale_manager", @@ -165,112 +180,8 @@ a = Analysis( noarchive=False, ) -# Keep only the Qt runtime pieces Mouser actually uses. The negative-match -# approach still let large transitive Qt payload through on Linux. -QT_KEEP = { - "Qt6Core", - "Qt6Gui", - "Qt6Widgets", - "Qt6Network", - "Qt6OpenGL", - "Qt6Qml", - "Qt6QmlCore", - "Qt6QmlMeta", - "Qt6QmlModels", - "Qt6QmlNetwork", - "Qt6QmlWorkerScript", - "Qt6Quick", - "Qt6QuickControls2", - "Qt6QuickControls2Impl", - "Qt6QuickControls2Basic", - "Qt6QuickControls2BasicStyleImpl", - "Qt6QuickControls2Material", - "Qt6QuickControls2MaterialStyleImpl", - "Qt6QuickTemplates2", - "Qt6QuickLayouts", - "Qt6QuickEffects", - "Qt6QuickShapes", - "Qt6ShaderTools", - "Qt6Svg", - "pyside6", - "pyside6qml", - "shiboken6", -} - -KEEP_PLUGIN_DIRS = { - "platforms", - "imageformats", - "styles", - "iconengines", - "platforminputcontexts", - "xcbglintegrations", - "platformthemes", - "tls", - "egldeviceintegrations", - "networkinformation", - "generic", - "wayland-decoration-client", - "wayland-graphics-integration-client", - "wayland-shell-integration", -} - -KEEP_QML_TOP = {"QtCore", "QtQml", "QtQuick", "QtNetwork"} -KEEP_QTQUICK = {"Controls", "Layouts", "Templates", "Window"} - - -def normalized_stem(path): - base = os.path.basename(path) - if ".so" in base: - return base.split(".so", 1)[0].removeprefix("lib") - stem = os.path.splitext(base)[0] - if stem.endswith(".abi3"): - stem = stem[:-5] - return stem - - -def should_keep(path): - normalized = path.replace("\\", "/") - lower = normalized.lower() - - if "PySide6" not in normalized and "pyside6" not in lower: - return True - - stem = normalized_stem(normalized) - if stem in QT_KEEP: - return True - - base = os.path.basename(normalized) - if base.endswith(".abi3.so"): - return True - - plugin_marker = "/plugins/" - plugin_index = lower.find(plugin_marker) - if plugin_index != -1: - plugin_path = normalized[plugin_index + len(plugin_marker) :] - plugin_dir = plugin_path.split("/", 1)[0] - return plugin_dir in KEEP_PLUGIN_DIRS and base != "libqpdf.so" - - qml_marker = "/qml/" - qml_index = lower.find(qml_marker) - if qml_index != -1: - qml_path = normalized[qml_index + len(qml_marker) :] - parts = [part for part in qml_path.split("/") if part] - if not parts: - return True - if parts[0] not in KEEP_QML_TOP: - return False - if parts[0] == "QtQuick" and len(parts) > 1 and parts[1] not in KEEP_QTQUICK: - return False - style_parts = {part.lower() for part in parts} - if style_parts & {"fusion", "imagine", "universal", "fluentwinui3", "ios", "macos"}: - return False - return True - - return False - - -a.binaries = [b for b in a.binaries if should_keep(b[0])] -a.datas = [d for d in a.datas if should_keep(d[0])] +a.binaries = [b for b in a.binaries if should_keep_linux_qt_asset(b[0])] +a.datas = [d for d in a.datas if should_keep_linux_qt_asset(d[0])] pyz = PYZ(a.pure) diff --git a/README.md b/README.md index 5e43ad2b..64382e84 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ For macOS Accessibility permissions and login-item notes, see the [macOS Setup G - **Logitech Options+ must NOT be running** (it conflicts with HID++ access) - **macOS only:** Accessibility permission required (System Settings → Privacy & Security → Accessibility) - **Linux only:** `xdotool` enables per-app profile switching on X11; `kdotool` additionally enables KDE Wayland detection -- **Linux only:** read access to `/dev/input/event*` and write access to `/dev/uinput` are required for remapping (you may need to add your user to the `input` group) +- **Linux only:** access to Logitech `/dev/hidraw*`, `/dev/input/event*`, and `/dev/uinput` is required. The Linux release includes `install-linux-permissions.sh` to install Mouser's udev rule. ### Steps @@ -198,6 +198,32 @@ pip install -r requirements.txt | `typing-extensions` | Backported typing helpers required by some dependencies | | `pyinstaller` | Build-time dependency for packaging standalone app bundles | +### Linux Device Permissions + +Mouser's Linux portable build runs as a normal user. HID++ features need +Logitech `hidraw` access, while button remapping needs readable +`/dev/input/event*` nodes and writable `/dev/uinput`. If Mouser sees the mouse +only when launched with `sudo`, install the bundled udev rule instead of +running the app as root: + +```bash +cd /path/to/extracted/Mouser +./install-linux-permissions.sh +``` + +When running from source, use the same helper from the checkout: + +```bash +./packaging/linux/install-linux-permissions.sh +``` + +The helper installs `69-mouser-logitech.rules`, reloads udev, and tries to load +`uinput`. Reconnect the mouse, fully quit Mouser, and launch it normally. If a +desktop launcher or autostart entry still cannot access the devices, log out and +back in once so the session receives fresh device ACLs. On systems without +logind/uaccess support, adding the user to the `input` group may still be +required as a distro-specific fallback. + ### Running ```bash @@ -504,16 +530,7 @@ The app has two pages accessible from a slim sidebar: - **Scroll inversion is experimental** — uses coalesced `PostMessage` injection to avoid LL hook deadlocks; may not work perfectly in all apps - **Admin not required** — but some games or elevated windows may not receive injected keystrokes - **Linux app detection is still limited** — X11 works via `xdotool`, KDE Wayland works via `kdotool`, and GNOME / other Wayland compositors still fall back to the default profile -- **Linux remapping needs device permissions** — Mouser must be able to read `/dev/input/event*` and write `/dev/uinput`. HID++ features (DPI, battery, Smart Shift) additionally require access to `/dev/hidraw*`, which most distros restrict to root by default. Create a udev rule file at `/etc/udev/rules.d/69-logitech-mouser.rules` with the following content: - ``` - # Logitech HID++ access for Mouser (USB + Bluetooth) - ACTION=="add", SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", TAG+="uaccess" - ACTION=="add", SUBSYSTEM=="hidraw", KERNELS=="0005:046D:*", TAG+="uaccess" - ``` - Then reload: - ```bash - sudo udevadm control --reload && sudo udevadm trigger - ``` +- **Linux remapping needs device permissions** — Mouser must be able to access Logitech `/dev/hidraw*`, read `/dev/input/event*`, and write `/dev/uinput`. Use the bundled `install-linux-permissions.sh` helper to install the udev rule, then reconnect the mouse and restart Mouser. ## Future Work diff --git a/README_CN.md b/README_CN.md index 3fdd542f..b58301d7 100644 --- a/README_CN.md +++ b/README_CN.md @@ -158,7 +158,7 @@ macOS 的辅助功能权限与登录项注意事项,请参见 [macOS 安装/ - **Accessibility / 辅助功能**(系统设置 → 隐私与安全性 → 辅助功能):用于 CGEventTap 拦截鼠标按键 - **Input Monitoring / 输入监控**(系统设置 → 隐私与安全性 → 输入监控):用于 HID++(手势键、DPI、Smart Shift、设备名) - **仅 Linux:** `xdotool` 用于 X11 的按应用 Profile 切换;`kdotool` 额外用于 KDE Wayland 检测 -- **仅 Linux:** 需要读取 `/dev/input/event*` 和写入 `/dev/uinput` 权限(可能需要把用户加入 `input` 组) +- **仅 Linux:** 需要访问 Logitech `/dev/hidraw*`、读取 `/dev/input/event*`、写入 `/dev/uinput`。Linux 发布包内附带 `install-linux-permissions.sh`,用于安装 Mouser 的 udev 规则。 ### 安装步骤 @@ -189,6 +189,30 @@ pip install -r requirements.txt | `pyobjc-framework-Cocoa` | macOS app detection and media-key support | | `evdev` | Linux mouse grab and virtual device forwarding (uinput) | +### Linux 设备权限 + +Mouser 的 Linux 便携版应以普通用户运行。HID++ 功能需要 Logitech +`hidraw` 权限,按键重映射需要读取 `/dev/input/event*` 并写入 +`/dev/uinput`。如果只有 `sudo ./Mouser` 能连接鼠标,请安装附带的 udev +规则,而不是长期以 root 运行应用: + +```bash +cd /path/to/extracted/Mouser +./install-linux-permissions.sh +``` + +从源码运行时,可使用仓库内的同一个脚本: + +```bash +./packaging/linux/install-linux-permissions.sh +``` + +该脚本会安装 `69-mouser-logitech.rules`、重新加载 udev,并尝试加载 +`uinput`。之后请重新连接鼠标,完全退出 Mouser,再以普通方式启动。如果 +桌面启动器或开机启动项仍无法访问设备,请注销并重新登录一次,让会话获得新的 +设备 ACL。某些不支持 logind/uaccess 的发行版可能仍需要将用户加入 `input` +组作为兜底方案。 + ### 运行方式 ```bash @@ -465,7 +489,7 @@ mouser/ - **Scroll inversion is experimental** — uses coalesced `PostMessage` injection to avoid LL hook deadlocks; may not work perfectly in all apps - **Admin not required** — but some games or elevated windows may not receive injected keystrokes - **Linux app detection is still limited** — X11 works via `xdotool`, KDE Wayland works via `kdotool`, and GNOME / other Wayland compositors still fall back to the default profile -- **Linux remapping needs device permissions** — Mouser must be able to read `/dev/input/event*` and write `/dev/uinput` +- **Linux remapping needs device permissions** — Mouser must be able to access Logitech `/dev/hidraw*`, read `/dev/input/event*`, and write `/dev/uinput`. Use the bundled `install-linux-permissions.sh` helper to install the udev rule, then reconnect the mouse and restart Mouser. ## 未来计划 diff --git a/build_support.py b/build_support.py new file mode 100644 index 00000000..5d8d85be --- /dev/null +++ b/build_support.py @@ -0,0 +1,116 @@ +"""Shared helpers for build-time packaging logic.""" + +from __future__ import annotations + +import os + + +LINUX_QT_KEEP = { + "Qt6Core", + "Qt6Gui", + "Qt6Widgets", + "Qt6Network", + "Qt6OpenGL", + "Qt6DBus", + "Qt6Qml", + "Qt6QmlCore", + "Qt6QmlMeta", + "Qt6QmlModels", + "Qt6QmlNetwork", + "Qt6QmlWorkerScript", + "Qt6Quick", + "Qt6QuickControls2", + "Qt6QuickControls2Impl", + "Qt6QuickControls2Basic", + "Qt6QuickControls2BasicStyleImpl", + "Qt6QuickControls2Material", + "Qt6QuickControls2MaterialStyleImpl", + "Qt6QuickTemplates2", + "Qt6QuickLayouts", + "Qt6QuickEffects", + "Qt6QuickShapes", + "Qt6ShaderTools", + "Qt6Svg", + "Qt6XcbQpa", + "icudata", + "icui18n", + "icuuc", + "pyside6", + "pyside6qml", + "shiboken6", +} + +LINUX_KEEP_PLUGIN_DIRS = { + "platforms", + "imageformats", + "styles", + "iconengines", + "platforminputcontexts", + "xcbglintegrations", + "platformthemes", + "tls", + "egldeviceintegrations", + "networkinformation", + "generic", + "wayland-decoration-client", + "wayland-graphics-integration-client", + "wayland-shell-integration", +} + +LINUX_KEEP_QML_TOP = {"QtCore", "QtQml", "QtQuick", "QtNetwork"} +LINUX_KEEP_QTQUICK = {"Controls", "Layouts", "Templates", "Window"} + + +def normalized_qt_library_stem(path: str) -> str: + """Normalize PySide/Qt shared-library filenames for whitelist matching.""" + + base = os.path.basename(path) + if ".so" in base: + stem = base.split(".so", 1)[0] + else: + stem = os.path.splitext(base)[0] + stem = stem.removeprefix("lib") + if stem.endswith(".abi3"): + stem = stem[:-5] + return stem + + +def should_keep_linux_qt_asset(path: str) -> bool: + normalized = path.replace("\\", "/") + lower = normalized.lower() + + if "PySide6" not in normalized and "pyside6" not in lower: + return True + + stem = normalized_qt_library_stem(normalized) + if stem in LINUX_QT_KEEP: + return True + + base = os.path.basename(normalized) + if ".abi3.so" in base: + return True + + plugin_marker = "/plugins/" + plugin_index = lower.find(plugin_marker) + if plugin_index != -1: + plugin_path = normalized[plugin_index + len(plugin_marker) :] + plugin_dir = plugin_path.split("/", 1)[0] + return plugin_dir in LINUX_KEEP_PLUGIN_DIRS and base != "libqpdf.so" + + qml_marker = "/qml/" + qml_index = lower.find(qml_marker) + if qml_index != -1: + qml_path = normalized[qml_index + len(qml_marker) :] + parts = [part for part in qml_path.split("/") if part] + if not parts: + return True + if parts[0] not in LINUX_KEEP_QML_TOP: + return False + if parts[0] == "QtQuick" and len(parts) > 1 and parts[1] not in LINUX_KEEP_QTQUICK: + return False + style_parts = {part.lower() for part in parts} + if style_parts & {"fusion", "imagine", "universal", "fluentwinui3", "ios", "macos"}: + return False + return True + + return False diff --git a/core/engine.py b/core/engine.py index 8a4551f7..879b8b50 100644 --- a/core/engine.py +++ b/core/engine.py @@ -16,6 +16,11 @@ BUTTON_TO_EVENTS, GESTURE_DIRECTION_BUTTONS, save_config, ) from core.app_detector import AppDetector +from core.linux_permissions import ( + linux_permission_log_message, + linux_permission_report, + linux_permission_status_message, +) from core.logi_devices import clamp_dpi HSCROLL_ACTION_COOLDOWN_S = 0.35 @@ -759,7 +764,17 @@ def reload_mappings(self): def set_enabled(self, enabled): self._enabled = bool(enabled) + def _emit_linux_permission_warning(self): + report = linux_permission_report() + log_message = linux_permission_log_message(report) + if log_message: + print(log_message) + status_message = linux_permission_status_message(report) + if status_message: + self._emit_status(status_message) + def start(self): + self._emit_linux_permission_warning() self.hook.start() self._app_detector.start() # Temporary safety-net: keep the old delayed replay path until the diff --git a/core/hid_gesture.py b/core/hid_gesture.py index c3de4f5b..bbd66d9c 100644 --- a/core/hid_gesture.py +++ b/core/hid_gesture.py @@ -11,6 +11,8 @@ Falls back gracefully if the package or device are unavailable. """ +import os +import stat import sys import queue import threading @@ -23,8 +25,22 @@ resolve_device, ) +_HID_MODULE_NAME = None try: - import hid as _hid + # The PyPI hidapi Linux wheels expose `hid` as the libusb backend and + # `hidraw` as the hidraw backend. Bluetooth HID devices only work through + # hidraw, so prefer it on Linux and fall back to `hid` for source builds + # where `hid` itself was compiled against hidraw. + if sys.platform.startswith("linux"): + try: + import hidraw as _hid + _HID_MODULE_NAME = "hidraw" + except ImportError: + import hid as _hid + _HID_MODULE_NAME = "hid" + else: + import hid as _hid + _HID_MODULE_NAME = "hid" HIDAPI_OK = True HIDAPI_IMPORT_ERROR = None # On macOS, allow non-exclusive HID access so the mouse keeps working @@ -34,7 +50,7 @@ HIDAPI_OK = False HIDAPI_IMPORT_ERROR = exc -# Support both "pip install hidapi" (hid.device) and "pip install hid" (hid.Device) +# Support both hidapi/hidraw-style modules (device) and "pip install hid" (Device). _HID_API_STYLE = None if HIDAPI_OK: if hasattr(_hid, 'device'): @@ -43,6 +59,63 @@ _HID_API_STYLE = "hid" +_LOG_ONCE_KEYS = set() + + +def _log_once(key, message): + if key in _LOG_ONCE_KEYS: + return + _LOG_ONCE_KEYS.add(key) + print(message) + + +def _device_path_display(path): + if isinstance(path, memoryview): + path = bytes(path) + if isinstance(path, bytes): + return path.decode("utf-8", errors="replace") + return str(path or "") + + +def _owner_name(uid): + try: + import pwd + return pwd.getpwuid(uid).pw_name + except Exception: + return str(uid) + + +def _group_name(gid): + try: + import grp + return grp.getgrgid(gid).gr_name + except Exception: + return str(gid) + + +def _format_linux_device_access(path): + if isinstance(path, memoryview): + path = bytes(path) + display = _device_path_display(path) + if not path: + return "path=-" + try: + st = os.stat(path) + except OSError as exc: + return f"path={display} stat_error={exc}" + + mode = stat.S_IMODE(st.st_mode) + can_read = os.access(path, os.R_OK) + can_write = os.access(path, os.W_OK) + can_rw = os.access(path, os.R_OK | os.W_OK) + return ( + f"path={display} mode={mode:04o} " + f"owner={_owner_name(st.st_uid)}({st.st_uid}) " + f"group={_group_name(st.st_gid)}({st.st_gid}) " + f"access=read:{can_read} write:{can_write} read_write:{can_rw}" + ) + + class _HidDeviceCompat: """Wraps the ``hid`` package Device to match the ``hidapi`` interface.""" @@ -464,6 +537,64 @@ def read(self, _size, timeout_ms=0): # ── Constants ───────────────────────────────────────────────────── LOGI_VID = 0x046D + +def _summarize_hid_infos(infos, limit=8): + parts = [] + for info in list(infos)[:limit]: + pid = int(info.get("product_id", 0) or 0) + usage_page = int(info.get("usage_page", 0) or 0) + usage = int(info.get("usage", 0) or 0) + product = info.get("product_string") or "?" + transport = info.get("transport") or "-" + parts.append( + f"PID=0x{pid:04X} UP=0x{usage_page:04X} " + f"usage=0x{usage:04X} transport={transport} product={product}" + ) + remaining = max(0, len(infos) - limit) + if remaining: + parts.append(f"... {remaining} more") + return "; ".join(parts) if parts else "-" + + +def _linux_logitech_hidraw_nodes(base="/sys/class/hidraw"): + if not sys.platform.startswith("linux"): + return [] + try: + entries = sorted(os.listdir(base)) + except OSError: + return [] + + nodes = [] + for entry in entries: + if not entry.startswith("hidraw"): + continue + uevent_path = os.path.join(base, entry, "device", "uevent") + try: + with open(uevent_path, "r", encoding="utf-8", errors="replace") as fh: + values = dict( + line.rstrip("\n").split("=", 1) + for line in fh + if "=" in line + ) + except OSError: + continue + + parts = values.get("HID_ID", "").split(":") + if len(parts) < 3: + continue + try: + vid = int(parts[1], 16) + pid = int(parts[2], 16) + except ValueError: + continue + if vid != LOGI_VID: + continue + + product = values.get("HID_NAME") or "?" + nodes.append(f"{entry} PID=0x{pid:04X} product={product}") + return nodes + + SHORT_ID = 0x10 # HID++ short report (7 bytes total) LONG_ID = 0x11 # HID++ long report (20 bytes total) SHORT_LEN = 7 @@ -624,6 +755,16 @@ def start(self): return False if not HIDAPI_OK and _MAC_NATIVE_OK: print("[HidGesture] hidapi unavailable; using native macOS HID backend only") + if HIDAPI_OK: + print( + "[HidGesture] HID module: " + f"{_HID_MODULE_NAME or '?'} API style: {_HID_API_STYLE or '?'}" + ) + if sys.platform.startswith("linux") and _HID_MODULE_NAME != "hidraw": + print( + "[HidGesture] Linux hidraw module is unavailable; Bluetooth " + "Logitech HID++ devices may not enumerate" + ) self._running = True self._thread = threading.Thread( target=self._main_loop, daemon=True, name="HidGesture") @@ -722,9 +863,60 @@ 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: + raw_infos = list(_hid.enumerate(LOGI_VID, 0)) + if not raw_infos: + _log_once( + f"hidapi-empty-{_HID_MODULE_NAME}", + "[HidGesture] " + f"{_HID_MODULE_NAME or 'hidapi'} enumerate(0x{LOGI_VID:04X}) " + "returned no Logitech HID interfaces" + ) + linux_nodes = _linux_logitech_hidraw_nodes() + if linux_nodes: + _log_once( + "linux-hidraw-logitech-present", + "[HidGesture] Linux sysfs sees Logitech hidraw nodes: " + f"{'; '.join(linux_nodes[:8])}. If hidapi still sees " + "none, check hidraw backend packaging and /dev/hidraw " + "permissions." + ) + elif sys.platform.startswith("linux"): + _log_once( + "linux-hidraw-logitech-missing", + "[HidGesture] Linux sysfs sees no Logitech hidraw " + "nodes for VID 0x046D; verify the mouse is connected " + "as an active HID device, not only paired." + ) + hidapi_candidates = 0 + fallback_candidates = 0 + for info in raw_infos: + pid = int(info.get("product_id", 0) or 0) + usage_page = int(info.get("usage_page", 0) or 0) + usage = int(info.get("usage", 0) or 0) + product = info.get("product_string") + if usage_page >= 0xFF00: add_info(dict(info, source="hidapi-enumerate")) + hidapi_candidates += 1 + continue + if resolve_device(product_id=pid, product_name=product): + print( + "[HidGesture] Accepting known Logitech device " + "without vendor usage metadata for fallback probe " + f"PID=0x{pid:04X} UP=0x{usage_page:04X} " + f"usage=0x{usage:04X} product={product or '?'}" + ) + add_info(dict(info, source="hidapi-enumerate-fallback")) + fallback_candidates += 1 + if raw_infos and not (hidapi_candidates or fallback_candidates): + print( + "[HidGesture] hidapi found Logitech interfaces, but none " + "matched vendor usage metadata or known-device fallback" + ) + _log_once( + f"hidapi-filtered-{_HID_MODULE_NAME}", + "[HidGesture] Filtered Logitech HID interfaces: " + f"{_summarize_hid_infos(raw_infos)}" + ) except Exception as exc: print(f"[HidGesture] hidapi enumerate error: {exc}") @@ -1449,9 +1641,10 @@ def _direct_device_first(info): transport = info.get("transport") source = info.get("source", "unknown") product = info.get("product_string") or "?" + path = _device_path_display(info.get("path")) print(f"[HidGesture] Candidate PID=0x{pid:04X} UP=0x{up:04X} " f"usage=0x{usage:04X} transport={transport or '-'} " - f"source={source} product={product}") + f"source={source} product={product} path={path or '-'}") for info in infos: pid = info.get("product_id", 0) @@ -1506,6 +1699,13 @@ def _direct_device_first(info): else: if not HIDAPI_OK: continue + if sys.platform.startswith("linux"): + path = open_info.get("path") + _log_once( + ("hid-path-access", _device_path_display(path)), + "[HidGesture] HID path access before open: " + f"{_format_linux_device_access(path)}", + ) if _HID_API_STYLE == "hidapi": d = _hid.device() d.open_path(open_info["path"]) diff --git a/core/linux_permissions.py b/core/linux_permissions.py new file mode 100644 index 00000000..d878950d --- /dev/null +++ b/core/linux_permissions.py @@ -0,0 +1,171 @@ +"""Linux device permission checks for Logitech HID++ access.""" + +from __future__ import annotations + +from dataclasses import dataclass +import glob +import os +import sys + + +LOGITECH_VENDOR_ID = 0x046D +INSTALL_HELPER = "install-linux-permissions.sh" + + +@dataclass(frozen=True) +class LinuxHidrawNode: + path: str + product_id: int | None = None + product_name: str = "" + bus_id: int | None = None + + +@dataclass(frozen=True) +class LinuxPermissionReport: + hidraw_nodes: tuple[LinuxHidrawNode, ...] + blocked_hidraw_paths: tuple[str, ...] + input_event_paths: tuple[str, ...] + input_events_readable: bool + uinput_path: str + uinput_writable: bool + uinput_exists: bool + + @property + def has_issue(self) -> bool: + return bool( + self.blocked_hidraw_paths + or (self.input_event_paths and not self.input_events_readable) + or not self.uinput_exists + or not self.uinput_writable + ) + + def issue_parts(self) -> list[str]: + parts: list[str] = [] + if self.blocked_hidraw_paths: + paths = ", ".join(self.blocked_hidraw_paths[:3]) + if len(self.blocked_hidraw_paths) > 3: + paths += ", ..." + parts.append(f"blocked hidraw access ({paths})") + if self.input_event_paths and not self.input_events_readable: + parts.append("no readable /dev/input/event* nodes") + if not self.uinput_exists: + parts.append("/dev/uinput is missing") + elif not self.uinput_writable: + parts.append("/dev/uinput is not writable") + return parts + + +def _parse_hid_id(value: str): + try: + bus_hex, vid_hex, pid_hex = value.split(":", 2) + return int(bus_hex, 16), int(vid_hex, 16), int(pid_hex, 16) + except (AttributeError, ValueError): + return None + + +def _read_uevent_props(path: str) -> dict[str, str]: + props: dict[str, str] = {} + try: + with open(path, encoding="utf-8") as fh: + for line in fh: + key, sep, value = line.strip().partition("=") + if sep: + props[key] = value + except OSError: + pass + return props + + +def logitech_hidraw_nodes( + *, + sysfs_base: str = "/sys/class/hidraw", + dev_base: str = "/dev", +) -> tuple[LinuxHidrawNode, ...]: + """Return Logitech hidraw nodes visible through sysfs.""" + + try: + entries = sorted(os.listdir(sysfs_base)) + except OSError: + return () + + nodes: list[LinuxHidrawNode] = [] + for entry in entries: + if not entry.startswith("hidraw"): + continue + props = _read_uevent_props( + os.path.join(sysfs_base, entry, "device", "uevent") + ) + parsed = _parse_hid_id(props.get("HID_ID", "")) + if not parsed: + continue + bus_id, vendor_id, product_id = parsed + if vendor_id != LOGITECH_VENDOR_ID: + continue + nodes.append( + LinuxHidrawNode( + path=os.path.join(dev_base, entry), + product_id=product_id, + product_name=props.get("HID_NAME", ""), + bus_id=bus_id, + ) + ) + return tuple(nodes) + + +def linux_permission_report( + *, + sysfs_base: str = "/sys/class/hidraw", + dev_base: str = "/dev", + input_event_glob: str = "/dev/input/event*", + uinput_path: str = "/dev/uinput", +) -> LinuxPermissionReport | None: + """Inspect Linux device-node access when a Logitech hidraw node is visible.""" + + if not sys.platform.startswith("linux"): + return None + + hidraw_nodes = logitech_hidraw_nodes(sysfs_base=sysfs_base, dev_base=dev_base) + if not hidraw_nodes: + return LinuxPermissionReport((), (), (), True, uinput_path, True, True) + + blocked_hidraw_paths = tuple( + node.path + for node in hidraw_nodes + if not os.access(node.path, os.R_OK | os.W_OK) + ) + input_event_paths = tuple(sorted(glob.glob(input_event_glob))) + input_events_readable = ( + not input_event_paths + or any(os.access(path, os.R_OK) for path in input_event_paths) + ) + uinput_exists = os.path.exists(uinput_path) + uinput_writable = uinput_exists and os.access(uinput_path, os.W_OK) + + return LinuxPermissionReport( + hidraw_nodes=hidraw_nodes, + blocked_hidraw_paths=blocked_hidraw_paths, + input_event_paths=input_event_paths, + input_events_readable=input_events_readable, + uinput_path=uinput_path, + uinput_writable=uinput_writable, + uinput_exists=uinput_exists, + ) + + +def linux_permission_status_message(report: LinuxPermissionReport | None) -> str: + if report is None or not report.has_issue: + return "" + return ( + "Linux permissions may block Mouser. Run " + f"{INSTALL_HELPER}, reconnect the mouse, then restart Mouser." + ) + + +def linux_permission_log_message(report: LinuxPermissionReport | None) -> str: + if report is None or not report.has_issue: + return "" + return ( + "[LinuxPermissions] Device access issue: " + + "; ".join(report.issue_parts()) + + f". Install the bundled udev rule with {INSTALL_HELPER}." + ) diff --git a/core/mouse_hook.py b/core/mouse_hook.py index b42aa5aa..24d49c5e 100644 --- a/core/mouse_hook.py +++ b/core/mouse_hook.py @@ -6,6 +6,9 @@ """ import queue +import glob +import os +import stat import sys import threading import time @@ -57,6 +60,60 @@ def _format_debug_details(raw_data): return f" value={raw_data}" +_LOG_ONCE_KEYS = set() + + +def _log_once(key, message): + if key in _LOG_ONCE_KEYS: + return + _LOG_ONCE_KEYS.add(key) + print(message) + + +def _owner_name(uid): + try: + import pwd + return pwd.getpwuid(uid).pw_name + except Exception: + return str(uid) + + +def _group_name(gid): + try: + import grp + return grp.getgrgid(gid).gr_name + except Exception: + return str(gid) + + +def _format_linux_device_access(path): + if not path: + return "path=-" + try: + st = os.stat(path) + except OSError as exc: + return f"path={path} stat_error={exc}" + + mode = stat.S_IMODE(st.st_mode) + can_read = os.access(path, os.R_OK) + can_write = os.access(path, os.W_OK) + can_rw = os.access(path, os.R_OK | os.W_OK) + return ( + f"path={path} mode={mode:04o} " + f"owner={_owner_name(st.st_uid)}({st.st_uid}) " + f"group={_group_name(st.st_gid)}({st.st_gid}) " + f"access=read:{can_read} write:{can_write} read_write:{can_rw}" + ) + + +def _format_linux_device_access_list(paths, limit=8): + details = [_format_linux_device_access(path) for path in list(paths)[:limit]] + remaining = max(0, len(paths) - limit) + if remaining: + details.append(f"... {remaining} more") + return "; ".join(details) if details else "-" + + # ================================================================== # Windows implementation # ================================================================== @@ -1807,7 +1864,10 @@ def stop(self): _EVDEV_OK = False print("[MouseHook] python-evdev not installed — pip install evdev") - from core.logi_devices import build_evdev_connected_device_info + from core.logi_devices import ( + build_evdev_connected_device_info, + resolve_device as _resolve_logi_device, + ) _LOGI_VENDOR = 0x046D @@ -2278,10 +2338,50 @@ def _on_hid_disconnect(self): def _find_mouse_device(self): """Find the best Logitech mouse evdev device.""" logi_mice = [] - for path in _evdev_mod.list_devices(): + try: + paths = list(_evdev_mod.list_devices()) + except Exception as exc: + _log_once( + ("evdev-list-error", type(exc).__name__, str(exc)), + f"[MouseHook] Cannot list evdev devices: {exc}" + ) + return None + if not paths: + event_paths = sorted(glob.glob("/dev/input/event*")) + if event_paths: + _log_once( + "evdev-empty-fallback-event-nodes", + "[MouseHook] evdev returned no input devices; falling " + "back to visible /dev/input/event* nodes: " + f"{_format_linux_device_access_list(event_paths)}" + ) + paths = event_paths + else: + _log_once( + "evdev-no-input-devices", + "[MouseHook] evdev returned no input devices and no " + "/dev/input/event* nodes are visible; remapping needs " + f"/dev/input/event* access. " + f"{_format_linux_device_access('/dev/input')}" + ) + + for path in paths: try: dev = _InputDevice(path) - except Exception: + except PermissionError as exc: + _log_once( + ("evdev-open-permission", path), + f"[MouseHook] Permission denied opening {path}: {exc}. " + f"{_format_linux_device_access(path)}. " + "Add the user to a group with /dev/input/event* access " + "or install a udev rule." + ) + continue + except Exception as exc: + _log_once( + ("evdev-open-error", path, type(exc).__name__, str(exc)), + f"[MouseHook] Cannot open evdev device {path}: {exc}" + ) continue try: caps = dev.capabilities(absinfo=False) @@ -2301,7 +2401,19 @@ def _find_mouse_device(self): has_side = bool(key_caps.intersection({ _ecodes.BTN_SIDE, _ecodes.BTN_EXTRA, })) - except Exception: + except PermissionError as exc: + _log_once( + ("evdev-capabilities-permission", dev.path), + f"[MouseHook] Permission denied reading capabilities for " + f"{dev.path}: {exc}" + ) + dev.close() + continue + except Exception as exc: + _log_once( + ("evdev-capabilities-error", dev.path, type(exc).__name__, str(exc)), + f"[MouseHook] Cannot inspect evdev device {dev.path}: {exc}" + ) dev.close() continue if dev.info.vendor == _LOGI_VENDOR: @@ -2324,7 +2436,26 @@ def _find_mouse_device(self): ) dev.close() - ordered = sorted(logi_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 + info = getattr(dev, "info", None) + spec = _resolve_logi_device( + product_id=getattr(info, "product", None), + product_name=getattr(dev, "name", None), + ) + return ( + int(spec is not None), + int(has_side), + _event_num(dev), + ) + + ordered = sorted(logi_mice, key=_sort_key, reverse=True) if ordered: chosen = ordered[0][0] for dev, _ in ordered[1:]: @@ -2332,6 +2463,12 @@ def _find_mouse_device(self): print(f"[MouseHook] Found mouse: {chosen.name} ({chosen.path}) " f"vendor=0x{chosen.info.vendor:04X}") return chosen + _log_once( + "evdev-no-logitech-mouse", + "[MouseHook] No Logitech evdev mouse found; UI connection state " + "and remapping require a Logitech mouse visible under " + "/dev/input/event* with vendor 0x046D" + ) return None def _setup_evdev(self): diff --git a/packaging/linux/69-mouser-logitech.rules b/packaging/linux/69-mouser-logitech.rules new file mode 100644 index 00000000..6812e950 --- /dev/null +++ b/packaging/linux/69-mouser-logitech.rules @@ -0,0 +1,18 @@ +# Mouser Linux device access for Logitech HID++ mice. +# +# HID++ features use hidraw. Button remapping uses evdev input events and +# uinput. uaccess grants the active local desktop session access through +# logind without making Mouser run as root. + +# Logitech HID++ over USB receivers and Bluetooth. +KERNEL=="hidraw*", SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", TAG+="uaccess" +KERNEL=="hidraw*", SUBSYSTEM=="hidraw", KERNELS=="0003:046D:*", TAG+="uaccess" +KERNEL=="hidraw*", SUBSYSTEM=="hidraw", KERNELS=="0005:046D:*", TAG+="uaccess" + +# Logitech evdev mouse nodes used for button/gesture remapping. +KERNEL=="event*", SUBSYSTEM=="input", ATTRS{idVendor}=="046d", TAG+="uaccess" +KERNEL=="event*", SUBSYSTEM=="input", KERNELS=="0003:046D:*", TAG+="uaccess" +KERNEL=="event*", SUBSYSTEM=="input", KERNELS=="0005:046D:*", TAG+="uaccess" + +# Virtual input device used to forward non-blocked mouse events. +KERNEL=="uinput", SUBSYSTEM=="misc", TAG+="uaccess", OPTIONS+="static_node=uinput" diff --git a/packaging/linux/install-linux-permissions.sh b/packaging/linux/install-linux-permissions.sh new file mode 100755 index 00000000..bba41d0c --- /dev/null +++ b/packaging/linux/install-linux-permissions.sh @@ -0,0 +1,43 @@ +#!/bin/sh +set -eu + +RULE_NAME="69-mouser-logitech.rules" +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +SCRIPT_PATH="$SCRIPT_DIR/$(basename -- "$0")" +RULE_SOURCE="$SCRIPT_DIR/$RULE_NAME" +RULE_TARGET="/etc/udev/rules.d/$RULE_NAME" + +if [ ! -f "$RULE_SOURCE" ]; then + echo "Missing udev rule file: $RULE_SOURCE" >&2 + exit 1 +fi + +if [ "$(id -u)" -ne 0 ]; then + if command -v pkexec >/dev/null 2>&1; then + exec pkexec /bin/sh "$SCRIPT_PATH" + fi + if command -v sudo >/dev/null 2>&1; then + exec sudo /bin/sh "$SCRIPT_PATH" + fi + echo "This installer needs administrator privileges." >&2 + echo "Run: sudo /bin/sh \"$SCRIPT_PATH\"" >&2 + exit 1 +fi + +install -m 0644 "$RULE_SOURCE" "$RULE_TARGET" + +if command -v modprobe >/dev/null 2>&1; then + modprobe uinput 2>/dev/null || true +fi + +if command -v udevadm >/dev/null 2>&1; then + udevadm control --reload-rules + udevadm trigger + udevadm settle 2>/dev/null || true +else + echo "udevadm was not found; installed the rule but could not reload udev." >&2 +fi + +echo "Installed $RULE_TARGET" +echo "Reconnect your Logitech mouse, fully quit Mouser, then launch Mouser again." +echo "If desktop launch still cannot access the mouse, log out and back in once." diff --git a/tests/test_build_support.py b/tests/test_build_support.py new file mode 100644 index 00000000..346ecb8b --- /dev/null +++ b/tests/test_build_support.py @@ -0,0 +1,69 @@ +import os +import stat +import unittest + +from build_support import normalized_qt_library_stem, should_keep_linux_qt_asset + + +class LinuxQtAssetFilterTests(unittest.TestCase): + def test_normalizes_versioned_abi3_shared_library_names(self): + cases = { + "/tmp/_internal/PySide6/libpyside6.abi3.so.6.11": "pyside6", + "/tmp/_internal/PySide6/libpyside6qml.abi3.so.6.11": "pyside6qml", + "/tmp/_internal/PySide6/libshiboken6.abi3.so.6.11": "shiboken6", + } + + for path, expected in cases.items(): + with self.subTest(path=path): + self.assertEqual(normalized_qt_library_stem(path), expected) + + def test_keeps_versioned_pyside6_abi3_runtime_library(self): + runtime_paths = [ + "/tmp/_internal/PySide6/libpyside6.abi3.so.6.11", + "/tmp/_internal/PySide6/libpyside6qml.abi3.so.6.11", + "/tmp/_internal/PySide6/libshiboken6.abi3.so.6.11", + ] + + for path in runtime_paths: + with self.subTest(path=path): + self.assertTrue(should_keep_linux_qt_asset(path)) + + def test_keeps_core_qt_dependency_libraries(self): + dependency_paths = [ + "/tmp/_internal/PySide6/Qt/lib/libQt6DBus.so.6", + "/tmp/_internal/PySide6/Qt/lib/libQt6XcbQpa.so.6", + "/tmp/_internal/PySide6/Qt/lib/libicui18n.so.73", + "/tmp/_internal/PySide6/Qt/lib/libicuuc.so.73", + "/tmp/_internal/PySide6/Qt/lib/libicudata.so.73", + ] + + for path in dependency_paths: + with self.subTest(path=path): + self.assertTrue(should_keep_linux_qt_asset(path)) + + def test_drops_unneeded_qt_webengine_binary(self): + path = "/tmp/_internal/PySide6/Qt/lib/libQt6WebEngineCore.so.6" + self.assertFalse(should_keep_linux_qt_asset(path)) + + def test_drops_optional_qml_style_family(self): + path = "/tmp/_internal/PySide6/qml/QtQuick/Controls/Fusion/Button.qml" + self.assertFalse(should_keep_linux_qt_asset(path)) + + +class LinuxPermissionPackagingTests(unittest.TestCase): + def test_linux_permission_helper_files_exist(self): + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + helper = os.path.join( + root, "packaging", "linux", "install-linux-permissions.sh" + ) + rules = os.path.join( + root, "packaging", "linux", "69-mouser-logitech.rules" + ) + + self.assertTrue(os.path.isfile(helper)) + self.assertTrue(os.stat(helper).st_mode & stat.S_IXUSR) + self.assertTrue(os.path.isfile(rules)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hid_gesture.py b/tests/test_hid_gesture.py index 6d78cd73..a2aeb1a8 100644 --- a/tests/test_hid_gesture.py +++ b/tests/test_hid_gesture.py @@ -1,3 +1,7 @@ +import importlib +import os +import sys +import tempfile import unittest from types import SimpleNamespace from unittest.mock import Mock, patch @@ -5,6 +9,78 @@ from core import hid_gesture +class HidModuleImportTests(unittest.TestCase): + def tearDown(self): + importlib.reload(hid_gesture) + + def test_linux_prefers_hidraw_module_when_available(self): + fake_hidraw = SimpleNamespace(device=object, enumerate=lambda *_args: []) + fake_hid = SimpleNamespace(device=object, enumerate=lambda *_args: []) + + with ( + patch.object(sys, "platform", "linux"), + patch.dict(sys.modules, {"hidraw": fake_hidraw, "hid": fake_hid}), + ): + module = importlib.reload(hid_gesture) + + self.assertTrue(module.HIDAPI_OK) + self.assertIs(module._hid, fake_hidraw) + self.assertEqual(module._HID_MODULE_NAME, "hidraw") + + def test_linux_falls_back_to_hid_when_hidraw_module_is_absent(self): + fake_hid = SimpleNamespace(device=object, enumerate=lambda *_args: []) + + with ( + patch.object(sys, "platform", "linux"), + patch.dict(sys.modules, {"hidraw": None, "hid": fake_hid}), + ): + module = importlib.reload(hid_gesture) + + self.assertTrue(module.HIDAPI_OK) + self.assertIs(module._hid, fake_hid) + self.assertEqual(module._HID_MODULE_NAME, "hid") + + +class HidLinuxDiagnosticsTests(unittest.TestCase): + def test_linux_logitech_hidraw_nodes_reads_sysfs_uevent(self): + with tempfile.TemporaryDirectory() as tmp: + node_dir = os.path.join(tmp, "hidraw3", "device") + os.makedirs(node_dir) + with open(os.path.join(node_dir, "uevent"), "w", encoding="utf-8") as fh: + fh.write("HID_ID=0005:0000046D:0000B034\n") + fh.write("HID_NAME=MX Master 3S\n") + + with patch.object(sys, "platform", "linux"): + nodes = hid_gesture._linux_logitech_hidraw_nodes(base=tmp) + + self.assertEqual(nodes, ["hidraw3 PID=0xB034 product=MX Master 3S"]) + + def test_summarize_hid_infos_includes_candidate_metadata(self): + summary = hid_gesture._summarize_hid_infos([ + { + "product_id": 0xB034, + "usage_page": 0x0000, + "usage": 0x0001, + "transport": "Bluetooth Low Energy", + "product_string": "MX Master 3S", + } + ]) + + self.assertIn("PID=0xB034", summary) + self.assertIn("UP=0x0000", summary) + self.assertIn("product=MX Master 3S", summary) + + def test_format_linux_device_access_includes_path_permissions_and_access(self): + with tempfile.NamedTemporaryFile() as fh: + summary = hid_gesture._format_linux_device_access(fh.name.encode()) + + self.assertIn("path=", summary) + self.assertIn("mode=", summary) + self.assertIn("owner=", summary) + self.assertIn("group=", summary) + self.assertIn("access=read:", summary) + + class HidBackendPreferenceTests(unittest.TestCase): def test_default_backend_uses_auto_on_macos(self): self.assertEqual(hid_gesture._default_backend_preference("darwin"), "auto") @@ -57,6 +133,97 @@ def __init__(self): self.close = Mock() +class HidEnumerationFallbackTests(unittest.TestCase): + @staticmethod + def _printed_messages(print_mock): + return [ + " ".join(str(arg) for arg in call.args) + for call in print_mock.call_args_list + ] + + def test_try_connect_accepts_known_device_without_usage_metadata(self): + listener = hid_gesture.HidGestureListener() + info = { + "product_id": 0xB034, + "usage_page": 0x0000, + "usage": 0x0000, + "transport": "Bluetooth Low Energy", + "product_string": "MX Master 3S", + "path": b"/dev/hidraw-test", + } + fake_dev = _FakeHidDevice() + + def fake_find_feature(feature_id): + if feature_id == hid_gesture.FEAT_REPROG_V4: + return 0x10 + return None + + with ( + patch.object(hid_gesture, "HIDAPI_OK", True), + patch.object(hid_gesture, "_BACKEND_PREFERENCE", "hidapi"), + patch.object(hid_gesture, "_HID_API_STYLE", "hidapi"), + patch.object( + hid_gesture, + "_hid", + SimpleNamespace( + enumerate=lambda vid, pid: [info], + device=lambda: fake_dev, + ), + create=True, + ), + patch.object(listener, "_find_feature", side_effect=fake_find_feature), + patch.object(listener, "_discover_reprog_controls", return_value=[]), + patch.object(listener, "_divert", return_value=True), + patch.object(listener, "_divert_extras"), + patch("builtins.print") as print_mock, + ): + self.assertTrue(listener._try_connect()) + + messages = self._printed_messages(print_mock) + self.assertTrue( + any( + "Accepting known Logitech device without vendor usage metadata" + in message + for message in messages + ) + ) + self.assertEqual(listener.connected_device.display_name, "MX Master 3S") + + def test_vendor_hid_infos_logs_when_logitech_interfaces_are_filtered_out(self): + info = { + "product_id": 0x1234, + "usage_page": 0x0000, + "usage": 0x0000, + "transport": "Bluetooth Low Energy", + "product_string": "Unknown Logitech", + "path": b"/dev/hidraw-test", + } + + with ( + patch.object(hid_gesture, "HIDAPI_OK", True), + patch.object(hid_gesture, "_BACKEND_PREFERENCE", "hidapi"), + patch.object( + hid_gesture, + "_hid", + SimpleNamespace(enumerate=lambda vid, pid: [info]), + create=True, + ), + patch("builtins.print") as print_mock, + ): + infos = hid_gesture.HidGestureListener._vendor_hid_infos() + + self.assertEqual(infos, []) + messages = self._printed_messages(print_mock) + self.assertTrue( + any( + "hidapi found Logitech interfaces, but none matched vendor " + "usage metadata or known-device fallback" + in message + for message in messages + ) + ) + + class HidDiscoveryDiagnosticsTests(unittest.TestCase): def _make_listener(self): listener = hid_gesture.HidGestureListener() @@ -114,6 +281,36 @@ def test_try_connect_logs_missing_reprog_when_open_succeeds_for_all_dev_indices( ) fake_dev.close.assert_called_once_with() + def test_try_connect_logs_linux_hid_path_access_before_open(self): + listener, info = self._make_listener() + fake_dev = _FakeHidDevice() + fake_dev.open_path.side_effect = OSError("open failed") + + with tempfile.NamedTemporaryFile() as fh: + info = dict(info, path=fh.name.encode()) + with ( + patch.object(sys, "platform", "linux"), + patch.object(listener, "_vendor_hid_infos", return_value=[info]), + patch.object(hid_gesture, "HIDAPI_OK", True), + patch.object(hid_gesture, "_BACKEND_PREFERENCE", "hidapi"), + patch.object(hid_gesture, "_HID_API_STYLE", "hidapi"), + patch.object( + hid_gesture, + "_hid", + SimpleNamespace(device=lambda: fake_dev), + create=True, + ), + patch("builtins.print") as print_mock, + ): + hid_gesture._LOG_ONCE_KEYS.clear() + self.assertFalse(listener._try_connect()) + + messages = self._printed_messages(print_mock) + self.assertTrue( + any("HID path access before open:" in message for message in messages) + ) + self.assertTrue(any("access=read:" in message for message in messages)) + def test_try_connect_success_path_keeps_existing_reprog_discovery_diagnostics(self): listener, info = self._make_listener() fake_dev = _FakeHidDevice() diff --git a/tests/test_linux_permissions.py b/tests/test_linux_permissions.py new file mode 100644 index 00000000..bb3e084b --- /dev/null +++ b/tests/test_linux_permissions.py @@ -0,0 +1,100 @@ +import os +import sys +import tempfile +import unittest +from unittest.mock import patch + +from core import linux_permissions + + +def _write_logitech_hidraw(sysfs_base, name="hidraw3"): + device_dir = os.path.join(sysfs_base, name, "device") + os.makedirs(device_dir) + with open(os.path.join(device_dir, "uevent"), "w", encoding="utf-8") as fh: + fh.write("HID_ID=0005:0000046D:0000B034\n") + fh.write("HID_NAME=Logitech MX Master 3S\n") + + +class LinuxPermissionTests(unittest.TestCase): + def test_logitech_hidraw_nodes_reads_sysfs(self): + with tempfile.TemporaryDirectory() as tmp: + sysfs_base = os.path.join(tmp, "sys", "class", "hidraw") + dev_base = os.path.join(tmp, "dev") + _write_logitech_hidraw(sysfs_base) + + nodes = linux_permissions.logitech_hidraw_nodes( + sysfs_base=sysfs_base, + dev_base=dev_base, + ) + + self.assertEqual(len(nodes), 1) + self.assertEqual(nodes[0].path, os.path.join(dev_base, "hidraw3")) + self.assertEqual(nodes[0].product_id, 0xB034) + self.assertEqual(nodes[0].product_name, "Logitech MX Master 3S") + + def test_report_warns_when_visible_logitech_devices_are_inaccessible(self): + with tempfile.TemporaryDirectory() as tmp: + sysfs_base = os.path.join(tmp, "sys", "class", "hidraw") + dev_base = os.path.join(tmp, "dev") + input_dir = os.path.join(dev_base, "input") + os.makedirs(input_dir) + _write_logitech_hidraw(sysfs_base) + + hidraw_path = os.path.join(dev_base, "hidraw3") + event_path = os.path.join(input_dir, "event0") + uinput_path = os.path.join(dev_base, "uinput") + for path in (hidraw_path, event_path, uinput_path): + with open(path, "w", encoding="utf-8"): + pass + + blocked = {hidraw_path, event_path, uinput_path} + + def fake_access(path, _mode): + return path not in blocked + + with ( + patch.object(sys, "platform", "linux"), + patch.object(linux_permissions.os, "access", side_effect=fake_access), + ): + report = linux_permissions.linux_permission_report( + sysfs_base=sysfs_base, + dev_base=dev_base, + input_event_glob=os.path.join(input_dir, "event*"), + uinput_path=uinput_path, + ) + + self.assertIsNotNone(report) + self.assertTrue(report.has_issue) + self.assertEqual(report.blocked_hidraw_paths, (hidraw_path,)) + self.assertFalse(report.input_events_readable) + self.assertFalse(report.uinput_writable) + self.assertIn( + linux_permissions.INSTALL_HELPER, + linux_permissions.linux_permission_status_message(report), + ) + self.assertIn( + "blocked hidraw access", + linux_permissions.linux_permission_log_message(report), + ) + + def test_report_stays_quiet_when_no_logitech_hidraw_node_is_visible(self): + with tempfile.TemporaryDirectory() as tmp: + with patch.object(sys, "platform", "linux"): + report = linux_permissions.linux_permission_report( + sysfs_base=tmp, + dev_base=os.path.join(tmp, "dev"), + input_event_glob=os.path.join(tmp, "event*"), + uinput_path=os.path.join(tmp, "uinput"), + ) + + self.assertIsNotNone(report) + self.assertFalse(report.has_issue) + self.assertEqual(linux_permissions.linux_permission_status_message(report), "") + + def test_report_is_disabled_off_linux(self): + with patch.object(sys, "platform", "darwin"): + self.assertIsNone(linux_permissions.linux_permission_report()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mouse_hook.py b/tests/test_mouse_hook.py index d31f9790..299aa104 100644 --- a/tests/test_mouse_hook.py +++ b/tests/test_mouse_hook.py @@ -8,11 +8,11 @@ class _FakeEvdevDevice: - def __init__(self, *, name, path, vendor, capabilities, fd=11): + def __init__(self, *, name, path, vendor, capabilities, product=0, fd=11): self.name = name self.path = path self.fd = fd - self.info = SimpleNamespace(vendor=vendor) + self.info = SimpleNamespace(vendor=vendor, product=product) self._capabilities = capabilities self.grab = Mock() self.ungrab = Mock() @@ -127,6 +127,37 @@ def test_find_mouse_device_prefers_logitech_candidates(self): self.assertTrue(generic.close.called) self.assertFalse(logi.close.called) + def test_find_mouse_device_prefers_known_supported_logitech_model(self): + module = self._reload_for_linux() + legacy = _FakeEvdevDevice( + name="Logitech Performance MX", + path="/dev/input/event11", + vendor=module._LOGI_VENDOR, + product=0x101A, + capabilities=self._fake_caps(module), + ) + modern = _FakeEvdevDevice( + name="Logitech MX Master 3S", + path="/dev/input/event22", + vendor=module._LOGI_VENDOR, + product=0xB034, + capabilities=self._fake_caps(module), + ) + + patches = self._patch_evdev_lookup( + module, + { + legacy.path: legacy, + modern.path: modern, + }, + ) + with patches[0], patches[1]: + chosen = module.MouseHook()._find_mouse_device() + + self.assertIs(chosen, modern) + self.assertTrue(legacy.close.called) + self.assertFalse(modern.close.called) + def test_find_mouse_device_returns_none_when_only_non_logitech_candidates_exist(self): module = self._reload_for_linux() generic_one = _FakeEvdevDevice( @@ -154,6 +185,54 @@ def test_find_mouse_device_returns_none_when_only_non_logitech_candidates_exist( self.assertIsNone(chosen) + def test_find_mouse_device_logs_permission_errors_opening_evdev(self): + module = self._reload_for_linux() + fake_evdev_mod = SimpleNamespace(list_devices=lambda: ["/dev/input/event0"]) + + with ( + patch.object(module, "_evdev_mod", fake_evdev_mod), + patch.object(module, "_InputDevice", side_effect=PermissionError("denied")), + patch("builtins.print") as print_mock, + ): + chosen = module.MouseHook()._find_mouse_device() + + self.assertIsNone(chosen) + messages = [ + " ".join(str(arg) for arg in call.args) + for call in print_mock.call_args_list + ] + self.assertTrue( + any("Permission denied opening /dev/input/event0" in msg for msg in messages) + ) + + def test_find_mouse_device_falls_back_to_glob_when_evdev_list_is_empty(self): + module = self._reload_for_linux() + logi = _FakeEvdevDevice( + name="MX Master 3S", + path="/dev/input/event4", + vendor=module._LOGI_VENDOR, + capabilities=self._fake_caps(module), + ) + fake_evdev_mod = SimpleNamespace(list_devices=lambda: []) + + with ( + patch.object(module, "_evdev_mod", fake_evdev_mod), + patch.object(module.glob, "glob", return_value=[logi.path]), + patch.object(module, "_InputDevice", return_value=logi), + patch("builtins.print") as print_mock, + ): + module._LOG_ONCE_KEYS.clear() + chosen = module.MouseHook()._find_mouse_device() + + self.assertIs(chosen, logi) + messages = [ + " ".join(str(arg) for arg in call.args) + for call in print_mock.call_args_list + ] + self.assertTrue( + any("falling back to visible /dev/input/event* nodes" in msg for msg in messages) + ) + def test_hid_reconnect_requests_rescan_for_fallback_evdev_device(self): module = self._reload_for_linux() hook = module.MouseHook() From 21d24c10d68e5b169d9d840d8249d2bef6acc595 Mon Sep 17 00:00:00 2001 From: lucamene04 Date: Thu, 30 Apr 2026 00:20:58 +0200 Subject: [PATCH 11/17] chore: release v3.6.0 --- core/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/version.py b/core/version.py index fdf7c780..bd59d6a2 100644 --- a/core/version.py +++ b/core/version.py @@ -9,7 +9,7 @@ import sys -_DEFAULT_APP_VERSION = "3.5.3" +_DEFAULT_APP_VERSION = "3.6.0" _BUILD_INFO_FILENAME = "mouser_build_info.json" _REPO_ROOT = Path(__file__).resolve().parent.parent From 7dd0a0deb5c7a858e7d026124f63b68c8b7c9233 Mon Sep 17 00:00:00 2001 From: hieshima <62245675+hieshima@users.noreply.github.com> Date: Thu, 30 Apr 2026 07:34:07 -0600 Subject: [PATCH 12/17] refactor: split mouse hook platform modules (#120) --- README.md | 142 +- core/mouse_hook.py | 2825 +---------------------------- core/mouse_hook_base.py | 246 +++ core/mouse_hook_contract.py | 38 + core/mouse_hook_linux.py | 750 ++++++++ core/mouse_hook_macos.py | 642 +++++++ core/mouse_hook_stub.py | 24 + core/mouse_hook_types.py | 43 + core/mouse_hook_windows.py | 818 +++++++++ tests/test_mouse_hook_contract.py | 58 + 10 files changed, 2735 insertions(+), 2851 deletions(-) create mode 100644 core/mouse_hook_base.py create mode 100644 core/mouse_hook_contract.py create mode 100644 core/mouse_hook_linux.py create mode 100644 core/mouse_hook_macos.py create mode 100644 core/mouse_hook_stub.py create mode 100644 core/mouse_hook_types.py create mode 100644 core/mouse_hook_windows.py create mode 100644 tests/test_mouse_hook_contract.py diff --git a/README.md b/README.md index 64382e84..83e6a60d 100644 --- a/README.md +++ b/README.md @@ -348,9 +348,11 @@ The output is in `dist/Mouser/`. Zip that entire folder and distribute it. └─────────────┘ ``` -### Mouse Hook (`mouse_hook.py`) +### Mouse Hook (`mouse_hook.py` + `mouse_hook_*.py`) -Mouser uses a platform-specific mouse hook behind a shared `MouseHook` abstraction: +Mouser uses a shared `MouseHook` façade in `core/mouse_hook.py`, with the +platform implementations split into dedicated modules behind the same +abstraction: - **Windows** — `SetWindowsHookExW` with `WH_MOUSE_LL` on a dedicated background thread, plus Raw Input for extra mouse data - **macOS** — `CGEventTap` for mouse interception and Quartz events for key simulation @@ -410,95 +412,53 @@ All settings are stored in `%APPDATA%\Mouser\config.json` (Windows) or `~/Librar ``` mouser/ -├── build_macos_app.sh # macOS bundle build + icon/signing flow -├── build.bat # Windows build helper with optional venv activation -├── CONTRIBUTING_DEVICES.md # Guide for adding/testing new device definitions -├── core # Backend logic -│   ├── __init__.py # Core package marker -│   ├── accessibility.py # macOS Accessibility trust checks -│   ├── app_catalog.py # Installed-app resolution + alias catalog helpers -│   ├── app_detector.py # Foreground app polling -│   ├── config_validation.py # Schema-first config normalization + validation helpers -│   ├── config.py # Config manager (JSON load/save/migrate) -│   ├── device_layouts.py # Device-family layout registry for QML overlays -│   ├── engine.py # Core engine — wires hook ↔ simulator ↔ config -│   ├── hid_gesture.py # HID++ 2.0 gesture button divert (Bluetooth + Logi Bolt) -│   ├── key_simulator.py # Platform-specific action simulator -│   ├── log_setup.py # Application logging setup -│   ├── logi_devices.py # Known Logitech device catalog + connected-device metadata -│   ├── mouse_hook.py # Low-level mouse hook + HID++ gesture listener -│   ├── startup.py # Cross-platform login startup (Windows registry + macOS LaunchAgent) -│   └── version.py # Build/version metadata helpers -├── DEVELOPMENT.md # Developer-oriented architecture and workflow notes -├── images # Raster/vector UI assets and app icons -│   ├── AppIcon.icns # Committed macOS app-bundle icon -│   ├── chrom.png # App icon: Chrome -│   ├── icons # Small reusable UI glyphs -│   │   ├── battery-high.svg # Battery status icon -│   │   ├── circle.svg # Generic circular indicator icon -│   │   ├── info.svg # Informational icon -│   │   ├── mouse-simple.svg # Generic fallback device card artwork -│   │   ├── plus.svg # Add/create icon -│   │   ├── sliders-horizontal.svg # Settings/sliders icon -│   │   ├── trash.svg # Delete icon -│   │   ├── warning.svg # Warning icon -│   │   └── x.svg # Close/remove icon -│   ├── logo_icon.png # Square icon with background -│   ├── logo.icns # Alternate macOS icon asset -│   ├── logo.ico # Multi-size icon for shortcuts -│   ├── logo.png # Mouser logo (source) -│   ├── media.webp # App icon: Windows Media Player -│   ├── mouse_mx_anywhere_3s.png # MX Anywhere family diagram -│   ├── mouse.png # MX Master 3S top-down diagram -│   ├── mx_vertical.png # MX Vertical diagram -│   ├── Screenshot_mouse.png # Documentation screenshot: Mouse page -│   ├── Screenshot_settings.png # Documentation screenshot: Settings page -│   ├── Screenshot.png # Documentation screenshot: app overview -│   ├── VLC.png # App icon: VLC -│   └── VSCODE.png # App icon: VS Code -├── LICENSE # Project license -├── main_cli.py # CLI entry point for config import/export + headless service control -├── main_qml.py # Application entry point (PySide6 + QML) -├── Mouser-linux.spec # Linux PyInstaller spec -├── Mouser-mac.spec # Native macOS app-bundle spec -├── Mouser.bat # Quick-launch batch file -├── Mouser.spec # Cross-platform/default PyInstaller spec -├── README_CN.md # Chinese-language project README -├── readme_mac_osx.md # macOS-specific setup and usage notes -├── README.md # Primary project documentation -├── requirements.txt # Python dependency list -├── test_config.json # Example/test JSON config input -├── test_config.yaml # Example/test YAML config input -├── tests # Automated test suite -│   ├── test_accessibility.py # Tests for macOS accessibility helpers -│   ├── test_app_detector.py # Tests for foreground-app detection -│   ├── test_backend.py # Tests for QML backend bridge behavior -│   ├── test_cli.py # Tests for CLI import/export and service commands -│   ├── test_config_validation.py # Tests for standalone core config validation -│   ├── test_config.py # Tests for config migration/load/save helpers -│   ├── test_device_layouts.py # Tests for device-layout registry lookups -│   ├── test_engine.py # Tests for engine dispatch and scroll behavior -│   ├── test_hid_gesture.py # Tests for HID++ gesture listener logic -│   ├── test_key_simulator.py # Tests for action simulation and custom shortcuts -│   ├── test_log_setup.py # Tests for logging setup -│   ├── test_logi_devices.py # Tests for Logitech device catalog helpers -│   ├── test_mouse_hook.py # Tests for low-level mouse hook + gesture detection -│   ├── test_single_instance.py # Tests for single-instance behavior -│   ├── test_smart_shift.py # Tests for Smart Shift config/engine/backend behavior -│   └── test_startup.py # Tests for login-startup integration -└── ui # UI layer - ├── __init__.py # UI package marker - ├── backend.py # QML ↔ Python bridge (QObject with properties/slots) - ├── locale_manager.py # UI string catalog + localization helpers - └── qml # QML component tree - ├── ActionChip.qml # Selectable action pill - ├── AppIcon.qml # Reusable app icon component - ├── HotspotDot.qml # Interactive button overlay on mouse image - ├── KeyCaptureDialog.qml # Custom shortcut capture dialog - ├── Main.qml # App shell (sidebar + page stack + tray toast) - ├── MousePage.qml # Merged mouse diagram + profile manager - ├── ScrollPage.qml # DPI slider + scroll inversion toggles - └── Theme.js # Shared colors and constants +├── main_qml.py # Application entry point (PySide6 + QML) +├── Mouser.bat # Quick-launch batch file +├── Mouser-mac.spec # Native macOS app-bundle spec +├── Mouser-linux.spec # Linux PyInstaller spec +├── build_macos_app.sh # macOS bundle build + icon/signing flow +├── .github/workflows/ +│ ├── ci.yml # CI checks (compile, tests, QML lint) +│ └── release.yml # Automated release builds (Win/macOS/Linux) +├── README.md +├── readme_mac_osx.md +├── requirements.txt +├── .gitignore +│ +├── core/ # Backend logic +│ ├── accessibility.py # macOS Accessibility trust checks +│ ├── engine.py # Core engine — wires hook ↔ simulator ↔ config +│ ├── mouse_hook.py # Platform dispatcher shim for MouseHook +│ ├── mouse_hook_*.py # Platform-specific MouseHook implementations +│ ├── hid_gesture.py # HID++ 2.0 gesture button divert (Bluetooth + Logi Bolt) +│ ├── 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) +│ ├── config.py # Config manager (JSON load/save/migrate) +│ └── app_detector.py # Foreground app polling +│ +├── ui/ # UI layer +│ ├── backend.py # QML ↔ Python bridge (QObject with properties/slots) +│ └── qml/ +│ ├── Main.qml # App shell (sidebar + page stack + tray toast) +│ ├── MousePage.qml # Merged mouse diagram + profile manager +│ ├── ScrollPage.qml # DPI slider + scroll inversion toggles +│ ├── HotspotDot.qml # Interactive button overlay on mouse image +│ ├── ActionChip.qml # Selectable action pill +│ └── Theme.js # Shared colors and constants +│ +└── images/ + ├── AppIcon.icns # Committed macOS app-bundle icon + ├── mouse.png # MX Master 3S top-down diagram + ├── icons/mouse-simple.svg # Generic fallback device card artwork + ├── logo.png # Mouser logo (source) + ├── logo.ico # Multi-size icon for shortcuts + ├── logo_icon.png # Square icon with background + ├── chrom.png # App icon: Chrome + ├── VSCODE.png # App icon: VS Code + ├── VLC.png # App icon: VLC + └── media.webp # App icon: Windows Media Player ``` ## UI Overview diff --git a/core/mouse_hook.py b/core/mouse_hook.py index 24d49c5e..03ccfa2e 100644 --- a/core/mouse_hook.py +++ b/core/mouse_hook.py @@ -1,2770 +1,75 @@ """ -Low-level mouse hook — supports Windows (via ctypes/Win32) and macOS (via -Quartz CGEventTap). -Intercepts mouse button presses and horizontal scroll events -so we can remap them before they reach applications. +Platform dispatcher shim for mouse hook implementations. """ -import queue -import glob -import os -import stat import sys -import threading -import time +import types -try: - from core.hid_gesture import HidGestureListener -except Exception: # ImportError or hidapi missing - HidGestureListener = None - - -# ================================================================== -# Shared: MouseEvent (platform-neutral) -# ================================================================== - -class MouseEvent: - """Represents a captured mouse event.""" - XBUTTON1_DOWN = "xbutton1_down" - XBUTTON1_UP = "xbutton1_up" - XBUTTON2_DOWN = "xbutton2_down" - XBUTTON2_UP = "xbutton2_up" - MIDDLE_DOWN = "middle_down" - MIDDLE_UP = "middle_up" - GESTURE_DOWN = "gesture_down" # MX Master 3S gesture button - GESTURE_UP = "gesture_up" - GESTURE_CLICK = "gesture_click" - GESTURE_SWIPE_LEFT = "gesture_swipe_left" - GESTURE_SWIPE_RIGHT = "gesture_swipe_right" - GESTURE_SWIPE_UP = "gesture_swipe_up" - GESTURE_SWIPE_DOWN = "gesture_swipe_down" - HSCROLL_LEFT = "hscroll_left" - HSCROLL_RIGHT = "hscroll_right" - MODE_SHIFT_DOWN = "mode_shift_down" - MODE_SHIFT_UP = "mode_shift_up" - DPI_SWITCH_DOWN = "dpi_switch_down" - DPI_SWITCH_UP = "dpi_switch_up" - - def __init__(self, event_type, raw_data=None): - self.event_type = event_type - self.raw_data = raw_data - self.timestamp = time.time() - - -def _format_debug_details(raw_data): - if raw_data is None: - return "" - if isinstance(raw_data, dict): - parts = [f"{k}={v}" for k, v in raw_data.items()] - return " " + " ".join(parts) - return f" value={raw_data}" - - -_LOG_ONCE_KEYS = set() - - -def _log_once(key, message): - if key in _LOG_ONCE_KEYS: - return - _LOG_ONCE_KEYS.add(key) - print(message) - - -def _owner_name(uid): - try: - import pwd - return pwd.getpwuid(uid).pw_name - except Exception: - return str(uid) - - -def _group_name(gid): - try: - import grp - return grp.getgrgid(gid).gr_name - except Exception: - return str(gid) - - -def _format_linux_device_access(path): - if not path: - return "path=-" - try: - st = os.stat(path) - except OSError as exc: - return f"path={path} stat_error={exc}" - - mode = stat.S_IMODE(st.st_mode) - can_read = os.access(path, os.R_OK) - can_write = os.access(path, os.W_OK) - can_rw = os.access(path, os.R_OK | os.W_OK) - return ( - f"path={path} mode={mode:04o} " - f"owner={_owner_name(st.st_uid)}({st.st_uid}) " - f"group={_group_name(st.st_gid)}({st.st_gid}) " - f"access=read:{can_read} write:{can_write} read_write:{can_rw}" - ) - - -def _format_linux_device_access_list(paths, limit=8): - details = [_format_linux_device_access(path) for path in list(paths)[:limit]] - remaining = max(0, len(paths) - limit) - if remaining: - details.append(f"... {remaining} more") - return "; ".join(details) if details else "-" - - -# ================================================================== -# Windows implementation -# ================================================================== +from core.mouse_hook_types import MouseEvent if sys.platform == "win32": - import ctypes - import ctypes.wintypes as wintypes - from ctypes import (CFUNCTYPE, POINTER, Structure, c_int, c_uint, c_ushort, - c_ulong, c_void_p, sizeof, byref, create_string_buffer, windll) - - # Windows constants - WH_MOUSE_LL = 14 - WM_XBUTTONDOWN = 0x020B - WM_XBUTTONUP = 0x020C - WM_MBUTTONDOWN = 0x0207 - WM_MBUTTONUP = 0x0208 - WM_MOUSEHWHEEL = 0x020E - WM_MOUSEWHEEL = 0x020A - - HC_ACTION = 0 - XBUTTON1 = 0x0001 - XBUTTON2 = 0x0002 - - class MSLLHOOKSTRUCT(Structure): - _fields_ = [ - ("pt", wintypes.POINT), - ("mouseData", wintypes.DWORD), - ("flags", wintypes.DWORD), - ("time", wintypes.DWORD), - ("dwExtraInfo", ctypes.POINTER(ctypes.c_ulong)), - ] - - HOOKPROC = CFUNCTYPE(ctypes.c_long, c_int, wintypes.WPARAM, ctypes.POINTER(MSLLHOOKSTRUCT)) - - SetWindowsHookExW = windll.user32.SetWindowsHookExW - SetWindowsHookExW.restype = wintypes.HHOOK - SetWindowsHookExW.argtypes = [c_int, HOOKPROC, wintypes.HINSTANCE, wintypes.DWORD] - - CallNextHookEx = windll.user32.CallNextHookEx - CallNextHookEx.restype = ctypes.c_long - CallNextHookEx.argtypes = [wintypes.HHOOK, c_int, wintypes.WPARAM, ctypes.POINTER(MSLLHOOKSTRUCT)] - - UnhookWindowsHookEx = windll.user32.UnhookWindowsHookEx - UnhookWindowsHookEx.restype = wintypes.BOOL - UnhookWindowsHookEx.argtypes = [wintypes.HHOOK] - - GetModuleHandleW = windll.kernel32.GetModuleHandleW - GetModuleHandleW.restype = wintypes.HMODULE - GetModuleHandleW.argtypes = [wintypes.LPCWSTR] - - GetMessageW = windll.user32.GetMessageW - PostThreadMessageW = windll.user32.PostThreadMessageW - - WM_QUIT = 0x0012 - INJECTED_FLAG = 0x00000001 - - # Raw Input constants - WM_INPUT = 0x00FF - RIDEV_INPUTSINK = 0x00000100 - RID_INPUT = 0x10000003 - RIM_TYPEMOUSE = 0 - RIM_TYPEKEYBOARD = 1 - RIM_TYPEHID = 2 - RIDI_DEVICENAME = 0x20000007 - SW_HIDE = 0 - STANDARD_BUTTON_MASK = 0x1F - - class RAWINPUTDEVICE(Structure): - _fields_ = [ - ("usUsagePage", c_ushort), - ("usUsage", c_ushort), - ("dwFlags", c_ulong), - ("hwndTarget", wintypes.HWND), - ] - - class RAWINPUTHEADER(Structure): - _fields_ = [ - ("dwType", c_ulong), - ("dwSize", c_ulong), - ("hDevice", c_void_p), - ("wParam", POINTER(c_ulong)), - ] - - class RAWMOUSE(Structure): - _fields_ = [ - ("usFlags", c_ushort), - ("usButtonFlags", c_ushort), - ("usButtonData", c_ushort), - ("ulRawButtons", c_ulong), - ("lLastX", c_int), - ("lLastY", c_int), - ("ulExtraInformation", c_ulong), - ] - - class RAWHID(Structure): - _fields_ = [ - ("dwSizeHid", c_ulong), - ("dwCount", c_ulong), - ] - - WNDPROC_TYPE = CFUNCTYPE(ctypes.c_longlong, wintypes.HWND, c_uint, - wintypes.WPARAM, wintypes.LPARAM) - - class WNDCLASSEXW(Structure): - _fields_ = [ - ("cbSize", c_uint), - ("style", c_uint), - ("lpfnWndProc", WNDPROC_TYPE), - ("cbClsExtra", c_int), - ("cbWndExtra", c_int), - ("hInstance", wintypes.HINSTANCE), - ("hIcon", wintypes.HICON), - ("hCursor", wintypes.HANDLE), - ("hbrBackground", wintypes.HBRUSH), - ("lpszMenuName", wintypes.LPCWSTR), - ("lpszClassName", wintypes.LPCWSTR), - ("hIconSm", wintypes.HICON), - ] - - RegisterRawInputDevices = windll.user32.RegisterRawInputDevices - GetRawInputData = windll.user32.GetRawInputData - GetRawInputData.argtypes = [c_void_p, c_uint, c_void_p, POINTER(c_uint), c_uint] - GetRawInputData.restype = c_uint - GetRawInputDeviceInfoW = windll.user32.GetRawInputDeviceInfoW - RegisterClassExW = windll.user32.RegisterClassExW - - CreateWindowExW = windll.user32.CreateWindowExW - CreateWindowExW.restype = wintypes.HWND - CreateWindowExW.argtypes = [ - wintypes.DWORD, wintypes.LPCWSTR, wintypes.LPCWSTR, wintypes.DWORD, - c_int, c_int, c_int, c_int, - wintypes.HWND, wintypes.HMENU, wintypes.HINSTANCE, wintypes.LPVOID, - ] - - ShowWindow = windll.user32.ShowWindow - DefWindowProcW = windll.user32.DefWindowProcW - DefWindowProcW.restype = ctypes.c_longlong - DefWindowProcW.argtypes = [wintypes.HWND, c_uint, wintypes.WPARAM, wintypes.LPARAM] - - TranslateMessage = windll.user32.TranslateMessage - DispatchMessageW = windll.user32.DispatchMessageW - DestroyWindow = windll.user32.DestroyWindow - - def hiword(dword): - val = (dword >> 16) & 0xFFFF - if val >= 0x8000: - val -= 0x10000 - return val - - # Custom messages for deferred scroll injection - WM_APP = 0x8000 - WM_APP_INJECT_VSCROLL = WM_APP + 1 - WM_APP_INJECT_HSCROLL = WM_APP + 2 - - # Device change notification constants - WM_DEVICECHANGE = 0x0219 - DBT_DEVNODES_CHANGED = 0x0007 - - from core.key_simulator import inject_scroll as _inject_scroll_impl - from core.key_simulator import MOUSEEVENTF_WHEEL, MOUSEEVENTF_HWHEEL - - PostMessageW = windll.user32.PostMessageW - PostMessageW.argtypes = [wintypes.HWND, c_uint, wintypes.WPARAM, wintypes.LPARAM] - PostMessageW.restype = wintypes.BOOL - - class MouseHook: - """ - Installs a low-level mouse hook on Windows to intercept - side-button clicks and horizontal scroll events. - """ - - def __init__(self): - self._hook = None - self._hook_thread = None - self._thread_id = None - self._running = False - self._callbacks = {} - self._blocked_events = set() - self._hook_proc = None - self._debug_callback = None - self._gesture_callback = None - self.debug_mode = False - self.invert_vscroll = False - self.invert_hscroll = False - self._pending_vscroll = 0 - self._pending_hscroll = 0 - self._vscroll_posted = False - self._hscroll_posted = False - self._ri_wndproc_ref = None - self._ri_hwnd = None - self._device_name_cache = {} - self.divert_mode_shift = False - self.divert_dpi_switch = False - self._gesture_active = False - self._prev_raw_buttons = {} - self._hid_gesture = None - self._last_rehook_time = 0 - self._device_connected = False - self._connection_change_cb = None - self._startup_event = threading.Event() - self._startup_ok = False - self._gesture_direction_enabled = False - self._gesture_threshold = 50.0 - self._gesture_deadzone = 40.0 - self._gesture_timeout_ms = 3000 - self._gesture_cooldown_ms = 500 - self._gesture_tracking = False - self._gesture_triggered = False - self._gesture_started_at = 0.0 - self._gesture_last_move_at = 0.0 - self._gesture_delta_x = 0.0 - self._gesture_delta_y = 0.0 - self._gesture_cooldown_until = 0.0 - self._gesture_last_swipe_event = None - self._gesture_input_source = None - self._connected_device = None - self._dispatch_queue = queue.Queue() - self._dispatch_worker_thread = None - - def register(self, event_type, callback): - self._callbacks.setdefault(event_type, []).append(callback) - - def block(self, event_type): - self._blocked_events.add(event_type) - - def unblock(self, event_type): - self._blocked_events.discard(event_type) - - def reset_bindings(self): - self._callbacks.clear() - self._blocked_events.clear() - - def configure_gestures(self, enabled=False, threshold=50, - deadzone=40, timeout_ms=3000, cooldown_ms=500): - self._gesture_direction_enabled = bool(enabled) - self._gesture_threshold = float(max(5, threshold)) - self._gesture_deadzone = float(max(0, deadzone)) - self._gesture_timeout_ms = max(250, int(timeout_ms)) - self._gesture_cooldown_ms = max(0, int(cooldown_ms)) - if not self._gesture_direction_enabled: - self._gesture_tracking = False - self._gesture_triggered = False - self._gesture_input_source = None - - def set_connection_change_callback(self, cb): - """Register ``cb(connected: bool)`` invoked on device connect/disconnect.""" - self._connection_change_cb = cb - - @property - def device_connected(self): - return self._device_connected - - @property - def connected_device(self): - return self._connected_device - - def dump_device_info(self): - hg = getattr(self, "_hid_gesture", None) - if hg and hasattr(hg, "dump_device_info"): - return hg.dump_device_info() - return None - - def _set_device_connected(self, connected): - if connected == self._device_connected: - return - self._device_connected = connected - state = "Connected" if connected else "Disconnected" - print(f"[MouseHook] Device {state}") - if self._connection_change_cb: - try: - self._connection_change_cb(connected) - except Exception: - pass - - def set_debug_callback(self, callback): - self._debug_callback = callback - - def set_gesture_callback(self, callback): - self._gesture_callback = callback - - def _emit_debug(self, message): - if self.debug_mode and self._debug_callback: - try: - self._debug_callback(message) - except Exception: - pass - - def _emit_gesture_event(self, event): - if self.debug_mode and self._gesture_callback: - try: - self._gesture_callback(event) - except Exception: - pass - - def _dispatch(self, event): - callbacks = self._callbacks.get(event.event_type, []) - self._emit_debug( - f"Dispatch {event.event_type}" - f"{_format_debug_details(event.raw_data)} callbacks={len(callbacks)}" - ) - if event.event_type.startswith("gesture_"): - self._emit_gesture_event({ - "type": "dispatch", - "event_name": event.event_type, - "callbacks": len(callbacks), - }) - if not callbacks: - self._emit_debug(f"No mapped action for {event.event_type}") - if event.event_type.startswith("gesture_"): - self._emit_gesture_event({ - "type": "unmapped", - "event_name": event.event_type, - }) - for cb in callbacks: - try: - cb(event) - except Exception as e: - print(f"[MouseHook] callback error: {e}") - - def _hid_gesture_available(self): - return self._hid_gesture is not None and self._device_connected - - def _dispatch_worker(self): - """Background thread: drains the event queue so the hook callback returns fast.""" - while self._running: - try: - event = self._dispatch_queue.get(timeout=0.05) - except queue.Empty: - continue - try: - self._dispatch(event) - except Exception as e: - print(f"[MouseHook] dispatch worker error: {e}") - - def _gesture_cooldown_active(self): - return time.monotonic() < self._gesture_cooldown_until - - def _start_gesture_tracking(self): - self._gesture_tracking = self._gesture_direction_enabled - self._gesture_started_at = time.monotonic() - self._gesture_last_move_at = self._gesture_started_at - self._gesture_delta_x = 0.0 - self._gesture_delta_y = 0.0 - self._gesture_input_source = None - - def _finish_gesture_tracking(self): - self._gesture_tracking = False - self._gesture_started_at = 0.0 - self._gesture_last_move_at = 0.0 - self._gesture_delta_x = 0.0 - self._gesture_delta_y = 0.0 - self._gesture_input_source = None - - def _detect_gesture_event(self): - delta_x = self._gesture_delta_x - delta_y = self._gesture_delta_y - - abs_x = abs(delta_x) - abs_y = abs(delta_y) - threshold = self._gesture_threshold - deadzone = self._gesture_deadzone - axis_margin = max(5.0, threshold * 0.2) - - if abs_x >= threshold and abs_y <= deadzone and abs_x >= abs_y + axis_margin: - if delta_x > 0: - return MouseEvent.GESTURE_SWIPE_RIGHT - return MouseEvent.GESTURE_SWIPE_LEFT - - if abs_y >= threshold and abs_x <= deadzone and abs_y >= abs_x + axis_margin: - if delta_y > 0: - return MouseEvent.GESTURE_SWIPE_DOWN - return MouseEvent.GESTURE_SWIPE_UP - - return None - - def _accumulate_gesture_delta(self, delta_x, delta_y, source): - if not (self._gesture_direction_enabled and self._gesture_active): - return - if self._gesture_cooldown_active(): - self._emit_debug( - f"Gesture cooldown active source={source} " - f"dx={delta_x} dy={delta_y}" - ) - self._emit_gesture_event({ - "type": "cooldown_active", - "source": source, - "dx": delta_x, - "dy": delta_y, - }) - return - if not self._gesture_tracking: - self._emit_debug(f"Gesture tracking started source={source}") - self._emit_gesture_event({ - "type": "tracking_started", - "source": source, - }) - self._start_gesture_tracking() - - now = time.monotonic() - idle_ms = (now - self._gesture_last_move_at) * 1000.0 - if idle_ms > self._gesture_timeout_ms: - self._emit_debug( - f"Gesture segment reset timeout source={source} " - f"accum_x={self._gesture_delta_x} accum_y={self._gesture_delta_y}" - ) - self._start_gesture_tracking() - - if self._gesture_input_source not in (None, source): - self._emit_debug( - f"Gesture source locked to {self._gesture_input_source}; " - f"ignoring {source} dx={delta_x} dy={delta_y}" - ) - return - self._gesture_input_source = source - - self._gesture_delta_x += delta_x - self._gesture_delta_y += delta_y - self._gesture_last_move_at = now - self._emit_debug( - f"Gesture segment source={source} " - f"accum_x={self._gesture_delta_x} accum_y={self._gesture_delta_y}" - ) - self._emit_gesture_event({ - "type": "segment", - "source": source, - "dx": self._gesture_delta_x, - "dy": self._gesture_delta_y, - }) - - gesture_event = self._detect_gesture_event() - if not gesture_event: - return - - if ( - self._gesture_last_swipe_event is not None - and gesture_event != self._gesture_last_swipe_event - ): - self._emit_debug( - "Gesture rebound suppressed " - f"{gesture_event} after {self._gesture_last_swipe_event}" - ) - self._emit_gesture_event({ - "type": "rebound_suppressed", - "event_name": gesture_event, - "previous_event_name": self._gesture_last_swipe_event, - "source": source, - "dx": self._gesture_delta_x, - "dy": self._gesture_delta_y, - }) - self._gesture_cooldown_until = ( - time.monotonic() + self._gesture_cooldown_ms / 1000.0 - ) - self._finish_gesture_tracking() - return - - self._gesture_triggered = True - self._gesture_last_swipe_event = gesture_event - self._emit_debug( - "Gesture detected " - f"{gesture_event} source={source} " - f"delta_x={self._gesture_delta_x} delta_y={self._gesture_delta_y}" - ) - self._emit_gesture_event({ - "type": "detected", - "event_name": gesture_event, - "source": source, - "dx": self._gesture_delta_x, - "dy": self._gesture_delta_y, - }) - self._dispatch( - MouseEvent( - gesture_event, - { - "delta_x": self._gesture_delta_x, - "delta_y": self._gesture_delta_y, - "source": source, - }, - ) - ) - self._gesture_cooldown_until = ( - time.monotonic() + self._gesture_cooldown_ms / 1000.0 - ) - self._emit_debug( - f"Gesture cooldown started source={source} " - f"for_ms={self._gesture_cooldown_ms}" - ) - self._emit_gesture_event({ - "type": "cooldown_started", - "source": source, - "for_ms": self._gesture_cooldown_ms, - }) - self._finish_gesture_tracking() - - _WM_NAMES = { - 0x0200: "WM_MOUSEMOVE", - 0x0201: "WM_LBUTTONDOWN", 0x0202: "WM_LBUTTONUP", - 0x0204: "WM_RBUTTONDOWN", 0x0205: "WM_RBUTTONUP", - 0x0207: "WM_MBUTTONDOWN", 0x0208: "WM_MBUTTONUP", - 0x020A: "WM_MOUSEWHEEL", 0x020B: "WM_XBUTTONDOWN", - 0x020C: "WM_XBUTTONUP", 0x020E: "WM_MOUSEHWHEEL", - } - - def _low_level_handler(self, nCode, wParam, lParam): - try: - return self._low_level_handler_inner(nCode, wParam, lParam) - except Exception as exc: - # CRITICAL: never let an exception escape the hook callback — - # Windows would silently uninstall the hook, killing all remapping. - try: - print(f"[MouseHook] CRITICAL _low_level_handler EXCEPTION: {exc}") - import traceback; traceback.print_exc() - except Exception: - pass - return CallNextHookEx(self._hook, nCode, wParam, lParam) - - def _low_level_handler_inner(self, nCode, wParam, lParam): - if nCode == HC_ACTION: - data = lParam.contents - mouse_data = data.mouseData - flags = data.flags - event = None - should_block = False - - if self.debug_mode and self._debug_callback: - wm_name = self._WM_NAMES.get(wParam, f"0x{wParam:04X}") - if wParam != 0x0200: - extra = data.dwExtraInfo.contents.value if data.dwExtraInfo else 0 - info = (f"{wm_name} mouseData=0x{mouse_data:08X} " - f"hiword={hiword(mouse_data)} flags=0x{flags:04X} " - f"extraInfo=0x{extra:X}") - try: - self._debug_callback(info) - except Exception: - pass - - if flags & INJECTED_FLAG: - return CallNextHookEx(self._hook, nCode, wParam, lParam) - - if wParam == WM_XBUTTONDOWN: - xbutton = hiword(mouse_data) - if xbutton == XBUTTON1: - event = MouseEvent(MouseEvent.XBUTTON1_DOWN) - should_block = MouseEvent.XBUTTON1_DOWN in self._blocked_events - elif xbutton == XBUTTON2: - event = MouseEvent(MouseEvent.XBUTTON2_DOWN) - should_block = MouseEvent.XBUTTON2_DOWN in self._blocked_events - - elif wParam == WM_XBUTTONUP: - xbutton = hiword(mouse_data) - if xbutton == XBUTTON1: - event = MouseEvent(MouseEvent.XBUTTON1_UP) - should_block = MouseEvent.XBUTTON1_UP in self._blocked_events - elif xbutton == XBUTTON2: - event = MouseEvent(MouseEvent.XBUTTON2_UP) - should_block = MouseEvent.XBUTTON2_UP in self._blocked_events - - elif wParam == WM_MBUTTONDOWN: - event = MouseEvent(MouseEvent.MIDDLE_DOWN) - should_block = MouseEvent.MIDDLE_DOWN in self._blocked_events - - elif wParam == WM_MBUTTONUP: - event = MouseEvent(MouseEvent.MIDDLE_UP) - should_block = MouseEvent.MIDDLE_UP in self._blocked_events - - elif wParam == WM_MOUSEWHEEL: - if self.invert_vscroll: - delta = hiword(mouse_data) - if delta != 0 and self._ri_hwnd: - self._pending_vscroll += (-delta) - if self._vscroll_posted: - return 1 - if PostMessageW(self._ri_hwnd, WM_APP_INJECT_VSCROLL, 0, 0): - self._vscroll_posted = True - return 1 - self._pending_vscroll -= (-delta) - elif delta != 0: - self._emit_debug("Invert vertical scroll skipped: raw input window unavailable") - - elif wParam == WM_MOUSEHWHEEL: - delta = hiword(mouse_data) - if delta > 0: - event = MouseEvent(MouseEvent.HSCROLL_LEFT, abs(delta)) - should_block = MouseEvent.HSCROLL_LEFT in self._blocked_events - elif delta < 0: - event = MouseEvent(MouseEvent.HSCROLL_RIGHT, abs(delta)) - should_block = MouseEvent.HSCROLL_RIGHT in self._blocked_events - - if self.invert_hscroll: - # When horizontal scroll is remapped, preserve the mapped - # action instead of short-circuiting into synthetic wheel injection. - if delta != 0 and self._ri_hwnd and not should_block: - self._pending_hscroll += (-delta) - if self._hscroll_posted: - return 1 - if PostMessageW(self._ri_hwnd, WM_APP_INJECT_HSCROLL, 0, 0): - self._hscroll_posted = True - return 1 - self._pending_hscroll -= (-delta) - elif delta != 0 and not should_block: - self._emit_debug("Invert horizontal scroll skipped: raw input window unavailable") - - if event: - self._dispatch_queue.put(event) - if should_block: - return 1 - - return CallNextHookEx(self._hook, nCode, wParam, lParam) - - def _get_device_name(self, hDevice): - if hDevice in self._device_name_cache: - return self._device_name_cache[hDevice] - try: - sz = c_uint(0) - GetRawInputDeviceInfoW(hDevice, RIDI_DEVICENAME, None, byref(sz)) - if sz.value > 0: - buf = ctypes.create_unicode_buffer(sz.value + 1) - GetRawInputDeviceInfoW(hDevice, RIDI_DEVICENAME, buf, byref(sz)) - name = buf.value - else: - name = "" - except Exception: - name = "" - self._device_name_cache[hDevice] = name - return name - - def _is_logitech(self, hDevice): - return "046d" in self._get_device_name(hDevice).lower() - - def _ri_wndproc(self, hwnd, msg, wParam, lParam): - if msg == WM_INPUT: - try: - self._process_raw_input(lParam) - except Exception as e: - print(f"[MouseHook] Raw Input error: {e}") - return 0 - - if msg == WM_APP_INJECT_VSCROLL: - delta = self._pending_vscroll - self._pending_vscroll = 0 - self._vscroll_posted = False - if delta != 0: - _inject_scroll_impl(MOUSEEVENTF_WHEEL, delta) - return 0 - - if msg == WM_APP_INJECT_HSCROLL: - delta = self._pending_hscroll - self._pending_hscroll = 0 - self._hscroll_posted = False - if delta != 0: - _inject_scroll_impl(MOUSEEVENTF_HWHEEL, delta) - return 0 - - if msg == WM_DEVICECHANGE: - if wParam == DBT_DEVNODES_CHANGED: - self._on_device_change() - return 0 - - return DefWindowProcW(hwnd, msg, wParam, lParam) - - def _process_raw_input(self, lParam): - sz = c_uint(0) - GetRawInputData(lParam, RID_INPUT, None, byref(sz), - sizeof(RAWINPUTHEADER)) - if sz.value == 0: - return - buf = create_string_buffer(sz.value) - ret = GetRawInputData(lParam, RID_INPUT, buf, byref(sz), - sizeof(RAWINPUTHEADER)) - if ret == 0xFFFFFFFF: - return - header = RAWINPUTHEADER.from_buffer_copy(buf) - if not self._is_logitech(header.hDevice): - return - if header.dwType == RIM_TYPEMOUSE: - self._check_raw_mouse_gesture(header.hDevice, buf) - - def _check_raw_mouse_gesture(self, hDevice, buf): - if self._hid_gesture_available(): - return - mouse = RAWMOUSE.from_buffer_copy(buf, sizeof(RAWINPUTHEADER)) - raw_btns = mouse.ulRawButtons - prev_btns = self._prev_raw_buttons.get(hDevice, 0) - self._prev_raw_buttons[hDevice] = raw_btns - - extra_now = raw_btns & ~STANDARD_BUTTON_MASK - extra_prev = prev_btns & ~STANDARD_BUTTON_MASK - - if extra_now == extra_prev: - return - if extra_now and not extra_prev: - if not self._gesture_active: - self._gesture_active = True - self._gesture_triggered = False - self._gesture_last_swipe_event = None - print(f"[MouseHook] Gesture DOWN (rawBtns extra: 0x{extra_now:X})") - elif not extra_now and extra_prev: - if self._gesture_active: - self._gesture_active = False - self._gesture_last_swipe_event = None - print("[MouseHook] Gesture UP") - self._dispatch(MouseEvent(MouseEvent.GESTURE_CLICK)) - - def _setup_raw_input(self): - hInst = GetModuleHandleW(None) - cls_name = f"MouserRawInput_{id(self)}" - self._ri_wndproc_ref = WNDPROC_TYPE(self._ri_wndproc) - - wc = WNDCLASSEXW() - wc.cbSize = sizeof(WNDCLASSEXW) - wc.lpfnWndProc = self._ri_wndproc_ref - wc.hInstance = hInst - wc.lpszClassName = cls_name - RegisterClassExW(byref(wc)) - - self._ri_hwnd = CreateWindowExW( - 0, cls_name, "Mouser RI", 0, - 0, 0, 1, 1, - None, None, hInst, None, - ) - if not self._ri_hwnd: - print("[MouseHook] CreateWindowExW failed — gesture detection unavailable") - return False - - ShowWindow(self._ri_hwnd, SW_HIDE) - - rid = (RAWINPUTDEVICE * 4)() - rid[0].usUsagePage = 0x01 - rid[0].usUsage = 0x02 - rid[0].dwFlags = RIDEV_INPUTSINK - rid[0].hwndTarget = self._ri_hwnd - rid[1].usUsagePage = 0xFF43 - rid[1].usUsage = 0x0202 - rid[1].dwFlags = RIDEV_INPUTSINK - rid[1].hwndTarget = self._ri_hwnd - rid[2].usUsagePage = 0xFF43 - rid[2].usUsage = 0x0204 - rid[2].dwFlags = RIDEV_INPUTSINK - rid[2].hwndTarget = self._ri_hwnd - rid[3].usUsagePage = 0x0C - rid[3].usUsage = 0x01 - rid[3].dwFlags = RIDEV_INPUTSINK - rid[3].hwndTarget = self._ri_hwnd - - if RegisterRawInputDevices(rid, 4, sizeof(RAWINPUTDEVICE)): - print("[MouseHook] Raw Input: mice + Logitech HID + consumer") - return True - if RegisterRawInputDevices(rid, 2, sizeof(RAWINPUTDEVICE)): - print("[MouseHook] Raw Input: mice + Logitech HID short") - return True - if RegisterRawInputDevices(rid, 1, sizeof(RAWINPUTDEVICE)): - print("[MouseHook] Raw Input: mice only") - return True - print("[MouseHook] Raw Input registration failed") - return False - - def _run_hook(self): - self._thread_id = windll.kernel32.GetCurrentThreadId() - self._hook_proc = HOOKPROC(self._low_level_handler) - self._hook = SetWindowsHookExW( - WH_MOUSE_LL, self._hook_proc, GetModuleHandleW(None), 0) - if not self._hook: - self._startup_ok = False - self._startup_event.set() - print("[MouseHook] Failed to install hook!") - return - print("[MouseHook] Hook installed successfully") - self._setup_raw_input() - self._running = True - self._startup_ok = True - self._startup_event.set() - - msg = wintypes.MSG() - while self._running: - result = GetMessageW(ctypes.byref(msg), None, 0, 0) - if result == 0 or result == -1: - break - TranslateMessage(ctypes.byref(msg)) - DispatchMessageW(ctypes.byref(msg)) - - if self._ri_hwnd: - DestroyWindow(self._ri_hwnd) - self._ri_hwnd = None - if self._hook: - UnhookWindowsHookEx(self._hook) - self._hook = None - self._running = False - print("[MouseHook] Hook removed") - - def _on_device_change(self): - now = time.time() - if now - self._last_rehook_time < 2.0: - return - self._last_rehook_time = now - print("[MouseHook] Device change detected — refreshing hook") - self._device_name_cache.clear() - self._prev_raw_buttons.clear() - self._reinstall_hook() - - def _reinstall_hook(self): - if self._hook: - UnhookWindowsHookEx(self._hook) - self._hook = None - self._hook_proc = HOOKPROC(self._low_level_handler) - self._hook = SetWindowsHookExW( - WH_MOUSE_LL, self._hook_proc, GetModuleHandleW(None), 0) - if self._hook: - print("[MouseHook] Hook reinstalled successfully") - else: - print("[MouseHook] Failed to reinstall hook!") - - def _on_hid_gesture_down(self): - if not self._gesture_active: - self._gesture_active = True - self._gesture_triggered = False - self._gesture_last_swipe_event = None - self._emit_debug("HID gesture button down") - self._emit_gesture_event({"type": "button_down"}) - if self._gesture_direction_enabled and not self._gesture_cooldown_active(): - self._start_gesture_tracking() - else: - self._gesture_tracking = False - self._gesture_triggered = False - - def _on_hid_gesture_up(self): - if self._gesture_active: - should_click = not self._gesture_triggered - self._gesture_active = False - self._finish_gesture_tracking() - self._gesture_triggered = False - self._gesture_last_swipe_event = None - self._emit_debug( - f"HID gesture button up click_candidate={str(should_click).lower()}" - ) - self._emit_gesture_event({ - "type": "button_up", - "click_candidate": should_click, - }) - if should_click: - self._dispatch(MouseEvent(MouseEvent.GESTURE_CLICK)) - - def _on_hid_mode_shift_down(self): - self._emit_debug("HID mode shift button down") - self._dispatch(MouseEvent(MouseEvent.MODE_SHIFT_DOWN)) - - def _on_hid_mode_shift_up(self): - self._emit_debug("HID mode shift button up") - self._dispatch(MouseEvent(MouseEvent.MODE_SHIFT_UP)) - - def _on_hid_dpi_switch_down(self): - self._emit_debug("HID DPI switch button down") - self._dispatch(MouseEvent(MouseEvent.DPI_SWITCH_DOWN)) - - def _on_hid_dpi_switch_up(self): - self._emit_debug("HID DPI switch button up") - self._dispatch(MouseEvent(MouseEvent.DPI_SWITCH_UP)) - - def _on_hid_gesture_move(self, delta_x, delta_y): - self._emit_debug( - f"HID rawxy move dx={delta_x} dy={delta_y}" - ) - self._emit_gesture_event({ - "type": "move", - "source": "hid_rawxy", - "dx": delta_x, - "dy": delta_y, - }) - self._accumulate_gesture_delta(delta_x, delta_y, "hid_rawxy") - - def _on_hid_connect(self): - self._connected_device = ( - self._hid_gesture.connected_device if self._hid_gesture else None - ) - self._set_device_connected(True) - - def _on_hid_disconnect(self): - self._connected_device = None - self._set_device_connected(False) - - def start(self): - if self._hook_thread and self._hook_thread.is_alive(): - return True - self._startup_ok = False - self._startup_event.clear() - self._hook_thread = threading.Thread(target=self._run_hook, daemon=True) - self._hook_thread.start() - if not self._startup_event.wait(2): - print("[MouseHook] Hook startup timed out") - self.stop() - return False - if not self._startup_ok: - return False - if HidGestureListener is not None: - extra = {} - if self.divert_mode_shift: - extra[0x00C4] = { - "on_down": self._on_hid_mode_shift_down, - "on_up": self._on_hid_mode_shift_up, - } - if self.divert_dpi_switch: - extra[0x00FD] = { - "on_down": self._on_hid_dpi_switch_down, - "on_up": self._on_hid_dpi_switch_up, - } - listener = HidGestureListener( - on_down=self._on_hid_gesture_down, - on_up=self._on_hid_gesture_up, - on_move=self._on_hid_gesture_move, - on_connect=self._on_hid_connect, - on_disconnect=self._on_hid_disconnect, - extra_diverts=extra, - ) - self._hid_gesture = listener - if not listener.start(): - self._hid_gesture = None - # Start the dispatch worker — processes events off the hook thread - self._dispatch_worker_thread = threading.Thread( - target=self._dispatch_worker, daemon=True, name="HookDispatch") - self._dispatch_worker_thread.start() - return True - - def stop(self): - self._running = False - if self._hid_gesture: - self._hid_gesture.stop() - self._hid_gesture = None - self._connected_device = None - if self._dispatch_worker_thread: - self._dispatch_worker_thread.join(timeout=1) - self._dispatch_worker_thread = None - if self._thread_id: - PostThreadMessageW(self._thread_id, WM_QUIT, 0, 0) - if self._hook_thread: - self._hook_thread.join(timeout=2) - self._hook = None - self._ri_hwnd = None - self._thread_id = None - self._startup_ok = False - self._startup_event.clear() - - -# ================================================================== -# macOS implementation -# ================================================================== - + from core import mouse_hook_windows as _platform elif sys.platform == "darwin": - import functools - - try: - import objc - except ImportError as exc: - raise ImportError( - "PyObjC is required on macOS. Run " - "`python -m pip install -r requirements.txt`." - ) from exc - - try: - import Quartz - _QUARTZ_OK = True - except ImportError: - _QUARTZ_OK = False - print("[MouseHook] pyobjc-framework-Quartz not installed — " - "pip install pyobjc-framework-Quartz") - - def _autoreleased(fn): - @functools.wraps(fn) - def wrapper(*args, **kwargs): - with objc.autorelease_pool(): - return fn(*args, **kwargs) - return wrapper - - # HID button numbers (typical USB/BT HID mapping on macOS) - _BTN_MIDDLE = 2 - _BTN_BACK = 3 - _BTN_FORWARD = 4 - _SCROLL_INVERT_MARKER = 0x4D4F5553 - _INJECTED_EVENT_MARKER = 0x4D4F5554 - _kCGEventTapDisabledByTimeout = 0xFFFFFFFE - _kCGEventTapDisabledByUserInput = 0xFFFFFFFF - - class MouseHook: - """ - Uses CGEventTap on macOS to intercept mouse button presses and scroll - events. Requires Accessibility permission: - System Settings -> Privacy & Security -> Accessibility - """ - - def __init__(self): - self._running = False - self._callbacks = {} - self._blocked_events = set() - self._tap = None - self._tap_source = None - self._debug_callback = None - self._gesture_callback = None - self.debug_mode = False - self.invert_vscroll = False - self.invert_hscroll = False - self.ignore_trackpad = True - self._gesture_active = False - self._hid_gesture = None - self._wake_observer = None - self._session_resign_observer = None - self._session_activate_observer = None - self._dispatch_queue = queue.Queue() - self._dispatch_thread = None - self._first_event_logged = False - self._device_connected = False - self._connection_change_cb = None - self.divert_mode_shift = False - self.divert_dpi_switch = False - self._gesture_direction_enabled = False - self._gesture_threshold = 50.0 - self._gesture_deadzone = 40.0 - self._gesture_timeout_ms = 3000 - self._gesture_cooldown_ms = 500 - self._gesture_tracking = False - self._gesture_triggered = False - self._gesture_started_at = 0.0 - self._gesture_last_move_at = 0.0 - self._gesture_delta_x = 0.0 - self._gesture_delta_y = 0.0 - self._gesture_cooldown_until = 0.0 - self._gesture_last_swipe_event = None - self._gesture_input_source = None - self._connected_device = None - - def register(self, event_type, callback): - self._callbacks.setdefault(event_type, []).append(callback) - - def block(self, event_type): - self._blocked_events.add(event_type) - - def unblock(self, event_type): - self._blocked_events.discard(event_type) - - def reset_bindings(self): - self._callbacks.clear() - self._blocked_events.clear() - - def configure_gestures(self, enabled=False, threshold=50, - deadzone=40, timeout_ms=3000, cooldown_ms=500): - self._gesture_direction_enabled = bool(enabled) - self._gesture_threshold = float(max(5, threshold)) - self._gesture_deadzone = float(max(0, deadzone)) - self._gesture_timeout_ms = max(250, int(timeout_ms)) - self._gesture_cooldown_ms = max(0, int(cooldown_ms)) - if not self._gesture_direction_enabled: - self._gesture_tracking = False - self._gesture_triggered = False - - def set_connection_change_callback(self, cb): - self._connection_change_cb = cb - - @property - def device_connected(self): - return self._device_connected - - @property - def connected_device(self): - return self._connected_device - - def dump_device_info(self): - hg = getattr(self, "_hid_gesture", None) - if hg and hasattr(hg, "dump_device_info"): - return hg.dump_device_info() - return None - - def _set_device_connected(self, connected): - if connected == self._device_connected: - return - self._device_connected = connected - state = "Connected" if connected else "Disconnected" - print(f"[MouseHook] Device {state}") - if self._connection_change_cb: - try: - self._connection_change_cb(connected) - except Exception: - pass - - def set_debug_callback(self, callback): - self._debug_callback = callback - - def set_gesture_callback(self, callback): - self._gesture_callback = callback - - def _emit_debug(self, message): - if self.debug_mode and self._debug_callback: - try: - self._debug_callback(message) - except Exception: - pass - - def _emit_gesture_event(self, event): - if self.debug_mode and self._gesture_callback: - try: - self._gesture_callback(event) - except Exception: - pass - - def _dispatch(self, event): - callbacks = self._callbacks.get(event.event_type, []) - self._emit_debug( - f"Dispatch {event.event_type}" - f"{_format_debug_details(event.raw_data)} callbacks={len(callbacks)}" - ) - if event.event_type.startswith("gesture_"): - self._emit_gesture_event({ - "type": "dispatch", - "event_name": event.event_type, - "callbacks": len(callbacks), - }) - if not callbacks: - self._emit_debug(f"No mapped action for {event.event_type}") - if event.event_type.startswith("gesture_"): - self._emit_gesture_event({ - "type": "unmapped", - "event_name": event.event_type, - }) - for cb in callbacks: - try: - cb(event) - except Exception as e: - print(f"[MouseHook] callback error: {e}") - - def _negate_scroll_axis(self, cg_event, axis): - for field_name in ( - f"kCGScrollWheelEventDeltaAxis{axis}", - f"kCGScrollWheelEventFixedPtDeltaAxis{axis}", - f"kCGScrollWheelEventPointDeltaAxis{axis}", - ): - field = getattr(Quartz, field_name, None) - if field is None: - continue - value = Quartz.CGEventGetIntegerValueField(cg_event, field) - if value: - Quartz.CGEventSetIntegerValueField(cg_event, field, -value) - - def _post_inverted_scroll_event(self, cg_event): - v_point = Quartz.CGEventGetIntegerValueField( - cg_event, Quartz.kCGScrollWheelEventPointDeltaAxis1 - ) - h_point = Quartz.CGEventGetIntegerValueField( - cg_event, Quartz.kCGScrollWheelEventPointDeltaAxis2 - ) - if self.invert_vscroll: - v_point = -v_point - if self.invert_hscroll: - h_point = -h_point - - inverted = Quartz.CGEventCreateScrollWheelEvent( - None, - Quartz.kCGScrollEventUnitPixel, - 2, - v_point, - h_point, - ) - if not inverted: - return False - Quartz.CGEventSetFlags(inverted, Quartz.CGEventGetFlags(cg_event)) - Quartz.CGEventSetIntegerValueField( - inverted, Quartz.kCGEventSourceUserData, _SCROLL_INVERT_MARKER - ) - for axis in (1, 2): - sign = -1 if ( - (axis == 1 and self.invert_vscroll) or - (axis == 2 and self.invert_hscroll) - ) else 1 - for field_name in ( - f"kCGScrollWheelEventDeltaAxis{axis}", - f"kCGScrollWheelEventFixedPtDeltaAxis{axis}", - f"kCGScrollWheelEventPointDeltaAxis{axis}", - ): - field = getattr(Quartz, field_name, None) - if field is None: - continue - value = Quartz.CGEventGetIntegerValueField(cg_event, field) - Quartz.CGEventSetIntegerValueField(inverted, field, sign * value) - for field_name in ( - "kCGScrollWheelEventScrollPhase", - "kCGScrollWheelEventMomentumPhase", - ): - field = getattr(Quartz, field_name, None) - if field is None: - continue - value = Quartz.CGEventGetIntegerValueField(cg_event, field) - Quartz.CGEventSetIntegerValueField(inverted, field, value) - Quartz.CGEventPost(Quartz.kCGHIDEventTap, inverted) - return True - - def _gesture_cooldown_active(self): - return time.monotonic() < self._gesture_cooldown_until - - def _start_gesture_tracking(self): - self._gesture_tracking = self._gesture_direction_enabled - self._gesture_started_at = time.monotonic() - self._gesture_last_move_at = self._gesture_started_at - self._gesture_delta_x = 0.0 - self._gesture_delta_y = 0.0 - self._gesture_input_source = None - - def _finish_gesture_tracking(self): - self._gesture_tracking = False - self._gesture_started_at = 0.0 - self._gesture_last_move_at = 0.0 - self._gesture_delta_x = 0.0 - self._gesture_delta_y = 0.0 - self._gesture_input_source = None - - def _detect_gesture_event(self): - delta_x = self._gesture_delta_x - delta_y = self._gesture_delta_y - - abs_x = abs(delta_x) - abs_y = abs(delta_y) - threshold = self._gesture_threshold - deadzone = self._gesture_deadzone - axis_margin = max(5.0, threshold * 0.2) - - if abs_x >= threshold and abs_y <= deadzone and abs_x >= abs_y + axis_margin: - if delta_x > 0: - return MouseEvent.GESTURE_SWIPE_RIGHT - return MouseEvent.GESTURE_SWIPE_LEFT - - if abs_y >= threshold and abs_x <= deadzone and abs_y >= abs_x + axis_margin: - if delta_y > 0: - return MouseEvent.GESTURE_SWIPE_DOWN - return MouseEvent.GESTURE_SWIPE_UP - - return None - - def _accumulate_gesture_delta(self, delta_x, delta_y, source): - if not (self._gesture_direction_enabled and self._gesture_active): - return - if self._gesture_cooldown_active(): - self._emit_debug( - f"Gesture cooldown active source={source} " - f"dx={delta_x} dy={delta_y}" - ) - self._emit_gesture_event({ - "type": "cooldown_active", - "source": source, - "dx": delta_x, - "dy": delta_y, - }) - return - if not self._gesture_tracking: - self._emit_debug(f"Gesture tracking started source={source}") - self._emit_gesture_event({ - "type": "tracking_started", - "source": source, - }) - self._start_gesture_tracking() - - now = time.monotonic() - idle_ms = (now - self._gesture_last_move_at) * 1000.0 - if idle_ms > self._gesture_timeout_ms: - self._emit_debug( - f"Gesture segment reset timeout source={source} " - f"accum_x={self._gesture_delta_x} accum_y={self._gesture_delta_y}" - ) - self._start_gesture_tracking() - - # Prefer device-provided RawXY over CGEventTap deltas. On fast swipes - # the event tap can emit a tiny starter delta before the HID stream - # arrives; if we keep that lock, the real swipe is discarded and the - # release falls through as a click. - if source == "hid_rawxy" and self._gesture_input_source == "event_tap": - self._emit_debug( - "Gesture source promoted from event_tap to hid_rawxy " - f"prev_accum_x={self._gesture_delta_x} " - f"prev_accum_y={self._gesture_delta_y}" - ) - self._start_gesture_tracking() - - if self._gesture_input_source not in (None, source): - self._emit_debug( - f"Gesture source locked to {self._gesture_input_source}; " - f"ignoring {source} dx={delta_x} dy={delta_y}" - ) - return - self._gesture_input_source = source - - self._gesture_delta_x += delta_x - self._gesture_delta_y += delta_y - self._gesture_last_move_at = now - self._emit_debug( - f"Gesture segment source={source} " - f"accum_x={self._gesture_delta_x} accum_y={self._gesture_delta_y}" - ) - self._emit_gesture_event({ - "type": "segment", - "source": source, - "dx": self._gesture_delta_x, - "dy": self._gesture_delta_y, - }) - - while True: - gesture_event = self._detect_gesture_event() - if not gesture_event: - return - - if ( - self._gesture_last_swipe_event is not None - and gesture_event != self._gesture_last_swipe_event - ): - self._emit_debug( - "Gesture rebound suppressed " - f"{gesture_event} after {self._gesture_last_swipe_event}" - ) - self._emit_gesture_event({ - "type": "rebound_suppressed", - "event_name": gesture_event, - "previous_event_name": self._gesture_last_swipe_event, - "source": source, - "dx": self._gesture_delta_x, - "dy": self._gesture_delta_y, - }) - self._gesture_cooldown_until = ( - time.monotonic() + self._gesture_cooldown_ms / 1000.0 - ) - self._finish_gesture_tracking() - return - - self._gesture_triggered = True - self._gesture_last_swipe_event = gesture_event - self._emit_debug( - "Gesture detected " - f"{gesture_event} source={source} " - f"delta_x={self._gesture_delta_x} delta_y={self._gesture_delta_y}" - ) - self._emit_gesture_event({ - "type": "detected", - "event_name": gesture_event, - "source": source, - "dx": self._gesture_delta_x, - "dy": self._gesture_delta_y, - }) - self._dispatch_queue.put( - MouseEvent( - gesture_event, - { - "delta_x": self._gesture_delta_x, - "delta_y": self._gesture_delta_y, - "source": source, - }, - ) - ) - self._gesture_cooldown_until = ( - time.monotonic() + self._gesture_cooldown_ms / 1000.0 - ) - self._emit_debug( - f"Gesture cooldown started source={source} " - f"for_ms={self._gesture_cooldown_ms}" - ) - self._emit_gesture_event({ - "type": "cooldown_started", - "source": source, - "for_ms": self._gesture_cooldown_ms, - }) - self._finish_gesture_tracking() - return - - def _dispatch_worker(self): - """Background thread: drains the event queue so tap callback returns fast.""" - while self._running: - try: - event = self._dispatch_queue.get(timeout=0.05) - self._dispatch(event) - except queue.Empty: - continue - - @_autoreleased - def _event_tap_callback(self, proxy, event_type, cg_event, refcon): - """CGEventTap callback. Return the event to pass through, or None to suppress.""" - try: - if event_type in ( - _kCGEventTapDisabledByTimeout, - _kCGEventTapDisabledByUserInput, - ): - print(f"[MouseHook] CGEventTap disabled by system " - f"(type=0x{event_type:X}), re-enabling", flush=True) - Quartz.CGEventTapEnable(self._tap, True) - return cg_event - - if not self._first_event_logged: - self._first_event_logged = True - print("[MouseHook] CGEventTap: first event received", flush=True) - - # Ignore events we injected ourselves. The key_simulator marks - # synthetic events by setting kCGEventSourceUserData to _INJECTED_EVENT_MARKER. - try: - if Quartz.CGEventGetIntegerValueField( - cg_event, Quartz.kCGEventSourceUserData - ) == _INJECTED_EVENT_MARKER: - return cg_event - except Exception: - # If the field isn't available or the call fails, continue. - pass - mouse_event = None - should_block = False - - if (event_type in ( - Quartz.kCGEventMouseMoved, - Quartz.kCGEventOtherMouseDragged, - ) and - self._gesture_direction_enabled and self._gesture_active): - self._emit_debug( - "Gesture move event " - f"type={int(event_type)} " - f"dx={Quartz.CGEventGetIntegerValueField(cg_event, Quartz.kCGMouseEventDeltaX)} " - f"dy={Quartz.CGEventGetIntegerValueField(cg_event, Quartz.kCGMouseEventDeltaY)}" - ) - self._emit_gesture_event({ - "type": "move", - "source": "event_tap", - "dx": Quartz.CGEventGetIntegerValueField( - cg_event, Quartz.kCGMouseEventDeltaX), - "dy": Quartz.CGEventGetIntegerValueField( - cg_event, Quartz.kCGMouseEventDeltaY), - }) - if self._gesture_input_source == "hid_rawxy": - return None - self._accumulate_gesture_delta( - Quartz.CGEventGetIntegerValueField( - cg_event, Quartz.kCGMouseEventDeltaX), - Quartz.CGEventGetIntegerValueField( - cg_event, Quartz.kCGMouseEventDeltaY), - "event_tap", - ) - return None - - if event_type == Quartz.kCGEventOtherMouseDown: - btn = Quartz.CGEventGetIntegerValueField( - cg_event, Quartz.kCGMouseEventButtonNumber) - if self.debug_mode and self._debug_callback: - try: - self._debug_callback(f"OtherMouseDown btn={btn}") - except Exception: - pass - if btn == _BTN_MIDDLE: - mouse_event = MouseEvent(MouseEvent.MIDDLE_DOWN) - should_block = MouseEvent.MIDDLE_DOWN in self._blocked_events - elif btn == _BTN_BACK: - mouse_event = MouseEvent(MouseEvent.XBUTTON1_DOWN) - should_block = MouseEvent.XBUTTON1_DOWN in self._blocked_events - elif btn == _BTN_FORWARD: - mouse_event = MouseEvent(MouseEvent.XBUTTON2_DOWN) - should_block = MouseEvent.XBUTTON2_DOWN in self._blocked_events - - elif event_type == Quartz.kCGEventOtherMouseUp: - btn = Quartz.CGEventGetIntegerValueField( - cg_event, Quartz.kCGMouseEventButtonNumber) - if self.debug_mode and self._debug_callback: - try: - self._debug_callback(f"OtherMouseUp btn={btn}") - except Exception: - pass - if btn == _BTN_MIDDLE: - mouse_event = MouseEvent(MouseEvent.MIDDLE_UP) - should_block = MouseEvent.MIDDLE_UP in self._blocked_events - elif btn == _BTN_BACK: - mouse_event = MouseEvent(MouseEvent.XBUTTON1_UP) - should_block = MouseEvent.XBUTTON1_UP in self._blocked_events - elif btn == _BTN_FORWARD: - mouse_event = MouseEvent(MouseEvent.XBUTTON2_UP) - should_block = MouseEvent.XBUTTON2_UP in self._blocked_events - - elif event_type == Quartz.kCGEventScrollWheel: - if ( - Quartz.CGEventGetIntegerValueField( - cg_event, Quartz.kCGEventSourceUserData - ) == _SCROLL_INVERT_MARKER - ): - return cg_event - if self.ignore_trackpad: - # Pass through trackpad / Magic Mouse continuous scroll - # events untouched — only intercept discrete mouse wheel. - _kCGScrollWheelEventIsContinuous = 88 - if Quartz.CGEventGetIntegerValueField( - cg_event, _kCGScrollWheelEventIsContinuous - ): - return cg_event - h_delta = Quartz.CGEventGetIntegerValueField( - cg_event, Quartz.kCGScrollWheelEventFixedPtDeltaAxis2) - h_delta = h_delta / 65536.0 - if self.debug_mode and self._debug_callback: - try: - v_delta = Quartz.CGEventGetIntegerValueField( - cg_event, - Quartz.kCGScrollWheelEventFixedPtDeltaAxis1) / 65536.0 - self._debug_callback(f"ScrollWheel v={v_delta} h={h_delta}") - except Exception: - pass - if h_delta != 0: - if h_delta > 0: - mouse_event = MouseEvent(MouseEvent.HSCROLL_RIGHT, abs(h_delta)) - should_block = MouseEvent.HSCROLL_RIGHT in self._blocked_events - else: - mouse_event = MouseEvent(MouseEvent.HSCROLL_LEFT, abs(h_delta)) - should_block = MouseEvent.HSCROLL_LEFT in self._blocked_events - if mouse_event: - self._dispatch_queue.put(mouse_event) - mouse_event = None - if should_block: - return None - if self.invert_vscroll or self.invert_hscroll: - if self._post_inverted_scroll_event(cg_event): - return None - - if mouse_event: - self._dispatch_queue.put(mouse_event) - - if should_block: - return None - return cg_event - - except Exception as e: - print(f"[MouseHook] event tap callback error: {e}") - return cg_event - - def _on_hid_gesture_down(self): - if not self._gesture_active: - self._gesture_active = True - self._gesture_triggered = False - self._gesture_last_swipe_event = None - self._emit_debug("HID gesture button down") - self._emit_gesture_event({"type": "button_down"}) - if self._gesture_direction_enabled and not self._gesture_cooldown_active(): - self._start_gesture_tracking() - else: - self._gesture_tracking = False - self._gesture_triggered = False - - def _on_hid_gesture_up(self): - if self._gesture_active: - should_click = not self._gesture_triggered - self._gesture_active = False - self._finish_gesture_tracking() - self._gesture_triggered = False - self._gesture_last_swipe_event = None - self._emit_debug( - f"HID gesture button up click_candidate={str(should_click).lower()}" - ) - self._emit_gesture_event({ - "type": "button_up", - "click_candidate": should_click, - }) - if should_click: - self._dispatch(MouseEvent(MouseEvent.GESTURE_CLICK)) - - def _on_hid_mode_shift_down(self): - self._emit_debug("HID mode shift button down") - self._dispatch(MouseEvent(MouseEvent.MODE_SHIFT_DOWN)) - - def _on_hid_mode_shift_up(self): - self._emit_debug("HID mode shift button up") - self._dispatch(MouseEvent(MouseEvent.MODE_SHIFT_UP)) - - def _on_hid_dpi_switch_down(self): - self._emit_debug("HID DPI switch button down") - self._dispatch(MouseEvent(MouseEvent.DPI_SWITCH_DOWN)) - - def _on_hid_dpi_switch_up(self): - self._emit_debug("HID DPI switch button up") - self._dispatch(MouseEvent(MouseEvent.DPI_SWITCH_UP)) - - def _on_hid_gesture_move(self, delta_x, delta_y): - self._emit_debug( - f"HID rawxy move dx={delta_x} dy={delta_y}" - ) - self._emit_gesture_event({ - "type": "move", - "source": "hid_rawxy", - "dx": delta_x, - "dy": delta_y, - }) - self._accumulate_gesture_delta(delta_x, delta_y, "hid_rawxy") - - def _on_hid_connect(self): - self._connected_device = ( - self._hid_gesture.connected_device if self._hid_gesture else None - ) - self._set_device_connected(True) - - def _on_hid_disconnect(self): - self._connected_device = None - self._set_device_connected(False) - - def _register_wake_observer(self): - """Register NSWorkspace observers for wake and fast-user-switch events. - - On wake or session-activate: re-enable the CGEventTap and request a - full HID++ reconnect so button diverts (including CID 0x00C4) are - re-applied after the device soft-resets. - """ - try: - from AppKit import NSWorkspace - except ImportError: - return - nc = NSWorkspace.sharedWorkspace().notificationCenter() - hg = self._hid_gesture - - def _re_enable_tap_and_reconnect(reason): - if self._tap and self._running: - Quartz.CGEventTapEnable(self._tap, True) - ok = Quartz.CGEventTapIsEnabled(self._tap) - print(f"[MouseHook] Event tap re-enabled ({reason}): " - f"{'OK' if ok else 'FAILED — may need restart'}", flush=True) - if hg: - hg.force_reconnect() - - def _on_wake(n): - _re_enable_tap_and_reconnect("wake") - - def _on_session_resign(n): - print("[MouseHook] Session deactivated", flush=True) - - def _on_session_activate(n): - _re_enable_tap_and_reconnect("user-switch") - - self._wake_observer = nc.addObserverForName_object_queue_usingBlock_( - "NSWorkspaceDidWakeNotification", None, None, _on_wake) - self._session_resign_observer = nc.addObserverForName_object_queue_usingBlock_( - "NSWorkspaceSessionDidResignActiveNotification", None, None, _on_session_resign) - self._session_activate_observer = nc.addObserverForName_object_queue_usingBlock_( - "NSWorkspaceSessionDidBecomeActiveNotification", None, None, _on_session_activate) - - def _unregister_wake_observer(self): - try: - from AppKit import NSWorkspace - nc = NSWorkspace.sharedWorkspace().notificationCenter() - for attr in ("_wake_observer", "_session_resign_observer", "_session_activate_observer"): - obs = getattr(self, attr, None) - if obs is not None: - nc.removeObserver_(obs) - setattr(self, attr, None) - except Exception: - pass - - def start(self): - if not _QUARTZ_OK: - print("[MouseHook] Quartz not available — hook not installed") - return False - if self._running: - return True - - event_mask = ( - Quartz.CGEventMaskBit(Quartz.kCGEventMouseMoved) | - Quartz.CGEventMaskBit(Quartz.kCGEventOtherMouseDown) | - Quartz.CGEventMaskBit(Quartz.kCGEventOtherMouseUp) | - Quartz.CGEventMaskBit(Quartz.kCGEventOtherMouseDragged) | - Quartz.CGEventMaskBit(Quartz.kCGEventScrollWheel) - ) - - self._tap = Quartz.CGEventTapCreate( - Quartz.kCGSessionEventTap, - Quartz.kCGHeadInsertEventTap, - Quartz.kCGEventTapOptionDefault, - event_mask, - self._event_tap_callback, - None - ) - - if self._tap is None: - print("[MouseHook] ERROR: Failed to create CGEventTap!") - print("[MouseHook] Grant Accessibility permission in:") - print("[MouseHook] System Settings -> Privacy & Security -> Accessibility") - return False - - print("[MouseHook] CGEventTap created successfully", flush=True) - - self._tap_source = Quartz.CFMachPortCreateRunLoopSource(None, self._tap, 0) - Quartz.CFRunLoopAddSource( - Quartz.CFRunLoopGetCurrent(), - self._tap_source, - Quartz.kCFRunLoopCommonModes - ) - Quartz.CGEventTapEnable(self._tap, True) - print("[MouseHook] CGEventTap enabled and integrated with run loop", flush=True) - self._running = True - - self._dispatch_thread = threading.Thread( - target=self._dispatch_worker, daemon=True, name="MouseHook-dispatch") - self._dispatch_thread.start() - - if HidGestureListener is not None: - extra = {} - if self.divert_mode_shift: - extra[0x00C4] = { - "on_down": self._on_hid_mode_shift_down, - "on_up": self._on_hid_mode_shift_up, - } - if self.divert_dpi_switch: - extra[0x00FD] = { - "on_down": self._on_hid_dpi_switch_down, - "on_up": self._on_hid_dpi_switch_up, - } - listener = HidGestureListener( - on_down=self._on_hid_gesture_down, - on_up=self._on_hid_gesture_up, - on_move=self._on_hid_gesture_move, - on_connect=self._on_hid_connect, - on_disconnect=self._on_hid_disconnect, - extra_diverts=extra, - ) - self._hid_gesture = listener - if not listener.start(): - self._hid_gesture = None - self._register_wake_observer() - return True - - def stop(self): - self._unregister_wake_observer() - self._running = False - if self._hid_gesture: - self._hid_gesture.stop() - self._hid_gesture = None - self._connected_device = None - - if self._tap: - Quartz.CGEventTapEnable(self._tap, False) - if self._tap_source: - Quartz.CFRunLoopRemoveSource( - Quartz.CFRunLoopGetCurrent(), - self._tap_source, - Quartz.kCFRunLoopCommonModes - ) - self._tap_source = None - self._tap = None - print("[MouseHook] CGEventTap disabled and removed", flush=True) - - if self._dispatch_thread: - self._dispatch_thread.join(timeout=1) - self._dispatch_thread = None - - -# ================================================================== -# Linux implementation -# ================================================================== - + from core import mouse_hook_macos as _platform elif sys.platform == "linux": - try: - import select as _select_mod - import evdev as _evdev_mod - from evdev import ecodes as _ecodes, UInput as _UInput, InputDevice as _InputDevice - _EVDEV_OK = True - except ImportError: - _EVDEV_OK = False - print("[MouseHook] python-evdev not installed — pip install evdev") - - from core.logi_devices import ( - build_evdev_connected_device_info, - resolve_device as _resolve_logi_device, - ) - - _LOGI_VENDOR = 0x046D - - class MouseHook: - """ - Uses evdev on Linux to intercept mouse button presses and scroll - events. Grabs the mouse device for exclusive access and forwards - non-blocked events via a uinput virtual mouse. - Requires read access to /dev/input/event* and write access to - /dev/uinput (add user to 'input' group). - """ - - def __init__(self): - self._running = False - self._callbacks = {} - self._blocked_events = set() - self._debug_callback = None - self._gesture_callback = None - self.debug_mode = False - self.invert_vscroll = False - self.invert_hscroll = False - self._gesture_active = False - self._hid_gesture = None - self._device_connected = False - self._evdev_ready = False - self._hid_ready = False - self._connection_change_cb = None - self._connected_device = None - self._evdev_connected_device = None - self.divert_mode_shift = False - self.divert_dpi_switch = False - self._gesture_direction_enabled = False - self._gesture_threshold = 50.0 - self._gesture_deadzone = 40.0 - self._gesture_timeout_ms = 3000 - self._gesture_cooldown_ms = 500 - self._gesture_tracking = False - self._gesture_triggered = False - self._gesture_started_at = 0.0 - self._gesture_last_move_at = 0.0 - self._gesture_delta_x = 0.0 - self._gesture_delta_y = 0.0 - self._gesture_cooldown_until = 0.0 - self._gesture_last_swipe_event = None - self._gesture_input_source = None - self._gesture_lock = threading.Lock() - # Linux-specific - self._evdev_device = None - self._uinput = None - self._evdev_thread = None - self._rescan_requested = threading.Event() - self._evdev_wakeup = threading.Event() - self._ignored_non_logitech = set() - - # -- standard interface methods --------------------------------- - - def register(self, event_type, callback): - self._callbacks.setdefault(event_type, []).append(callback) - - def block(self, event_type): - self._blocked_events.add(event_type) - - def unblock(self, event_type): - self._blocked_events.discard(event_type) - - def reset_bindings(self): - self._callbacks.clear() - self._blocked_events.clear() - - def configure_gestures(self, enabled=False, threshold=50, - deadzone=40, timeout_ms=3000, cooldown_ms=500): - self._gesture_direction_enabled = bool(enabled) - self._gesture_threshold = float(max(5, threshold)) - self._gesture_deadzone = float(max(0, deadzone)) - self._gesture_timeout_ms = max(250, int(timeout_ms)) - self._gesture_cooldown_ms = max(0, int(cooldown_ms)) - if not self._gesture_direction_enabled: - self._gesture_tracking = False - self._gesture_triggered = False - self._gesture_input_source = None - - def set_connection_change_callback(self, cb): - self._connection_change_cb = cb - - @property - def device_connected(self): - return self._device_connected - - @property - def evdev_ready(self): - return self._evdev_ready - - @property - def hid_ready(self): - return self._hid_ready - - @property - def connected_device(self): - return self._connected_device - - def dump_device_info(self): - hg = getattr(self, "_hid_gesture", None) - if hg and hasattr(hg, "dump_device_info"): - return hg.dump_device_info() - return None - - def _set_evdev_ready(self, ready): - if ready == self._evdev_ready: - return - self._evdev_ready = ready - self._refresh_device_state(force=True) - - def _set_device_connected(self, connected, force=False): - changed = connected != self._device_connected - if not changed and not force: - return - self._device_connected = connected - if changed: - state = "Connected" if connected else "Disconnected" - print(f"[MouseHook] Device {state}") - if self._connection_change_cb: - try: - self._connection_change_cb(connected) - except Exception: - pass - - def _build_evdev_connected_device(self, dev): - info = getattr(dev, "info", None) - return build_evdev_connected_device_info( - product_id=getattr(info, "product", None) if info else None, - product_name=getattr(dev, "name", None), - transport="evdev", - source="evdev", - ) - - def _refresh_device_state(self, force=False): - previous = self._connected_device - next_device = None - if self._hid_ready and self._hid_gesture: - next_device = self._hid_gesture.connected_device - if next_device is None: - next_device = self._evdev_connected_device - self._connected_device = next_device - - prev_source = getattr(previous, "source", None) if previous is not None else None - next_source = getattr(next_device, "source", None) if next_device is not None else None - if prev_source != next_source: - if next_source == "evdev": - print("[MouseHook] Using evdev fallback device info") - elif prev_source == "evdev" and next_device is not None: - print("[MouseHook] Device info upgraded from evdev fallback to HID++") - - self._set_device_connected(self._evdev_ready, force=force) - - def set_debug_callback(self, callback): - self._debug_callback = callback - - def set_gesture_callback(self, callback): - self._gesture_callback = callback - - def _emit_debug(self, message): - if self.debug_mode and self._debug_callback: - try: - self._debug_callback(message) - except Exception: - pass - - def _emit_gesture_event(self, event): - if self.debug_mode and self._gesture_callback: - try: - self._gesture_callback(event) - except Exception: - pass - - def _dispatch(self, event): - callbacks = self._callbacks.get(event.event_type, []) - self._emit_debug( - f"Dispatch {event.event_type}" - f"{_format_debug_details(event.raw_data)} callbacks={len(callbacks)}" - ) - if event.event_type.startswith("gesture_"): - self._emit_gesture_event({ - "type": "dispatch", - "event_name": event.event_type, - "callbacks": len(callbacks), - }) - if not callbacks: - self._emit_debug(f"No mapped action for {event.event_type}") - if event.event_type.startswith("gesture_"): - self._emit_gesture_event({ - "type": "unmapped", - "event_name": event.event_type, - }) - for cb in callbacks: - try: - cb(event) - except Exception as e: - print(f"[MouseHook] callback error: {e}") - - def _hid_gesture_available(self): - return self._hid_gesture is not None and self._evdev_ready - - # -- gesture detection (shared logic) --------------------------- - - def _gesture_cooldown_active(self): - return time.monotonic() < self._gesture_cooldown_until - - def _start_gesture_tracking(self): - self._gesture_tracking = self._gesture_direction_enabled - self._gesture_started_at = time.monotonic() - self._gesture_last_move_at = self._gesture_started_at - self._gesture_delta_x = 0.0 - self._gesture_delta_y = 0.0 - self._gesture_input_source = None - - def _finish_gesture_tracking(self): - self._gesture_tracking = False - self._gesture_started_at = 0.0 - self._gesture_last_move_at = 0.0 - self._gesture_delta_x = 0.0 - self._gesture_delta_y = 0.0 - self._gesture_input_source = None - - def _detect_gesture_event(self): - delta_x = self._gesture_delta_x - delta_y = self._gesture_delta_y - - abs_x = abs(delta_x) - abs_y = abs(delta_y) - threshold = self._gesture_threshold - deadzone = self._gesture_deadzone - axis_margin = max(5.0, threshold * 0.2) - - if abs_x >= threshold and abs_y <= deadzone and abs_x >= abs_y + axis_margin: - if delta_x > 0: - return MouseEvent.GESTURE_SWIPE_RIGHT - return MouseEvent.GESTURE_SWIPE_LEFT - - if abs_y >= threshold and abs_x <= deadzone and abs_y >= abs_x + axis_margin: - if delta_y > 0: - return MouseEvent.GESTURE_SWIPE_DOWN - return MouseEvent.GESTURE_SWIPE_UP - - return None - - def _accumulate_gesture_delta(self, delta_x, delta_y, source): - dispatch_event = None - with self._gesture_lock: - if not (self._gesture_direction_enabled and self._gesture_active): - return - if self._gesture_cooldown_active(): - self._emit_debug( - f"Gesture cooldown active source={source} " - f"dx={delta_x} dy={delta_y}" - ) - self._emit_gesture_event({ - "type": "cooldown_active", - "source": source, - "dx": delta_x, - "dy": delta_y, - }) - return - if not self._gesture_tracking: - self._emit_debug(f"Gesture tracking started source={source}") - self._emit_gesture_event({ - "type": "tracking_started", - "source": source, - }) - self._start_gesture_tracking() - - now = time.monotonic() - idle_ms = (now - self._gesture_last_move_at) * 1000.0 - if idle_ms > self._gesture_timeout_ms: - self._emit_debug( - f"Gesture segment reset timeout source={source} " - f"accum_x={self._gesture_delta_x} accum_y={self._gesture_delta_y}" - ) - self._start_gesture_tracking() - - # Prefer device-provided RawXY over evdev deltas. - if source == "hid_rawxy" and self._gesture_input_source == "evdev": - self._emit_debug( - "Gesture source promoted from evdev to hid_rawxy " - f"prev_accum_x={self._gesture_delta_x} " - f"prev_accum_y={self._gesture_delta_y}" - ) - self._start_gesture_tracking() - - if self._gesture_input_source not in (None, source): - self._emit_debug( - f"Gesture source locked to {self._gesture_input_source}; " - f"ignoring {source} dx={delta_x} dy={delta_y}" - ) - return - self._gesture_input_source = source - - self._gesture_delta_x += delta_x - self._gesture_delta_y += delta_y - self._gesture_last_move_at = now - self._emit_debug( - f"Gesture segment source={source} " - f"accum_x={self._gesture_delta_x} accum_y={self._gesture_delta_y}" - ) - self._emit_gesture_event({ - "type": "segment", - "source": source, - "dx": self._gesture_delta_x, - "dy": self._gesture_delta_y, - }) - - gesture_event = self._detect_gesture_event() - if not gesture_event: - return - - if ( - self._gesture_last_swipe_event is not None - and gesture_event != self._gesture_last_swipe_event - ): - self._emit_debug( - "Gesture rebound suppressed " - f"{gesture_event} after {self._gesture_last_swipe_event}" - ) - self._emit_gesture_event({ - "type": "rebound_suppressed", - "event_name": gesture_event, - "previous_event_name": self._gesture_last_swipe_event, - "source": source, - "dx": self._gesture_delta_x, - "dy": self._gesture_delta_y, - }) - self._gesture_cooldown_until = ( - time.monotonic() + self._gesture_cooldown_ms / 1000.0 - ) - self._finish_gesture_tracking() - return - self._gesture_triggered = True - self._gesture_last_swipe_event = gesture_event - self._emit_debug( - "Gesture detected " - f"{gesture_event} source={source} " - f"delta_x={self._gesture_delta_x} delta_y={self._gesture_delta_y}" - ) - self._emit_gesture_event({ - "type": "detected", - "event_name": gesture_event, - "source": source, - "dx": self._gesture_delta_x, - "dy": self._gesture_delta_y, - }) - dispatch_event = MouseEvent( - gesture_event, - { - "delta_x": self._gesture_delta_x, - "delta_y": self._gesture_delta_y, - "source": source, - }, - ) - self._gesture_cooldown_until = ( - time.monotonic() + self._gesture_cooldown_ms / 1000.0 - ) - self._emit_debug( - f"Gesture cooldown started source={source} " - f"for_ms={self._gesture_cooldown_ms}" - ) - self._emit_gesture_event({ - "type": "cooldown_started", - "source": source, - "for_ms": self._gesture_cooldown_ms, - }) - self._finish_gesture_tracking() - - # Dispatch outside lock to avoid deadlock with callbacks - if dispatch_event: - self._dispatch(dispatch_event) - - # -- HID gesture callbacks -------------------------------------- - - def _on_hid_gesture_down(self): - with self._gesture_lock: - if not self._gesture_active: - self._gesture_active = True - self._gesture_triggered = False - self._gesture_last_swipe_event = None - self._emit_debug("HID gesture button down") - self._emit_gesture_event({"type": "button_down"}) - if self._gesture_direction_enabled and not self._gesture_cooldown_active(): - self._start_gesture_tracking() - else: - self._gesture_tracking = False - self._gesture_triggered = False - - def _on_hid_gesture_up(self): - dispatch_click = False - with self._gesture_lock: - if self._gesture_active: - should_click = not self._gesture_triggered - self._gesture_active = False - self._finish_gesture_tracking() - self._gesture_triggered = False - self._gesture_last_swipe_event = None - self._emit_debug( - f"HID gesture button up click_candidate={str(should_click).lower()}" - ) - self._emit_gesture_event({ - "type": "button_up", - "click_candidate": should_click, - }) - dispatch_click = should_click - if dispatch_click: - self._dispatch(MouseEvent(MouseEvent.GESTURE_CLICK)) - - def _on_hid_mode_shift_down(self): - self._emit_debug("HID mode shift button down") - self._dispatch(MouseEvent(MouseEvent.MODE_SHIFT_DOWN)) - - def _on_hid_mode_shift_up(self): - self._emit_debug("HID mode shift button up") - self._dispatch(MouseEvent(MouseEvent.MODE_SHIFT_UP)) - - def _on_hid_dpi_switch_down(self): - self._emit_debug("HID DPI switch button down") - self._dispatch(MouseEvent(MouseEvent.DPI_SWITCH_DOWN)) - - def _on_hid_dpi_switch_up(self): - self._emit_debug("HID DPI switch button up") - self._dispatch(MouseEvent(MouseEvent.DPI_SWITCH_UP)) - - def _on_hid_gesture_move(self, delta_x, delta_y): - self._emit_debug( - f"HID rawxy move dx={delta_x} dy={delta_y}" - ) - self._emit_gesture_event({ - "type": "move", - "source": "hid_rawxy", - "dx": delta_x, - "dy": delta_y, - }) - self._accumulate_gesture_delta(delta_x, delta_y, "hid_rawxy") - - def _on_hid_connect(self): - self._hid_ready = True - self._refresh_device_state(force=True) - dev = self._evdev_device - should_wake_evdev = ( - self._running - and _EVDEV_OK - and ( - dev is None - or not self._evdev_ready - or dev.info.vendor != _LOGI_VENDOR - ) - ) - if should_wake_evdev: - print("[MouseHook] Logitech HID connected; waking evdev scan") - self._rescan_requested.set() - self._evdev_wakeup.set() - - def _on_hid_disconnect(self): - self._hid_ready = False - if self._gesture_active: - self._gesture_active = False - self._finish_gesture_tracking() - self._gesture_triggered = False - self._refresh_device_state(force=True) - - # -- Linux evdev specifics -------------------------------------- - - def _find_mouse_device(self): - """Find the best Logitech mouse evdev device.""" - logi_mice = [] - try: - paths = list(_evdev_mod.list_devices()) - except Exception as exc: - _log_once( - ("evdev-list-error", type(exc).__name__, str(exc)), - f"[MouseHook] Cannot list evdev devices: {exc}" - ) - return None - if not paths: - event_paths = sorted(glob.glob("/dev/input/event*")) - if event_paths: - _log_once( - "evdev-empty-fallback-event-nodes", - "[MouseHook] evdev returned no input devices; falling " - "back to visible /dev/input/event* nodes: " - f"{_format_linux_device_access_list(event_paths)}" - ) - paths = event_paths - else: - _log_once( - "evdev-no-input-devices", - "[MouseHook] evdev returned no input devices and no " - "/dev/input/event* nodes are visible; remapping needs " - f"/dev/input/event* access. " - f"{_format_linux_device_access('/dev/input')}" - ) - - for path in paths: - try: - dev = _InputDevice(path) - except PermissionError as exc: - _log_once( - ("evdev-open-permission", path), - f"[MouseHook] Permission denied opening {path}: {exc}. " - f"{_format_linux_device_access(path)}. " - "Add the user to a group with /dev/input/event* access " - "or install a udev rule." - ) - continue - except Exception as exc: - _log_once( - ("evdev-open-error", path, type(exc).__name__, str(exc)), - f"[MouseHook] Cannot open evdev device {path}: {exc}" - ) - continue - try: - caps = dev.capabilities(absinfo=False) - if _ecodes.EV_REL not in caps or _ecodes.EV_KEY not in caps: - dev.close() - continue - rel_caps = set(caps.get(_ecodes.EV_REL, [])) - key_caps = set(caps.get(_ecodes.EV_KEY, [])) - if _ecodes.REL_X not in rel_caps or _ecodes.REL_Y not in rel_caps: - dev.close() - continue - if not key_caps.intersection({ - _ecodes.BTN_LEFT, _ecodes.BTN_RIGHT, _ecodes.BTN_MIDDLE, - }): - dev.close() - continue - has_side = bool(key_caps.intersection({ - _ecodes.BTN_SIDE, _ecodes.BTN_EXTRA, - })) - except PermissionError as exc: - _log_once( - ("evdev-capabilities-permission", dev.path), - f"[MouseHook] Permission denied reading capabilities for " - f"{dev.path}: {exc}" - ) - dev.close() - continue - except Exception as exc: - _log_once( - ("evdev-capabilities-error", dev.path, type(exc).__name__, str(exc)), - f"[MouseHook] Cannot inspect evdev device {dev.path}: {exc}" - ) - dev.close() - continue - if dev.info.vendor == _LOGI_VENDOR: - logi_mice.append((dev, has_side)) - else: - info = getattr(dev, "info", None) - dedupe_key = ( - dev.path, - getattr(info, "vendor", 0), - getattr(info, "product", 0), - dev.name or "", - ) - if dedupe_key not in self._ignored_non_logitech: - self._ignored_non_logitech.add(dedupe_key) - print( - "[MouseHook] Ignoring non-Logitech evdev candidate: " - f"{dev.name} ({dev.path}) " - f"vendor=0x{getattr(info, 'vendor', 0):04X} " - f"product=0x{getattr(info, 'product', 0):04X}" - ) - dev.close() - - 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 - info = getattr(dev, "info", None) - spec = _resolve_logi_device( - product_id=getattr(info, "product", None), - product_name=getattr(dev, "name", None), - ) - return ( - int(spec is not None), - int(has_side), - _event_num(dev), - ) - - ordered = sorted(logi_mice, key=_sort_key, reverse=True) - if ordered: - chosen = ordered[0][0] - for dev, _ in ordered[1:]: - dev.close() - print(f"[MouseHook] Found mouse: {chosen.name} ({chosen.path}) " - f"vendor=0x{chosen.info.vendor:04X}") - return chosen - _log_once( - "evdev-no-logitech-mouse", - "[MouseHook] No Logitech evdev mouse found; UI connection state " - "and remapping require a Logitech mouse visible under " - "/dev/input/event* with vendor 0x046D" - ) - return None - - def _setup_evdev(self): - """Find mouse, create uinput mirror, grab device.""" - dev = self._find_mouse_device() - if not dev: - return False - try: - self._uinput = _UInput.from_device( - dev, name="Mouser Virtual Mouse", - ) - dev.grab() - self._evdev_device = dev - self._evdev_connected_device = self._build_evdev_connected_device(dev) - self._set_evdev_ready(True) - print(f"[MouseHook] Grabbed {dev.name} ({dev.path})") - return True - except PermissionError: - print("[MouseHook] Permission denied — add user to 'input' group " - "and ensure /dev/uinput is writable") - dev.close() - except Exception as e: - print(f"[MouseHook] Failed to setup evdev: {e}") - dev.close() - return False - - def _cleanup_evdev(self): - """Release grab and close devices.""" - if self._evdev_device: - try: - self._evdev_device.ungrab() - except Exception: - pass - try: - self._evdev_device.close() - except Exception: - pass - self._evdev_device = None - print("[MouseHook] evdev device released") - if self._uinput: - try: - self._uinput.close() - except Exception: - pass - self._uinput = None - self._evdev_connected_device = None - self._set_evdev_ready(False) - - def _evdev_loop(self): - """Outer loop: find device -> listen -> reconnect on error.""" - while self._running: - self._rescan_requested.clear() - if not self._setup_evdev(): - if self._running: - self._wait_for_evdev_wakeup(2) - continue - try: - self._listen_loop() - except OSError as e: - if self._running: - print(f"[MouseHook] Device disconnected: {e}") - except Exception as e: - if self._running: - print(f"[MouseHook] evdev error: {e}") - finally: - self._cleanup_evdev() - if self._running: - if self._rescan_requested.is_set(): - continue - self._wait_for_evdev_wakeup(1) - - def _wait_for_evdev_wakeup(self, timeout): - self._evdev_wakeup.wait(timeout) - self._evdev_wakeup.clear() - - def _listen_loop(self): - """Read events from the grabbed device, forward or block.""" - fd = self._evdev_device.fd - while self._running: - if self._rescan_requested.is_set(): - print("[MouseHook] Rescan requested; leaving listen loop") - return - readable, _, _ = _select_mod.select([fd], [], [], 0.5) - if not readable: - continue - for event in self._evdev_device.read(): - if not self._running: - return - if event.type == _ecodes.EV_SYN: - self._uinput.write_event(event) - elif event.type == _ecodes.EV_KEY: - self._handle_button(event) - elif event.type == _ecodes.EV_REL: - self._handle_rel(event) - else: - self._uinput.write_event(event) - - def _handle_button(self, event): - """Process a key/button event, dispatch and optionally block.""" - mouse_event = None - should_block = False - - if event.code == _ecodes.BTN_SIDE: - if event.value == 1: - mouse_event = MouseEvent(MouseEvent.XBUTTON1_DOWN) - should_block = MouseEvent.XBUTTON1_DOWN in self._blocked_events - elif event.value == 0: - mouse_event = MouseEvent(MouseEvent.XBUTTON1_UP) - should_block = MouseEvent.XBUTTON1_UP in self._blocked_events - - elif event.code == _ecodes.BTN_EXTRA: - if event.value == 1: - mouse_event = MouseEvent(MouseEvent.XBUTTON2_DOWN) - should_block = MouseEvent.XBUTTON2_DOWN in self._blocked_events - elif event.value == 0: - mouse_event = MouseEvent(MouseEvent.XBUTTON2_UP) - should_block = MouseEvent.XBUTTON2_UP in self._blocked_events - - elif event.code == _ecodes.BTN_MIDDLE: - if event.value == 1: - mouse_event = MouseEvent(MouseEvent.MIDDLE_DOWN) - should_block = MouseEvent.MIDDLE_DOWN in self._blocked_events - elif event.value == 0: - mouse_event = MouseEvent(MouseEvent.MIDDLE_UP) - should_block = MouseEvent.MIDDLE_UP in self._blocked_events - - if mouse_event: - self._dispatch(mouse_event) - - if not should_block: - self._uinput.write_event(event) - - def _handle_rel(self, event): - """Process a relative axis event (movement, scroll).""" - code = event.code - value = event.value - - # Mouse movement - if code == _ecodes.REL_X or code == _ecodes.REL_Y: - if self._gesture_direction_enabled and self._gesture_active: - if self._gesture_input_source != "hid_rawxy": - if code == _ecodes.REL_X: - self._accumulate_gesture_delta(value, 0, "evdev") - else: - self._accumulate_gesture_delta(0, value, "evdev") - return # suppress cursor during gesture - self._uinput.write_event(event) - return - - # Vertical scroll (low-res and hi-res) - _REL_WHEEL_HI_RES = getattr(_ecodes, "REL_WHEEL_HI_RES", 0x0B) - if code == _ecodes.REL_WHEEL or code == _REL_WHEEL_HI_RES: - if self.invert_vscroll: - self._uinput.write(_ecodes.EV_REL, code, -value) - else: - self._uinput.write_event(event) - return - - # Horizontal scroll (low-res and hi-res) - _REL_HWHEEL_HI_RES = getattr(_ecodes, "REL_HWHEEL_HI_RES", 0x0C) - if code == _ecodes.REL_HWHEEL or code == _REL_HWHEEL_HI_RES: - should_block = False - if value > 0: - should_block = MouseEvent.HSCROLL_RIGHT in self._blocked_events - elif value < 0: - should_block = MouseEvent.HSCROLL_LEFT in self._blocked_events - - # Dispatch action only from low-res to avoid double-trigger - if code == _ecodes.REL_HWHEEL: - if value > 0: - self._dispatch( - MouseEvent(MouseEvent.HSCROLL_RIGHT, abs(value))) - elif value < 0: - self._dispatch( - MouseEvent(MouseEvent.HSCROLL_LEFT, abs(value))) - - if should_block: - return - if self.invert_hscroll: - self._uinput.write(_ecodes.EV_REL, code, -value) - else: - self._uinput.write_event(event) - return - - # Other relative events: forward as-is - self._uinput.write_event(event) - - # -- lifecycle -------------------------------------------------- - - def _install_crash_guard(self): - """Register signal handlers to release the evdev grab on abnormal exit.""" - import signal - import atexit - atexit.register(self._cleanup_evdev) - for sig in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP): - prev = signal.getsignal(sig) - def _handler(signum, frame, _prev=prev): - self._cleanup_evdev() - if callable(_prev) and _prev not in (signal.SIG_DFL, signal.SIG_IGN): - _prev(signum, frame) - else: - raise SystemExit(128 + signum) - signal.signal(sig, _handler) - - def start(self): - self._running = True - - # Start HID gesture listener (works even without evdev) - if HidGestureListener is not None: - extra = {} - if self.divert_mode_shift: - extra[0x00C4] = { - "on_down": self._on_hid_mode_shift_down, - "on_up": self._on_hid_mode_shift_up, - } - if self.divert_dpi_switch: - extra[0x00FD] = { - "on_down": self._on_hid_dpi_switch_down, - "on_up": self._on_hid_dpi_switch_up, - } - listener = HidGestureListener( - on_down=self._on_hid_gesture_down, - on_up=self._on_hid_gesture_up, - on_move=self._on_hid_gesture_move, - on_connect=self._on_hid_connect, - on_disconnect=self._on_hid_disconnect, - extra_diverts=extra, - ) - self._hid_gesture = listener - if not listener.start(): - self._hid_gesture = None - - # Start evdev hook if available - if _EVDEV_OK: - self._install_crash_guard() - self._evdev_thread = threading.Thread( - target=self._evdev_loop, daemon=True, - name="MouseHook-evdev") - self._evdev_thread.start() - else: - print("[MouseHook] evdev not available — " - "button remapping disabled") - - return True - - def stop(self): - self._running = False - if self._hid_gesture: - self._hid_gesture.stop() - self._hid_gesture = None - self._hid_ready = False - self._connected_device = None - self._evdev_connected_device = None - self._rescan_requested.set() - self._evdev_wakeup.set() - if self._evdev_thread: - self._evdev_thread.join(timeout=2) - self._evdev_thread = None - self._cleanup_evdev() - - -# ================================================================== -# Unsupported platform stub -# ================================================================== - + from core import mouse_hook_linux as _platform else: - class MouseHook: - """Stub for unsupported platforms.""" - def __init__(self): - self._callbacks = {} - self._blocked_events = set() - self.debug_mode = False - self.invert_vscroll = False - self.invert_hscroll = False - self._hid_gesture = None - self._device_connected = False - self._connection_change_cb = None - self._gesture_callback = None - self._connected_device = None - self.divert_mode_shift = False - self.divert_dpi_switch = False - print(f"[MouseHook] Platform \'{sys.platform}\' not supported") - - def register(self, event_type, callback): pass - def block(self, event_type): pass - def unblock(self, event_type): pass - def reset_bindings(self): pass - def configure_gestures(self, enabled=False, threshold=50, - deadzone=40, timeout_ms=3000, cooldown_ms=500): pass - def set_debug_callback(self, callback): pass - def set_gesture_callback(self, callback): pass - def set_connection_change_callback(self, cb): pass - @property - def device_connected(self): return False - @property - def connected_device(self): return None - def dump_device_info(self): return None - def start(self): pass - def stop(self): pass + from core import mouse_hook_stub as _platform + +MouseHook = _platform.MouseHook + +_RESERVED = { + "MouseHook", + "MouseEvent", + "_platform", + "_MouseHookModule", + "_RESERVED", + "__all__", + "__class__", + "__doc__", + "__file__", + "__loader__", + "__name__", + "__package__", + "__spec__", + "__cached__", + "__builtins__", +} + + +def _should_forward(name): + return name not in _RESERVED + + +class _MouseHookModule(types.ModuleType): + def __getattr__(self, name): + if name == "MouseEvent": + return MouseEvent + try: + return getattr(_platform, name) + except AttributeError as exc: + raise AttributeError( + f"module {__name__!r} has no attribute {name!r}" + ) from exc + + def __setattr__(self, name, value): + if _should_forward(name): + setattr(_platform, name, value) + return + super().__setattr__(name, value) + + def __delattr__(self, name): + if _should_forward(name) and hasattr(_platform, name): + delattr(_platform, name) + return + super().__delattr__(name) + + def __dir__(self): + return sorted(set(super().__dir__()) | set(dir(_platform)) | {"MouseEvent"}) + + +module = sys.modules[__name__] +module.__class__ = _MouseHookModule +module.MouseHook = MouseHook +module.MouseEvent = MouseEvent +module.__all__ = ["MouseHook", "MouseEvent"] + list(getattr(_platform, "__all__", [])) diff --git a/core/mouse_hook_base.py b/core/mouse_hook_base.py new file mode 100644 index 00000000..0175bc67 --- /dev/null +++ b/core/mouse_hook_base.py @@ -0,0 +1,246 @@ +""" +Shared mouse hook behavior used by platform implementations. +""" + +import time + +try: + from core.hid_gesture import HidGestureListener +except Exception: + HidGestureListener = None + +from core.mouse_hook_types import MouseEvent, format_debug_details + + +class BaseMouseHook: + def __init__(self): + self._callbacks = {} + self._blocked_events = set() + self._debug_callback = None + self._gesture_callback = None + self.debug_mode = False + self.invert_vscroll = False + self.invert_hscroll = False + self._gesture_active = False + self._hid_gesture = None + self._device_connected = False + self._connection_change_cb = None + self.divert_mode_shift = False + self.divert_dpi_switch = False + self._gesture_direction_enabled = False + self._gesture_threshold = 50.0 + self._gesture_deadzone = 40.0 + self._gesture_timeout_ms = 3000 + self._gesture_cooldown_ms = 500 + self._gesture_tracking = False + self._gesture_triggered = False + self._gesture_started_at = 0.0 + self._gesture_last_move_at = 0.0 + self._gesture_delta_x = 0.0 + self._gesture_delta_y = 0.0 + self._gesture_cooldown_until = 0.0 + self._gesture_input_source = None + self._connected_device = None + + def register(self, event_type, callback): + self._callbacks.setdefault(event_type, []).append(callback) + + def block(self, event_type): + self._blocked_events.add(event_type) + + def unblock(self, event_type): + self._blocked_events.discard(event_type) + + def reset_bindings(self): + self._callbacks.clear() + self._blocked_events.clear() + + def configure_gestures( + self, + enabled=False, + threshold=50, + deadzone=40, + timeout_ms=3000, + cooldown_ms=500, + ): + self._gesture_direction_enabled = bool(enabled) + self._gesture_threshold = float(max(5, threshold)) + self._gesture_deadzone = float(max(0, deadzone)) + self._gesture_timeout_ms = max(250, int(timeout_ms)) + self._gesture_cooldown_ms = max(0, int(cooldown_ms)) + if not self._gesture_direction_enabled: + self._gesture_tracking = False + self._gesture_triggered = False + self._gesture_input_source = None + + def set_connection_change_callback(self, cb): + self._connection_change_cb = cb + + @property + def device_connected(self): + return self._device_connected + + @property + def connected_device(self): + return self._connected_device + + def dump_device_info(self): + hg = getattr(self, "_hid_gesture", None) + if hg and hasattr(hg, "dump_device_info"): + return hg.dump_device_info() + return None + + def _set_device_connected(self, connected): + if connected == self._device_connected: + return + self._device_connected = connected + state = "Connected" if connected else "Disconnected" + print(f"[MouseHook] Device {state}") + if self._connection_change_cb: + try: + self._connection_change_cb(connected) + except Exception: + pass + + def set_debug_callback(self, callback): + self._debug_callback = callback + + def set_gesture_callback(self, callback): + self._gesture_callback = callback + + def _emit_debug(self, message): + if self.debug_mode and self._debug_callback: + try: + self._debug_callback(message) + except Exception: + pass + + def _emit_gesture_event(self, event): + if self.debug_mode and self._gesture_callback: + try: + self._gesture_callback(event) + except Exception: + pass + + def _dispatch(self, event): + callbacks = self._callbacks.get(event.event_type, []) + self._emit_debug( + f"Dispatch {event.event_type}" + f"{format_debug_details(event.raw_data)} callbacks={len(callbacks)}" + ) + if event.event_type.startswith("gesture_"): + self._emit_gesture_event( + { + "type": "dispatch", + "event_name": event.event_type, + "callbacks": len(callbacks), + } + ) + if not callbacks: + self._emit_debug(f"No mapped action for {event.event_type}") + if event.event_type.startswith("gesture_"): + self._emit_gesture_event( + { + "type": "unmapped", + "event_name": event.event_type, + } + ) + for callback in callbacks: + try: + callback(event) + except Exception as exc: + print(f"[MouseHook] callback error: {exc}") + + def _hid_gesture_available(self): + return self._hid_gesture is not None and self._device_connected + + def _gesture_cooldown_active(self): + return time.monotonic() < self._gesture_cooldown_until + + def _start_gesture_tracking(self): + self._gesture_tracking = self._gesture_direction_enabled + self._gesture_started_at = time.monotonic() + self._gesture_last_move_at = self._gesture_started_at + self._gesture_delta_x = 0.0 + self._gesture_delta_y = 0.0 + self._gesture_input_source = None + + def _finish_gesture_tracking(self): + self._gesture_tracking = False + self._gesture_started_at = 0.0 + self._gesture_last_move_at = 0.0 + self._gesture_delta_x = 0.0 + self._gesture_delta_y = 0.0 + self._gesture_input_source = None + + def _detect_gesture_event(self): + delta_x = self._gesture_delta_x + delta_y = self._gesture_delta_y + + abs_x = abs(delta_x) + abs_y = abs(delta_y) + dominant = max(abs_x, abs_y) + if dominant < self._gesture_threshold: + return None + + cross_limit = max(self._gesture_deadzone, dominant * 0.35) + + if abs_x > abs_y: + if abs_y > cross_limit: + return None + if delta_x > 0: + return MouseEvent.GESTURE_SWIPE_RIGHT + return MouseEvent.GESTURE_SWIPE_LEFT + + if abs_x > cross_limit: + return None + if delta_y > 0: + return MouseEvent.GESTURE_SWIPE_DOWN + return MouseEvent.GESTURE_SWIPE_UP + + def _build_extra_diverts(self): + extra = {} + if self.divert_mode_shift: + extra[0x00C4] = { + "on_down": self._on_hid_mode_shift_down, + "on_up": self._on_hid_mode_shift_up, + } + if self.divert_dpi_switch: + extra[0x00FD] = { + "on_down": self._on_hid_dpi_switch_down, + "on_up": self._on_hid_dpi_switch_up, + } + return extra + + def _start_hid_listener(self): + platform_module = getattr(self.__class__, "_platform_module", None) + listener_cls = getattr(platform_module, "HidGestureListener", HidGestureListener) + if listener_cls is None: + return None + listener = listener_cls( + on_down=self._on_hid_gesture_down, + on_up=self._on_hid_gesture_up, + on_move=self._on_hid_gesture_move, + on_connect=self._on_hid_connect, + on_disconnect=self._on_hid_disconnect, + extra_diverts=self._build_extra_diverts(), + ) + self._hid_gesture = listener + if not listener.start(): + self._hid_gesture = None + return self._hid_gesture + + def _stop_hid_listener(self): + if self._hid_gesture: + self._hid_gesture.stop() + self._hid_gesture = None + + def _on_hid_connect(self): + self._connected_device = ( + self._hid_gesture.connected_device if self._hid_gesture else None + ) + self._set_device_connected(True) + + def _on_hid_disconnect(self): + self._connected_device = None + self._set_device_connected(False) diff --git a/core/mouse_hook_contract.py b/core/mouse_hook_contract.py new file mode 100644 index 00000000..bfb677dc --- /dev/null +++ b/core/mouse_hook_contract.py @@ -0,0 +1,38 @@ +""" +Structural contract exposed by platform mouse hook implementations. +""" + +from typing import Any, Callable, Protocol, runtime_checkable + + +@runtime_checkable +class MouseHookLike(Protocol): + debug_mode: bool + invert_vscroll: bool + invert_hscroll: bool + divert_mode_shift: bool + divert_dpi_switch: bool + _hid_gesture: Any + + def register(self, event_type: str, callback: Callable[[Any], None]) -> None: ... + def block(self, event_type: str) -> None: ... + def unblock(self, event_type: str) -> None: ... + def reset_bindings(self) -> None: ... + def configure_gestures( + self, + enabled: bool = False, + threshold: int = 50, + deadzone: int = 40, + timeout_ms: int = 3000, + cooldown_ms: int = 500, + ) -> None: ... + def set_connection_change_callback(self, cb: Callable[[bool], None]) -> None: ... + def set_debug_callback(self, callback: Callable[[str], None]) -> None: ... + def set_gesture_callback(self, callback: Callable[[Any], None]) -> None: ... + @property + def device_connected(self) -> bool: ... + @property + def connected_device(self) -> Any: ... + def dump_device_info(self) -> Any: ... + def start(self) -> bool: ... + def stop(self) -> None: ... diff --git a/core/mouse_hook_linux.py b/core/mouse_hook_linux.py new file mode 100644 index 00000000..394aaeb7 --- /dev/null +++ b/core/mouse_hook_linux.py @@ -0,0 +1,750 @@ +""" +Linux mouse hook implementation. +""" + +import glob +import os +import stat +import sys +import threading +import time + +try: + import select as _select_mod + import evdev as _evdev_mod + from evdev import InputDevice as _InputDevice + from evdev import UInput as _UInput + from evdev import ecodes as _ecodes + + _EVDEV_OK = True +except ImportError: + _EVDEV_OK = False + print("[MouseHook] python-evdev not installed — pip install evdev") + +from core.logi_devices import ( + build_evdev_connected_device_info, + resolve_device as _resolve_logi_device, +) +from core.mouse_hook_base import BaseMouseHook, HidGestureListener +from core.mouse_hook_types import MouseEvent + +_LOGI_VENDOR = 0x046D +_LOG_ONCE_KEYS = set() + + +def _log_once(key, message): + if key in _LOG_ONCE_KEYS: + return + _LOG_ONCE_KEYS.add(key) + print(message) + + +def _owner_name(uid): + try: + import pwd + return pwd.getpwuid(uid).pw_name + except Exception: + return str(uid) + + +def _group_name(gid): + try: + import grp + return grp.getgrgid(gid).gr_name + except Exception: + return str(gid) + + +def _format_linux_device_access(path): + if not path: + return "path=-" + try: + st = os.stat(path) + except OSError as exc: + return f"path={path} stat_error={exc}" + + mode = stat.S_IMODE(st.st_mode) + can_read = os.access(path, os.R_OK) + can_write = os.access(path, os.W_OK) + can_rw = os.access(path, os.R_OK | os.W_OK) + return ( + f"path={path} mode={mode:04o} " + f"owner={_owner_name(st.st_uid)}({st.st_uid}) " + f"group={_group_name(st.st_gid)}({st.st_gid}) " + f"access=read:{can_read} write:{can_write} read_write:{can_rw}" + ) + + +def _format_linux_device_access_list(paths, limit=8): + details = [_format_linux_device_access(path) for path in list(paths)[:limit]] + remaining = max(0, len(paths) - limit) + if remaining: + details.append(f"... {remaining} more") + return "; ".join(details) if details else "-" + + +class MouseHook(BaseMouseHook): + """ + Uses evdev on Linux to intercept mouse button presses and scroll events. + Grabs the mouse device for exclusive access and forwards non-blocked events + via a uinput virtual mouse. + """ + + def __init__(self): + super().__init__() + self._running = False + self._evdev_ready = False + self._hid_ready = False + self._evdev_connected_device = None + self._gesture_lock = threading.Lock() + self._evdev_device = None + self._uinput = None + self._evdev_thread = None + self._rescan_requested = threading.Event() + self._evdev_wakeup = threading.Event() + self._ignored_non_logitech = set() + + @property + def evdev_ready(self): + return self._evdev_ready + + @property + def hid_ready(self): + return self._hid_ready + + def _set_evdev_ready(self, ready): + if ready == self._evdev_ready: + return + self._evdev_ready = ready + self._refresh_device_state(force=True) + + def _set_device_connected(self, connected, force=False): + changed = connected != self._device_connected + if not changed and not force: + return + self._device_connected = connected + if changed: + state = "Connected" if connected else "Disconnected" + print(f"[MouseHook] Device {state}") + if self._connection_change_cb: + try: + self._connection_change_cb(connected) + except Exception: + pass + + def _build_evdev_connected_device(self, dev): + info = getattr(dev, "info", None) + return build_evdev_connected_device_info( + product_id=getattr(info, "product", None) if info else None, + product_name=getattr(dev, "name", None), + transport="evdev", + source="evdev", + ) + + def _refresh_device_state(self, force=False): + previous = self._connected_device + next_device = None + if self._hid_ready and self._hid_gesture: + next_device = self._hid_gesture.connected_device + if next_device is None: + next_device = self._evdev_connected_device + self._connected_device = next_device + + prev_source = getattr(previous, "source", None) if previous is not None else None + next_source = getattr(next_device, "source", None) if next_device is not None else None + if prev_source != next_source: + if next_source == "evdev": + print("[MouseHook] Using evdev fallback device info") + elif prev_source == "evdev" and next_device is not None: + print("[MouseHook] Device info upgraded from evdev fallback to HID++") + + self._set_device_connected(self._evdev_ready, force=force) + + def _hid_gesture_available(self): + return self._hid_gesture is not None and self._evdev_ready + + def _accumulate_gesture_delta(self, delta_x, delta_y, source): + dispatch_event = None + with self._gesture_lock: + if not (self._gesture_direction_enabled and self._gesture_active): + return + if self._gesture_cooldown_active(): + self._emit_debug( + f"Gesture cooldown active source={source} " + f"dx={delta_x} dy={delta_y}" + ) + self._emit_gesture_event( + { + "type": "cooldown_active", + "source": source, + "dx": delta_x, + "dy": delta_y, + } + ) + return + if not self._gesture_tracking: + self._emit_debug(f"Gesture tracking started source={source}") + self._emit_gesture_event( + { + "type": "tracking_started", + "source": source, + } + ) + self._start_gesture_tracking() + + now = time.monotonic() + idle_ms = (now - self._gesture_last_move_at) * 1000.0 + if idle_ms > self._gesture_timeout_ms: + self._emit_debug( + f"Gesture segment reset timeout source={source} " + f"accum_x={self._gesture_delta_x} accum_y={self._gesture_delta_y}" + ) + self._start_gesture_tracking() + + if source == "hid_rawxy" and self._gesture_input_source == "evdev": + self._emit_debug( + "Gesture source promoted from evdev to hid_rawxy " + f"prev_accum_x={self._gesture_delta_x} " + f"prev_accum_y={self._gesture_delta_y}" + ) + self._start_gesture_tracking() + + if self._gesture_input_source not in (None, source): + self._emit_debug( + f"Gesture source locked to {self._gesture_input_source}; " + f"ignoring {source} dx={delta_x} dy={delta_y}" + ) + return + self._gesture_input_source = source + + self._gesture_delta_x += delta_x + self._gesture_delta_y += delta_y + self._gesture_last_move_at = now + self._emit_debug( + f"Gesture segment source={source} " + f"accum_x={self._gesture_delta_x} accum_y={self._gesture_delta_y}" + ) + self._emit_gesture_event( + { + "type": "segment", + "source": source, + "dx": self._gesture_delta_x, + "dy": self._gesture_delta_y, + } + ) + + gesture_event = self._detect_gesture_event() + if not gesture_event: + return + + self._gesture_triggered = True + self._emit_debug( + "Gesture detected " + f"{gesture_event} source={source} " + f"delta_x={self._gesture_delta_x} delta_y={self._gesture_delta_y}" + ) + self._emit_gesture_event( + { + "type": "detected", + "event_name": gesture_event, + "source": source, + "dx": self._gesture_delta_x, + "dy": self._gesture_delta_y, + } + ) + dispatch_event = MouseEvent( + gesture_event, + { + "delta_x": self._gesture_delta_x, + "delta_y": self._gesture_delta_y, + "source": source, + }, + ) + self._gesture_cooldown_until = ( + time.monotonic() + self._gesture_cooldown_ms / 1000.0 + ) + self._emit_debug( + f"Gesture cooldown started source={source} " + f"for_ms={self._gesture_cooldown_ms}" + ) + self._emit_gesture_event( + { + "type": "cooldown_started", + "source": source, + "for_ms": self._gesture_cooldown_ms, + } + ) + self._finish_gesture_tracking() + + if dispatch_event: + self._dispatch(dispatch_event) + + def _on_hid_gesture_down(self): + with self._gesture_lock: + if not self._gesture_active: + self._gesture_active = True + self._gesture_triggered = False + self._emit_debug("HID gesture button down") + self._emit_gesture_event({"type": "button_down"}) + if self._gesture_direction_enabled and not self._gesture_cooldown_active(): + self._start_gesture_tracking() + else: + self._gesture_tracking = False + self._gesture_triggered = False + + def _on_hid_gesture_up(self): + dispatch_click = False + with self._gesture_lock: + if self._gesture_active: + should_click = not self._gesture_triggered + self._gesture_active = False + self._finish_gesture_tracking() + self._gesture_triggered = False + self._emit_debug( + f"HID gesture button up click_candidate={str(should_click).lower()}" + ) + self._emit_gesture_event( + { + "type": "button_up", + "click_candidate": should_click, + } + ) + dispatch_click = should_click + if dispatch_click: + self._dispatch(MouseEvent(MouseEvent.GESTURE_CLICK)) + + def _on_hid_mode_shift_down(self): + self._emit_debug("HID mode shift button down") + self._dispatch(MouseEvent(MouseEvent.MODE_SHIFT_DOWN)) + + def _on_hid_mode_shift_up(self): + self._emit_debug("HID mode shift button up") + self._dispatch(MouseEvent(MouseEvent.MODE_SHIFT_UP)) + + def _on_hid_dpi_switch_down(self): + self._emit_debug("HID DPI switch button down") + self._dispatch(MouseEvent(MouseEvent.DPI_SWITCH_DOWN)) + + def _on_hid_dpi_switch_up(self): + self._emit_debug("HID DPI switch button up") + self._dispatch(MouseEvent(MouseEvent.DPI_SWITCH_UP)) + + def _on_hid_gesture_move(self, delta_x, delta_y): + self._emit_debug(f"HID rawxy move dx={delta_x} dy={delta_y}") + self._emit_gesture_event( + { + "type": "move", + "source": "hid_rawxy", + "dx": delta_x, + "dy": delta_y, + } + ) + self._accumulate_gesture_delta(delta_x, delta_y, "hid_rawxy") + + def _on_hid_connect(self): + self._hid_ready = True + self._refresh_device_state(force=True) + dev = self._evdev_device + should_wake_evdev = ( + self._running + and _EVDEV_OK + and ( + dev is None + or not self._evdev_ready + or dev.info.vendor != _LOGI_VENDOR + ) + ) + if should_wake_evdev: + print("[MouseHook] Logitech HID connected; waking evdev scan") + self._rescan_requested.set() + self._evdev_wakeup.set() + + def _on_hid_disconnect(self): + self._hid_ready = False + if self._gesture_active: + self._gesture_active = False + self._finish_gesture_tracking() + self._gesture_triggered = False + self._refresh_device_state(force=True) + + def _find_mouse_device(self): + logi_mice = [] + try: + paths = list(_evdev_mod.list_devices()) + except Exception as exc: + _log_once( + ("evdev-list-error", type(exc).__name__, str(exc)), + f"[MouseHook] Cannot list evdev devices: {exc}", + ) + return None + if not paths: + event_paths = sorted(glob.glob("/dev/input/event*")) + if event_paths: + _log_once( + "evdev-empty-fallback-event-nodes", + "[MouseHook] evdev returned no input devices; falling " + "back to visible /dev/input/event* nodes: " + f"{_format_linux_device_access_list(event_paths)}", + ) + paths = event_paths + else: + _log_once( + "evdev-no-input-devices", + "[MouseHook] evdev returned no input devices and no " + "/dev/input/event* nodes are visible; remapping needs " + f"/dev/input/event* access. " + f"{_format_linux_device_access('/dev/input')}", + ) + + for path in paths: + try: + dev = _InputDevice(path) + except PermissionError as exc: + _log_once( + ("evdev-open-permission", path), + f"[MouseHook] Permission denied opening {path}: {exc}. " + f"{_format_linux_device_access(path)}. " + "Add the user to a group with /dev/input/event* access " + "or install a udev rule.", + ) + continue + except Exception as exc: + _log_once( + ("evdev-open-error", path, type(exc).__name__, str(exc)), + f"[MouseHook] Cannot open evdev device {path}: {exc}", + ) + continue + try: + caps = dev.capabilities(absinfo=False) + if _ecodes.EV_REL not in caps or _ecodes.EV_KEY not in caps: + dev.close() + continue + rel_caps = set(caps.get(_ecodes.EV_REL, [])) + key_caps = set(caps.get(_ecodes.EV_KEY, [])) + if _ecodes.REL_X not in rel_caps or _ecodes.REL_Y not in rel_caps: + dev.close() + continue + if not key_caps.intersection( + { + _ecodes.BTN_LEFT, + _ecodes.BTN_RIGHT, + _ecodes.BTN_MIDDLE, + } + ): + dev.close() + continue + has_side = bool( + key_caps.intersection( + { + _ecodes.BTN_SIDE, + _ecodes.BTN_EXTRA, + } + ) + ) + except PermissionError as exc: + _log_once( + ("evdev-capabilities-permission", dev.path), + f"[MouseHook] Permission denied reading capabilities for " + f"{dev.path}: {exc}", + ) + dev.close() + continue + except Exception as exc: + _log_once( + ("evdev-capabilities-error", dev.path, type(exc).__name__, str(exc)), + f"[MouseHook] Cannot inspect evdev device {dev.path}: {exc}", + ) + dev.close() + continue + if dev.info.vendor == _LOGI_VENDOR: + logi_mice.append((dev, has_side)) + else: + info = getattr(dev, "info", None) + dedupe_key = ( + dev.path, + getattr(info, "vendor", 0), + getattr(info, "product", 0), + dev.name or "", + ) + if dedupe_key not in self._ignored_non_logitech: + self._ignored_non_logitech.add(dedupe_key) + print( + "[MouseHook] Ignoring non-Logitech evdev candidate: " + f"{dev.name} ({dev.path}) " + f"vendor=0x{getattr(info, 'vendor', 0):04X} " + f"product=0x{getattr(info, 'product', 0):04X}" + ) + dev.close() + + 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 + info = getattr(dev, "info", None) + spec = _resolve_logi_device( + product_id=getattr(info, "product", None), + product_name=getattr(dev, "name", None), + ) + return ( + int(spec is not None), + int(has_side), + _event_num(dev), + ) + + ordered = sorted(logi_mice, key=_sort_key, reverse=True) + if ordered: + chosen = ordered[0][0] + for dev, _ in ordered[1:]: + dev.close() + print( + f"[MouseHook] Found mouse: {chosen.name} ({chosen.path}) " + f"vendor=0x{chosen.info.vendor:04X}" + ) + return chosen + _log_once( + "evdev-no-logitech-mouse", + "[MouseHook] No Logitech evdev mouse found; UI connection state " + "and remapping require a Logitech mouse visible under " + "/dev/input/event* with vendor 0x046D", + ) + return None + + def _setup_evdev(self): + dev = self._find_mouse_device() + if not dev: + return False + try: + self._uinput = _UInput.from_device(dev, name="Mouser Virtual Mouse") + dev.grab() + self._evdev_device = dev + self._evdev_connected_device = self._build_evdev_connected_device(dev) + self._set_evdev_ready(True) + print(f"[MouseHook] Grabbed {dev.name} ({dev.path})") + return True + except PermissionError: + print( + "[MouseHook] Permission denied — add user to 'input' group " + "and ensure /dev/uinput is writable" + ) + dev.close() + except Exception as exc: + print(f"[MouseHook] Failed to setup evdev: {exc}") + dev.close() + return False + + def _cleanup_evdev(self): + if self._evdev_device: + try: + self._evdev_device.ungrab() + except Exception: + pass + try: + self._evdev_device.close() + except Exception: + pass + self._evdev_device = None + print("[MouseHook] evdev device released") + if self._uinput: + try: + self._uinput.close() + except Exception: + pass + self._uinput = None + self._evdev_connected_device = None + self._set_evdev_ready(False) + + def _evdev_loop(self): + while self._running: + self._rescan_requested.clear() + if not self._setup_evdev(): + if self._running: + self._wait_for_evdev_wakeup(2) + continue + try: + self._listen_loop() + except OSError as exc: + if self._running: + print(f"[MouseHook] Device disconnected: {exc}") + except Exception as exc: + if self._running: + print(f"[MouseHook] evdev error: {exc}") + finally: + self._cleanup_evdev() + if self._running: + if self._rescan_requested.is_set(): + continue + self._wait_for_evdev_wakeup(1) + + def _wait_for_evdev_wakeup(self, timeout): + self._evdev_wakeup.wait(timeout) + self._evdev_wakeup.clear() + + def _listen_loop(self): + fd = self._evdev_device.fd + while self._running: + if self._rescan_requested.is_set(): + print("[MouseHook] Rescan requested; leaving listen loop") + return + readable, _, _ = _select_mod.select([fd], [], [], 0.5) + if not readable: + continue + for event in self._evdev_device.read(): + if not self._running: + return + if event.type == _ecodes.EV_SYN: + self._uinput.write_event(event) + elif event.type == _ecodes.EV_KEY: + self._handle_button(event) + elif event.type == _ecodes.EV_REL: + self._handle_rel(event) + else: + self._uinput.write_event(event) + + def _handle_button(self, event): + mouse_event = None + should_block = False + + if event.code == _ecodes.BTN_SIDE: + if event.value == 1: + mouse_event = MouseEvent(MouseEvent.XBUTTON1_DOWN) + should_block = MouseEvent.XBUTTON1_DOWN in self._blocked_events + elif event.value == 0: + mouse_event = MouseEvent(MouseEvent.XBUTTON1_UP) + should_block = MouseEvent.XBUTTON1_UP in self._blocked_events + + elif event.code == _ecodes.BTN_EXTRA: + if event.value == 1: + mouse_event = MouseEvent(MouseEvent.XBUTTON2_DOWN) + should_block = MouseEvent.XBUTTON2_DOWN in self._blocked_events + elif event.value == 0: + mouse_event = MouseEvent(MouseEvent.XBUTTON2_UP) + should_block = MouseEvent.XBUTTON2_UP in self._blocked_events + + elif event.code == _ecodes.BTN_MIDDLE: + if event.value == 1: + mouse_event = MouseEvent(MouseEvent.MIDDLE_DOWN) + should_block = MouseEvent.MIDDLE_DOWN in self._blocked_events + elif event.value == 0: + mouse_event = MouseEvent(MouseEvent.MIDDLE_UP) + should_block = MouseEvent.MIDDLE_UP in self._blocked_events + + if mouse_event: + self._dispatch(mouse_event) + + if not should_block: + self._uinput.write_event(event) + + def _handle_rel(self, event): + code = event.code + value = event.value + + if code == _ecodes.REL_X or code == _ecodes.REL_Y: + if self._gesture_direction_enabled and self._gesture_active: + if self._gesture_input_source != "hid_rawxy": + if code == _ecodes.REL_X: + self._accumulate_gesture_delta(value, 0, "evdev") + else: + self._accumulate_gesture_delta(0, value, "evdev") + return + self._uinput.write_event(event) + return + + rel_wheel_hi_res = getattr(_ecodes, "REL_WHEEL_HI_RES", 0x0B) + if code == _ecodes.REL_WHEEL or code == rel_wheel_hi_res: + if self.invert_vscroll: + self._uinput.write(_ecodes.EV_REL, code, -value) + else: + self._uinput.write_event(event) + return + + rel_hwheel_hi_res = getattr(_ecodes, "REL_HWHEEL_HI_RES", 0x0C) + if code == _ecodes.REL_HWHEEL or code == rel_hwheel_hi_res: + should_block = False + if value > 0: + should_block = MouseEvent.HSCROLL_RIGHT in self._blocked_events + elif value < 0: + should_block = MouseEvent.HSCROLL_LEFT in self._blocked_events + + if code == _ecodes.REL_HWHEEL: + if value > 0: + self._dispatch(MouseEvent(MouseEvent.HSCROLL_RIGHT, abs(value))) + elif value < 0: + self._dispatch(MouseEvent(MouseEvent.HSCROLL_LEFT, abs(value))) + + if should_block: + return + if self.invert_hscroll: + self._uinput.write(_ecodes.EV_REL, code, -value) + else: + self._uinput.write_event(event) + return + + self._uinput.write_event(event) + + def _install_crash_guard(self): + import atexit + import signal + + atexit.register(self._cleanup_evdev) + for sig in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP): + prev = signal.getsignal(sig) + + def _handler(signum, frame, _prev=prev): + self._cleanup_evdev() + if callable(_prev) and _prev not in (signal.SIG_DFL, signal.SIG_IGN): + _prev(signum, frame) + else: + raise SystemExit(128 + signum) + + signal.signal(sig, _handler) + + def start(self): + self._running = True + + self._start_hid_listener() + + if _EVDEV_OK: + self._install_crash_guard() + self._evdev_thread = threading.Thread( + target=self._evdev_loop, + daemon=True, + name="MouseHook-evdev", + ) + self._evdev_thread.start() + else: + print("[MouseHook] evdev not available — button remapping disabled") + + return True + + def stop(self): + self._running = False + self._stop_hid_listener() + self._hid_ready = False + self._connected_device = None + self._evdev_connected_device = None + self._rescan_requested.set() + self._evdev_wakeup.set() + if self._evdev_thread: + self._evdev_thread.join(timeout=2) + self._evdev_thread = None + self._cleanup_evdev() + + +MouseHook._platform_module = sys.modules[__name__] + + +__all__ = [ + "MouseHook", + "HidGestureListener", + "_select_mod", + "_evdev_mod", + "_InputDevice", + "_UInput", + "_ecodes", + "_EVDEV_OK", + "_LOGI_VENDOR", +] diff --git a/core/mouse_hook_macos.py b/core/mouse_hook_macos.py new file mode 100644 index 00000000..e6d5e7a8 --- /dev/null +++ b/core/mouse_hook_macos.py @@ -0,0 +1,642 @@ +""" +macOS mouse hook implementation. +""" + +import functools +import queue +import sys +import threading +import time + +from core.mouse_hook_base import BaseMouseHook, HidGestureListener +from core.mouse_hook_types import MouseEvent + +try: + import objc +except ImportError as exc: + raise ImportError( + "PyObjC is required on macOS. Run " + "`python -m pip install -r requirements.txt`." + ) from exc + +try: + import Quartz + + _QUARTZ_OK = True +except ImportError: + _QUARTZ_OK = False + print( + "[MouseHook] pyobjc-framework-Quartz not installed — " + "pip install pyobjc-framework-Quartz" + ) + + +def _autoreleased(fn): + @functools.wraps(fn) + def wrapper(*args, **kwargs): + with objc.autorelease_pool(): + return fn(*args, **kwargs) + return wrapper + + +_BTN_MIDDLE = 2 +_BTN_BACK = 3 +_BTN_FORWARD = 4 +_SCROLL_INVERT_MARKER = 0x4D4F5553 +_INJECTED_EVENT_MARKER = 0x4D4F5554 +_kCGEventTapDisabledByTimeout = 0xFFFFFFFE +_kCGEventTapDisabledByUserInput = 0xFFFFFFFF + + +class MouseHook(BaseMouseHook): + """ + Uses CGEventTap on macOS to intercept mouse button presses and scroll + events. Requires Accessibility permission. + """ + + def __init__(self): + super().__init__() + self._running = False + self._tap = None + self._tap_source = None + self.ignore_trackpad = True + self._wake_observer = None + self._session_resign_observer = None + self._session_activate_observer = None + self._dispatch_queue = queue.Queue() + self._dispatch_thread = None + self._first_event_logged = False + + def _negate_scroll_axis(self, cg_event, axis): + for field_name in ( + f"kCGScrollWheelEventDeltaAxis{axis}", + f"kCGScrollWheelEventFixedPtDeltaAxis{axis}", + f"kCGScrollWheelEventPointDeltaAxis{axis}", + ): + field = getattr(Quartz, field_name, None) + if field is None: + continue + value = Quartz.CGEventGetIntegerValueField(cg_event, field) + if value: + Quartz.CGEventSetIntegerValueField(cg_event, field, -value) + + def _post_inverted_scroll_event(self, cg_event): + v_point = Quartz.CGEventGetIntegerValueField( + cg_event, Quartz.kCGScrollWheelEventPointDeltaAxis1 + ) + h_point = Quartz.CGEventGetIntegerValueField( + cg_event, Quartz.kCGScrollWheelEventPointDeltaAxis2 + ) + if self.invert_vscroll: + v_point = -v_point + if self.invert_hscroll: + h_point = -h_point + + inverted = Quartz.CGEventCreateScrollWheelEvent( + None, + Quartz.kCGScrollEventUnitPixel, + 2, + v_point, + h_point, + ) + if not inverted: + return False + Quartz.CGEventSetFlags(inverted, Quartz.CGEventGetFlags(cg_event)) + Quartz.CGEventSetIntegerValueField( + inverted, Quartz.kCGEventSourceUserData, _SCROLL_INVERT_MARKER + ) + for axis in (1, 2): + sign = -1 if ( + (axis == 1 and self.invert_vscroll) + or (axis == 2 and self.invert_hscroll) + ) else 1 + for field_name in ( + f"kCGScrollWheelEventDeltaAxis{axis}", + f"kCGScrollWheelEventFixedPtDeltaAxis{axis}", + f"kCGScrollWheelEventPointDeltaAxis{axis}", + ): + field = getattr(Quartz, field_name, None) + if field is None: + continue + value = Quartz.CGEventGetIntegerValueField(cg_event, field) + Quartz.CGEventSetIntegerValueField(inverted, field, sign * value) + for field_name in ( + "kCGScrollWheelEventScrollPhase", + "kCGScrollWheelEventMomentumPhase", + ): + field = getattr(Quartz, field_name, None) + if field is None: + continue + value = Quartz.CGEventGetIntegerValueField(cg_event, field) + Quartz.CGEventSetIntegerValueField(inverted, field, value) + Quartz.CGEventPost(Quartz.kCGHIDEventTap, inverted) + return True + + def _accumulate_gesture_delta(self, delta_x, delta_y, source): + if not (self._gesture_direction_enabled and self._gesture_active): + return + if self._gesture_cooldown_active(): + self._emit_debug( + f"Gesture cooldown active source={source} dx={delta_x} dy={delta_y}" + ) + self._emit_gesture_event( + { + "type": "cooldown_active", + "source": source, + "dx": delta_x, + "dy": delta_y, + } + ) + return + if not self._gesture_tracking: + self._emit_debug(f"Gesture tracking started source={source}") + self._emit_gesture_event( + { + "type": "tracking_started", + "source": source, + } + ) + self._start_gesture_tracking() + + now = time.monotonic() + idle_ms = (now - self._gesture_last_move_at) * 1000.0 + if idle_ms > self._gesture_timeout_ms: + self._emit_debug( + f"Gesture segment reset timeout source={source} " + f"accum_x={self._gesture_delta_x} accum_y={self._gesture_delta_y}" + ) + self._start_gesture_tracking() + + if source == "hid_rawxy" and self._gesture_input_source == "event_tap": + self._emit_debug( + "Gesture source promoted from event_tap to hid_rawxy " + f"prev_accum_x={self._gesture_delta_x} " + f"prev_accum_y={self._gesture_delta_y}" + ) + self._start_gesture_tracking() + + if self._gesture_input_source not in (None, source): + self._emit_debug( + f"Gesture source locked to {self._gesture_input_source}; " + f"ignoring {source} dx={delta_x} dy={delta_y}" + ) + return + self._gesture_input_source = source + + self._gesture_delta_x += delta_x + self._gesture_delta_y += delta_y + self._gesture_last_move_at = now + self._emit_debug( + f"Gesture segment source={source} " + f"accum_x={self._gesture_delta_x} accum_y={self._gesture_delta_y}" + ) + self._emit_gesture_event( + { + "type": "segment", + "source": source, + "dx": self._gesture_delta_x, + "dy": self._gesture_delta_y, + } + ) + + while True: + gesture_event = self._detect_gesture_event() + if not gesture_event: + return + + self._gesture_triggered = True + self._emit_debug( + "Gesture detected " + f"{gesture_event} source={source} " + f"delta_x={self._gesture_delta_x} delta_y={self._gesture_delta_y}" + ) + self._emit_gesture_event( + { + "type": "detected", + "event_name": gesture_event, + "source": source, + "dx": self._gesture_delta_x, + "dy": self._gesture_delta_y, + } + ) + self._dispatch_queue.put( + MouseEvent( + gesture_event, + { + "delta_x": self._gesture_delta_x, + "delta_y": self._gesture_delta_y, + "source": source, + }, + ) + ) + self._gesture_cooldown_until = ( + time.monotonic() + self._gesture_cooldown_ms / 1000.0 + ) + self._emit_debug( + f"Gesture cooldown started source={source} " + f"for_ms={self._gesture_cooldown_ms}" + ) + self._emit_gesture_event( + { + "type": "cooldown_started", + "source": source, + "for_ms": self._gesture_cooldown_ms, + } + ) + self._finish_gesture_tracking() + return + + def _dispatch_worker(self): + while self._running: + try: + event = self._dispatch_queue.get(timeout=0.05) + self._dispatch(event) + except queue.Empty: + continue + + @_autoreleased + def _event_tap_callback(self, proxy, event_type, cg_event, refcon): + try: + if event_type in ( + _kCGEventTapDisabledByTimeout, + _kCGEventTapDisabledByUserInput, + ): + print( + f"[MouseHook] CGEventTap disabled by system " + f"(type=0x{event_type:X}), re-enabling", + flush=True, + ) + Quartz.CGEventTapEnable(self._tap, True) + return cg_event + + if not self._first_event_logged: + self._first_event_logged = True + print("[MouseHook] CGEventTap: first event received", flush=True) + + try: + if ( + Quartz.CGEventGetIntegerValueField( + cg_event, Quartz.kCGEventSourceUserData + ) + == _INJECTED_EVENT_MARKER + ): + return cg_event + except Exception: + pass + mouse_event = None + should_block = False + + if ( + event_type + in ( + Quartz.kCGEventMouseMoved, + Quartz.kCGEventOtherMouseDragged, + ) + and self._gesture_direction_enabled + and self._gesture_active + ): + self._emit_debug( + "Gesture move event " + f"type={int(event_type)} " + f"dx={Quartz.CGEventGetIntegerValueField(cg_event, Quartz.kCGMouseEventDeltaX)} " + f"dy={Quartz.CGEventGetIntegerValueField(cg_event, Quartz.kCGMouseEventDeltaY)}" + ) + self._emit_gesture_event( + { + "type": "move", + "source": "event_tap", + "dx": Quartz.CGEventGetIntegerValueField( + cg_event, Quartz.kCGMouseEventDeltaX + ), + "dy": Quartz.CGEventGetIntegerValueField( + cg_event, Quartz.kCGMouseEventDeltaY + ), + } + ) + if self._gesture_input_source == "hid_rawxy": + return None + self._accumulate_gesture_delta( + Quartz.CGEventGetIntegerValueField( + cg_event, Quartz.kCGMouseEventDeltaX + ), + Quartz.CGEventGetIntegerValueField( + cg_event, Quartz.kCGMouseEventDeltaY + ), + "event_tap", + ) + return None + + if event_type == Quartz.kCGEventOtherMouseDown: + btn = Quartz.CGEventGetIntegerValueField( + cg_event, Quartz.kCGMouseEventButtonNumber + ) + if self.debug_mode and self._debug_callback: + try: + self._debug_callback(f"OtherMouseDown btn={btn}") + except Exception: + pass + if btn == _BTN_MIDDLE: + mouse_event = MouseEvent(MouseEvent.MIDDLE_DOWN) + should_block = MouseEvent.MIDDLE_DOWN in self._blocked_events + elif btn == _BTN_BACK: + mouse_event = MouseEvent(MouseEvent.XBUTTON1_DOWN) + should_block = MouseEvent.XBUTTON1_DOWN in self._blocked_events + elif btn == _BTN_FORWARD: + mouse_event = MouseEvent(MouseEvent.XBUTTON2_DOWN) + should_block = MouseEvent.XBUTTON2_DOWN in self._blocked_events + + elif event_type == Quartz.kCGEventOtherMouseUp: + btn = Quartz.CGEventGetIntegerValueField( + cg_event, Quartz.kCGMouseEventButtonNumber + ) + if self.debug_mode and self._debug_callback: + try: + self._debug_callback(f"OtherMouseUp btn={btn}") + except Exception: + pass + if btn == _BTN_MIDDLE: + mouse_event = MouseEvent(MouseEvent.MIDDLE_UP) + should_block = MouseEvent.MIDDLE_UP in self._blocked_events + elif btn == _BTN_BACK: + mouse_event = MouseEvent(MouseEvent.XBUTTON1_UP) + should_block = MouseEvent.XBUTTON1_UP in self._blocked_events + elif btn == _BTN_FORWARD: + mouse_event = MouseEvent(MouseEvent.XBUTTON2_UP) + should_block = MouseEvent.XBUTTON2_UP in self._blocked_events + + elif event_type == Quartz.kCGEventScrollWheel: + if ( + Quartz.CGEventGetIntegerValueField( + cg_event, Quartz.kCGEventSourceUserData + ) + == _SCROLL_INVERT_MARKER + ): + return cg_event + if self.ignore_trackpad: + is_continuous_field = 88 + if Quartz.CGEventGetIntegerValueField(cg_event, is_continuous_field): + return cg_event + h_delta = Quartz.CGEventGetIntegerValueField( + cg_event, Quartz.kCGScrollWheelEventFixedPtDeltaAxis2 + ) + h_delta = h_delta / 65536.0 + if self.debug_mode and self._debug_callback: + try: + v_delta = ( + Quartz.CGEventGetIntegerValueField( + cg_event, + Quartz.kCGScrollWheelEventFixedPtDeltaAxis1, + ) + / 65536.0 + ) + self._debug_callback(f"ScrollWheel v={v_delta} h={h_delta}") + except Exception: + pass + if h_delta != 0: + if h_delta > 0: + mouse_event = MouseEvent(MouseEvent.HSCROLL_RIGHT, abs(h_delta)) + should_block = MouseEvent.HSCROLL_RIGHT in self._blocked_events + else: + mouse_event = MouseEvent(MouseEvent.HSCROLL_LEFT, abs(h_delta)) + should_block = MouseEvent.HSCROLL_LEFT in self._blocked_events + if mouse_event: + self._dispatch_queue.put(mouse_event) + mouse_event = None + if should_block: + return None + if self.invert_vscroll or self.invert_hscroll: + if self._post_inverted_scroll_event(cg_event): + return None + + if mouse_event: + self._dispatch_queue.put(mouse_event) + + if should_block: + return None + return cg_event + + except Exception as exc: + print(f"[MouseHook] event tap callback error: {exc}") + return cg_event + + def _on_hid_gesture_down(self): + if not self._gesture_active: + self._gesture_active = True + self._gesture_triggered = False + self._emit_debug("HID gesture button down") + self._emit_gesture_event({"type": "button_down"}) + if self._gesture_direction_enabled and not self._gesture_cooldown_active(): + self._start_gesture_tracking() + else: + self._gesture_tracking = False + self._gesture_triggered = False + + def _on_hid_gesture_up(self): + if self._gesture_active: + should_click = not self._gesture_triggered + self._gesture_active = False + self._finish_gesture_tracking() + self._gesture_triggered = False + self._emit_debug( + f"HID gesture button up click_candidate={str(should_click).lower()}" + ) + self._emit_gesture_event( + { + "type": "button_up", + "click_candidate": should_click, + } + ) + if should_click: + self._dispatch(MouseEvent(MouseEvent.GESTURE_CLICK)) + + def _on_hid_mode_shift_down(self): + self._emit_debug("HID mode shift button down") + self._dispatch(MouseEvent(MouseEvent.MODE_SHIFT_DOWN)) + + def _on_hid_mode_shift_up(self): + self._emit_debug("HID mode shift button up") + self._dispatch(MouseEvent(MouseEvent.MODE_SHIFT_UP)) + + def _on_hid_dpi_switch_down(self): + self._emit_debug("HID DPI switch button down") + self._dispatch(MouseEvent(MouseEvent.DPI_SWITCH_DOWN)) + + def _on_hid_dpi_switch_up(self): + self._emit_debug("HID DPI switch button up") + self._dispatch(MouseEvent(MouseEvent.DPI_SWITCH_UP)) + + def _on_hid_gesture_move(self, delta_x, delta_y): + self._emit_debug(f"HID rawxy move dx={delta_x} dy={delta_y}") + self._emit_gesture_event( + { + "type": "move", + "source": "hid_rawxy", + "dx": delta_x, + "dy": delta_y, + } + ) + self._accumulate_gesture_delta(delta_x, delta_y, "hid_rawxy") + + def _register_wake_observer(self): + try: + from AppKit import NSWorkspace + except ImportError: + return + notification_center = NSWorkspace.sharedWorkspace().notificationCenter() + hg = self._hid_gesture + + def _re_enable_tap_and_reconnect(reason): + if self._tap and self._running: + Quartz.CGEventTapEnable(self._tap, True) + ok = Quartz.CGEventTapIsEnabled(self._tap) + print( + f"[MouseHook] Event tap re-enabled ({reason}): " + f"{'OK' if ok else 'FAILED — may need restart'}", + flush=True, + ) + if hg: + hg.force_reconnect() + + def _on_wake(notification): + _re_enable_tap_and_reconnect("wake") + + def _on_session_resign(notification): + print("[MouseHook] Session deactivated", flush=True) + + def _on_session_activate(notification): + _re_enable_tap_and_reconnect("user-switch") + + self._wake_observer = notification_center.addObserverForName_object_queue_usingBlock_( + "NSWorkspaceDidWakeNotification", + None, + None, + _on_wake, + ) + self._session_resign_observer = ( + notification_center.addObserverForName_object_queue_usingBlock_( + "NSWorkspaceSessionDidResignActiveNotification", + None, + None, + _on_session_resign, + ) + ) + self._session_activate_observer = ( + notification_center.addObserverForName_object_queue_usingBlock_( + "NSWorkspaceSessionDidBecomeActiveNotification", + None, + None, + _on_session_activate, + ) + ) + + def _unregister_wake_observer(self): + try: + from AppKit import NSWorkspace + + notification_center = NSWorkspace.sharedWorkspace().notificationCenter() + for attr in ( + "_wake_observer", + "_session_resign_observer", + "_session_activate_observer", + ): + observer = getattr(self, attr, None) + if observer is not None: + notification_center.removeObserver_(observer) + setattr(self, attr, None) + except Exception: + pass + + def start(self): + if not _QUARTZ_OK: + print("[MouseHook] Quartz not available — hook not installed") + return False + if self._running: + return True + + event_mask = ( + Quartz.CGEventMaskBit(Quartz.kCGEventMouseMoved) + | Quartz.CGEventMaskBit(Quartz.kCGEventOtherMouseDown) + | Quartz.CGEventMaskBit(Quartz.kCGEventOtherMouseUp) + | Quartz.CGEventMaskBit(Quartz.kCGEventOtherMouseDragged) + | Quartz.CGEventMaskBit(Quartz.kCGEventScrollWheel) + ) + + self._tap = Quartz.CGEventTapCreate( + Quartz.kCGSessionEventTap, + Quartz.kCGHeadInsertEventTap, + Quartz.kCGEventTapOptionDefault, + event_mask, + self._event_tap_callback, + None, + ) + + if self._tap is None: + print("[MouseHook] ERROR: Failed to create CGEventTap!") + print("[MouseHook] Grant Accessibility permission in:") + print( + "[MouseHook] System Settings -> Privacy & Security -> Accessibility" + ) + return False + + print("[MouseHook] CGEventTap created successfully", flush=True) + + self._tap_source = Quartz.CFMachPortCreateRunLoopSource(None, self._tap, 0) + Quartz.CFRunLoopAddSource( + Quartz.CFRunLoopGetCurrent(), + self._tap_source, + Quartz.kCFRunLoopCommonModes, + ) + Quartz.CGEventTapEnable(self._tap, True) + print("[MouseHook] CGEventTap enabled and integrated with run loop", flush=True) + self._running = True + + self._dispatch_thread = threading.Thread( + target=self._dispatch_worker, + daemon=True, + name="MouseHook-dispatch", + ) + self._dispatch_thread.start() + + self._start_hid_listener() + self._register_wake_observer() + return True + + def stop(self): + self._unregister_wake_observer() + self._running = False + self._stop_hid_listener() + self._connected_device = None + + if self._tap: + Quartz.CGEventTapEnable(self._tap, False) + if self._tap_source: + Quartz.CFRunLoopRemoveSource( + Quartz.CFRunLoopGetCurrent(), + self._tap_source, + Quartz.kCFRunLoopCommonModes, + ) + self._tap_source = None + self._tap = None + print("[MouseHook] CGEventTap disabled and removed", flush=True) + + if self._dispatch_thread: + self._dispatch_thread.join(timeout=1) + self._dispatch_thread = None + + +MouseHook._platform_module = sys.modules[__name__] + + +__all__ = [ + "MouseHook", + "HidGestureListener", + "Quartz", + "_QUARTZ_OK", + "_BTN_MIDDLE", + "_BTN_BACK", + "_BTN_FORWARD", + "_SCROLL_INVERT_MARKER", + "_INJECTED_EVENT_MARKER", + "_kCGEventTapDisabledByTimeout", + "_kCGEventTapDisabledByUserInput", +] diff --git a/core/mouse_hook_stub.py b/core/mouse_hook_stub.py new file mode 100644 index 00000000..be9e66ce --- /dev/null +++ b/core/mouse_hook_stub.py @@ -0,0 +1,24 @@ +""" +Unsupported-platform mouse hook stub. +""" + +import sys + +from core.mouse_hook_base import BaseMouseHook + + +class MouseHook(BaseMouseHook): + """Stub for unsupported platforms.""" + + def __init__(self): + super().__init__() + print(f"[MouseHook] Platform '{sys.platform}' not supported") + + def start(self): + return False + + def stop(self): + return None + + +__all__ = ["MouseHook"] diff --git a/core/mouse_hook_types.py b/core/mouse_hook_types.py new file mode 100644 index 00000000..7f614d9d --- /dev/null +++ b/core/mouse_hook_types.py @@ -0,0 +1,43 @@ +""" +Shared mouse hook types and helpers. +""" + +import time + + +class MouseEvent: + """Represents a captured mouse event.""" + + XBUTTON1_DOWN = "xbutton1_down" + XBUTTON1_UP = "xbutton1_up" + XBUTTON2_DOWN = "xbutton2_down" + XBUTTON2_UP = "xbutton2_up" + MIDDLE_DOWN = "middle_down" + MIDDLE_UP = "middle_up" + GESTURE_DOWN = "gesture_down" + GESTURE_UP = "gesture_up" + GESTURE_CLICK = "gesture_click" + GESTURE_SWIPE_LEFT = "gesture_swipe_left" + GESTURE_SWIPE_RIGHT = "gesture_swipe_right" + GESTURE_SWIPE_UP = "gesture_swipe_up" + GESTURE_SWIPE_DOWN = "gesture_swipe_down" + HSCROLL_LEFT = "hscroll_left" + HSCROLL_RIGHT = "hscroll_right" + MODE_SHIFT_DOWN = "mode_shift_down" + MODE_SHIFT_UP = "mode_shift_up" + DPI_SWITCH_DOWN = "dpi_switch_down" + DPI_SWITCH_UP = "dpi_switch_up" + + def __init__(self, event_type, raw_data=None): + self.event_type = event_type + self.raw_data = raw_data + self.timestamp = time.time() + + +def format_debug_details(raw_data): + if raw_data is None: + return "" + if isinstance(raw_data, dict): + parts = [f"{key}={value}" for key, value in raw_data.items()] + return " " + " ".join(parts) + return f" value={raw_data}" diff --git a/core/mouse_hook_windows.py b/core/mouse_hook_windows.py new file mode 100644 index 00000000..8c550790 --- /dev/null +++ b/core/mouse_hook_windows.py @@ -0,0 +1,818 @@ +""" +Windows mouse hook implementation. +""" + +import ctypes +import ctypes.wintypes as wintypes +import queue +import sys +import threading +import time +from ctypes import ( + CFUNCTYPE, + POINTER, + Structure, + byref, + c_int, + c_uint, + c_ulong, + c_ushort, + c_void_p, + create_string_buffer, + sizeof, + windll, +) + +from core.key_simulator import MOUSEEVENTF_HWHEEL, MOUSEEVENTF_WHEEL +from core.key_simulator import inject_scroll as _inject_scroll_impl +from core.mouse_hook_base import BaseMouseHook, HidGestureListener +from core.mouse_hook_types import MouseEvent + +WH_MOUSE_LL = 14 +WM_XBUTTONDOWN = 0x020B +WM_XBUTTONUP = 0x020C +WM_MBUTTONDOWN = 0x0207 +WM_MBUTTONUP = 0x0208 +WM_MOUSEHWHEEL = 0x020E +WM_MOUSEWHEEL = 0x020A + +HC_ACTION = 0 +XBUTTON1 = 0x0001 +XBUTTON2 = 0x0002 + + +class MSLLHOOKSTRUCT(Structure): + _fields_ = [ + ("pt", wintypes.POINT), + ("mouseData", wintypes.DWORD), + ("flags", wintypes.DWORD), + ("time", wintypes.DWORD), + ("dwExtraInfo", ctypes.POINTER(ctypes.c_ulong)), + ] + + +HOOKPROC = CFUNCTYPE( + ctypes.c_long, + c_int, + wintypes.WPARAM, + ctypes.POINTER(MSLLHOOKSTRUCT), +) + +SetWindowsHookExW = windll.user32.SetWindowsHookExW +SetWindowsHookExW.restype = wintypes.HHOOK +SetWindowsHookExW.argtypes = [c_int, HOOKPROC, wintypes.HINSTANCE, wintypes.DWORD] + +CallNextHookEx = windll.user32.CallNextHookEx +CallNextHookEx.restype = ctypes.c_long +CallNextHookEx.argtypes = [ + wintypes.HHOOK, + c_int, + wintypes.WPARAM, + ctypes.POINTER(MSLLHOOKSTRUCT), +] + +UnhookWindowsHookEx = windll.user32.UnhookWindowsHookEx +UnhookWindowsHookEx.restype = wintypes.BOOL +UnhookWindowsHookEx.argtypes = [wintypes.HHOOK] + +GetModuleHandleW = windll.kernel32.GetModuleHandleW +GetModuleHandleW.restype = wintypes.HMODULE +GetModuleHandleW.argtypes = [wintypes.LPCWSTR] + +GetMessageW = windll.user32.GetMessageW +PostThreadMessageW = windll.user32.PostThreadMessageW + +WM_QUIT = 0x0012 +INJECTED_FLAG = 0x00000001 + +WM_INPUT = 0x00FF +RIDEV_INPUTSINK = 0x00000100 +RID_INPUT = 0x10000003 +RIM_TYPEMOUSE = 0 +RIM_TYPEKEYBOARD = 1 +RIM_TYPEHID = 2 +RIDI_DEVICENAME = 0x20000007 +SW_HIDE = 0 +STANDARD_BUTTON_MASK = 0x1F + + +class RAWINPUTDEVICE(Structure): + _fields_ = [ + ("usUsagePage", c_ushort), + ("usUsage", c_ushort), + ("dwFlags", c_ulong), + ("hwndTarget", wintypes.HWND), + ] + + +class RAWINPUTHEADER(Structure): + _fields_ = [ + ("dwType", c_ulong), + ("dwSize", c_ulong), + ("hDevice", c_void_p), + ("wParam", POINTER(c_ulong)), + ] + + +class RAWMOUSE(Structure): + _fields_ = [ + ("usFlags", c_ushort), + ("usButtonFlags", c_ushort), + ("usButtonData", c_ushort), + ("ulRawButtons", c_ulong), + ("lLastX", c_int), + ("lLastY", c_int), + ("ulExtraInformation", c_ulong), + ] + + +class RAWHID(Structure): + _fields_ = [ + ("dwSizeHid", c_ulong), + ("dwCount", c_ulong), + ] + + +WNDPROC_TYPE = CFUNCTYPE( + ctypes.c_longlong, + wintypes.HWND, + c_uint, + wintypes.WPARAM, + wintypes.LPARAM, +) + + +class WNDCLASSEXW(Structure): + _fields_ = [ + ("cbSize", c_uint), + ("style", c_uint), + ("lpfnWndProc", WNDPROC_TYPE), + ("cbClsExtra", c_int), + ("cbWndExtra", c_int), + ("hInstance", wintypes.HINSTANCE), + ("hIcon", wintypes.HICON), + ("hCursor", wintypes.HANDLE), + ("hbrBackground", wintypes.HBRUSH), + ("lpszMenuName", wintypes.LPCWSTR), + ("lpszClassName", wintypes.LPCWSTR), + ("hIconSm", wintypes.HICON), + ] + + +RegisterRawInputDevices = windll.user32.RegisterRawInputDevices +GetRawInputData = windll.user32.GetRawInputData +GetRawInputData.argtypes = [c_void_p, c_uint, c_void_p, POINTER(c_uint), c_uint] +GetRawInputData.restype = c_uint +GetRawInputDeviceInfoW = windll.user32.GetRawInputDeviceInfoW +RegisterClassExW = windll.user32.RegisterClassExW + +CreateWindowExW = windll.user32.CreateWindowExW +CreateWindowExW.restype = wintypes.HWND +CreateWindowExW.argtypes = [ + wintypes.DWORD, + wintypes.LPCWSTR, + wintypes.LPCWSTR, + wintypes.DWORD, + c_int, + c_int, + c_int, + c_int, + wintypes.HWND, + wintypes.HMENU, + wintypes.HINSTANCE, + wintypes.LPVOID, +] + +ShowWindow = windll.user32.ShowWindow +DefWindowProcW = windll.user32.DefWindowProcW +DefWindowProcW.restype = ctypes.c_longlong +DefWindowProcW.argtypes = [ + wintypes.HWND, + c_uint, + wintypes.WPARAM, + wintypes.LPARAM, +] + +TranslateMessage = windll.user32.TranslateMessage +DispatchMessageW = windll.user32.DispatchMessageW +DestroyWindow = windll.user32.DestroyWindow + + +def hiword(dword): + value = (dword >> 16) & 0xFFFF + if value >= 0x8000: + value -= 0x10000 + return value + + +WM_APP = 0x8000 +WM_APP_INJECT_VSCROLL = WM_APP + 1 +WM_APP_INJECT_HSCROLL = WM_APP + 2 + +WM_DEVICECHANGE = 0x0219 +DBT_DEVNODES_CHANGED = 0x0007 + +PostMessageW = windll.user32.PostMessageW +PostMessageW.argtypes = [wintypes.HWND, c_uint, wintypes.WPARAM, wintypes.LPARAM] +PostMessageW.restype = wintypes.BOOL + + +class MouseHook(BaseMouseHook): + """ + Installs a low-level mouse hook on Windows to intercept side-button clicks + and horizontal scroll events. + """ + + def __init__(self): + super().__init__() + self._hook = None + self._hook_thread = None + self._thread_id = None + self._running = False + self._hook_proc = None + self._pending_vscroll = 0 + self._pending_hscroll = 0 + self._vscroll_posted = False + self._hscroll_posted = False + self._ri_wndproc_ref = None + self._ri_hwnd = None + self._device_name_cache = {} + self._startup_event = threading.Event() + self._startup_ok = False + self._prev_raw_buttons = {} + self._last_rehook_time = 0 + self._dispatch_queue = queue.Queue() + self._dispatch_worker_thread = None + + def _accumulate_gesture_delta(self, delta_x, delta_y, source): + if not (self._gesture_direction_enabled and self._gesture_active): + return + if self._gesture_cooldown_active(): + self._emit_debug( + f"Gesture cooldown active source={source} dx={delta_x} dy={delta_y}" + ) + self._emit_gesture_event( + { + "type": "cooldown_active", + "source": source, + "dx": delta_x, + "dy": delta_y, + } + ) + return + if not self._gesture_tracking: + self._emit_debug(f"Gesture tracking started source={source}") + self._emit_gesture_event( + { + "type": "tracking_started", + "source": source, + } + ) + self._start_gesture_tracking() + + now = time.monotonic() + idle_ms = (now - self._gesture_last_move_at) * 1000.0 + if idle_ms > self._gesture_timeout_ms: + self._emit_debug( + f"Gesture segment reset timeout source={source} " + f"accum_x={self._gesture_delta_x} accum_y={self._gesture_delta_y}" + ) + self._start_gesture_tracking() + + if self._gesture_input_source not in (None, source): + self._emit_debug( + f"Gesture source locked to {self._gesture_input_source}; " + f"ignoring {source} dx={delta_x} dy={delta_y}" + ) + return + self._gesture_input_source = source + + self._gesture_delta_x += delta_x + self._gesture_delta_y += delta_y + self._gesture_last_move_at = now + self._emit_debug( + f"Gesture segment source={source} " + f"accum_x={self._gesture_delta_x} accum_y={self._gesture_delta_y}" + ) + self._emit_gesture_event( + { + "type": "segment", + "source": source, + "dx": self._gesture_delta_x, + "dy": self._gesture_delta_y, + } + ) + + gesture_event = self._detect_gesture_event() + if not gesture_event: + return + + self._gesture_triggered = True + self._emit_debug( + "Gesture detected " + f"{gesture_event} source={source} " + f"delta_x={self._gesture_delta_x} delta_y={self._gesture_delta_y}" + ) + self._emit_gesture_event( + { + "type": "detected", + "event_name": gesture_event, + "source": source, + "dx": self._gesture_delta_x, + "dy": self._gesture_delta_y, + } + ) + self._dispatch( + MouseEvent( + gesture_event, + { + "delta_x": self._gesture_delta_x, + "delta_y": self._gesture_delta_y, + "source": source, + }, + ) + ) + self._gesture_cooldown_until = ( + time.monotonic() + self._gesture_cooldown_ms / 1000.0 + ) + self._emit_debug( + f"Gesture cooldown started source={source} " + f"for_ms={self._gesture_cooldown_ms}" + ) + self._emit_gesture_event( + { + "type": "cooldown_started", + "source": source, + "for_ms": self._gesture_cooldown_ms, + } + ) + self._finish_gesture_tracking() + + _WM_NAMES = { + 0x0200: "WM_MOUSEMOVE", + 0x0201: "WM_LBUTTONDOWN", + 0x0202: "WM_LBUTTONUP", + 0x0204: "WM_RBUTTONDOWN", + 0x0205: "WM_RBUTTONUP", + 0x0207: "WM_MBUTTONDOWN", + 0x0208: "WM_MBUTTONUP", + 0x020A: "WM_MOUSEWHEEL", + 0x020B: "WM_XBUTTONDOWN", + 0x020C: "WM_XBUTTONUP", + 0x020E: "WM_MOUSEHWHEEL", + } + + def _low_level_handler(self, nCode, wParam, lParam): + try: + return self._low_level_handler_inner(nCode, wParam, lParam) + except Exception as exc: + try: + print(f"[MouseHook] CRITICAL _low_level_handler EXCEPTION: {exc}") + import traceback + + traceback.print_exc() + except Exception: + pass + return CallNextHookEx(self._hook, nCode, wParam, lParam) + + def _low_level_handler_inner(self, nCode, wParam, lParam): + if nCode == HC_ACTION: + data = lParam.contents + mouse_data = data.mouseData + flags = data.flags + event = None + should_block = False + + if self.debug_mode and self._debug_callback: + wm_name = self._WM_NAMES.get(wParam, f"0x{wParam:04X}") + if wParam != 0x0200: + extra = data.dwExtraInfo.contents.value if data.dwExtraInfo else 0 + info = ( + f"{wm_name} mouseData=0x{mouse_data:08X} " + f"hiword={hiword(mouse_data)} flags=0x{flags:04X} " + f"extraInfo=0x{extra:X}" + ) + try: + self._debug_callback(info) + except Exception: + pass + + if flags & INJECTED_FLAG: + return CallNextHookEx(self._hook, nCode, wParam, lParam) + + if wParam == WM_XBUTTONDOWN: + xbutton = hiword(mouse_data) + if xbutton == XBUTTON1: + event = MouseEvent(MouseEvent.XBUTTON1_DOWN) + should_block = MouseEvent.XBUTTON1_DOWN in self._blocked_events + elif xbutton == XBUTTON2: + event = MouseEvent(MouseEvent.XBUTTON2_DOWN) + should_block = MouseEvent.XBUTTON2_DOWN in self._blocked_events + + elif wParam == WM_XBUTTONUP: + xbutton = hiword(mouse_data) + if xbutton == XBUTTON1: + event = MouseEvent(MouseEvent.XBUTTON1_UP) + should_block = MouseEvent.XBUTTON1_UP in self._blocked_events + elif xbutton == XBUTTON2: + event = MouseEvent(MouseEvent.XBUTTON2_UP) + should_block = MouseEvent.XBUTTON2_UP in self._blocked_events + + elif wParam == WM_MBUTTONDOWN: + event = MouseEvent(MouseEvent.MIDDLE_DOWN) + should_block = MouseEvent.MIDDLE_DOWN in self._blocked_events + + elif wParam == WM_MBUTTONUP: + event = MouseEvent(MouseEvent.MIDDLE_UP) + should_block = MouseEvent.MIDDLE_UP in self._blocked_events + + elif wParam == WM_MOUSEWHEEL: + if self.invert_vscroll: + delta = hiword(mouse_data) + if delta != 0 and self._ri_hwnd: + self._pending_vscroll += -delta + if self._vscroll_posted: + return 1 + if PostMessageW(self._ri_hwnd, WM_APP_INJECT_VSCROLL, 0, 0): + self._vscroll_posted = True + return 1 + self._pending_vscroll -= -delta + elif delta != 0: + self._emit_debug( + "Invert vertical scroll skipped: raw input window unavailable" + ) + + elif wParam == WM_MOUSEHWHEEL: + delta = hiword(mouse_data) + if delta > 0: + event = MouseEvent(MouseEvent.HSCROLL_LEFT, abs(delta)) + should_block = MouseEvent.HSCROLL_LEFT in self._blocked_events + elif delta < 0: + event = MouseEvent(MouseEvent.HSCROLL_RIGHT, abs(delta)) + should_block = MouseEvent.HSCROLL_RIGHT in self._blocked_events + + if self.invert_hscroll: + if delta != 0 and self._ri_hwnd and not should_block: + self._pending_hscroll += -delta + if self._hscroll_posted: + return 1 + if PostMessageW(self._ri_hwnd, WM_APP_INJECT_HSCROLL, 0, 0): + self._hscroll_posted = True + return 1 + self._pending_hscroll -= -delta + elif delta != 0 and not should_block: + self._emit_debug( + "Invert horizontal scroll skipped: raw input window unavailable" + ) + + if event: + self._dispatch_queue.put(event) + if should_block: + return 1 + + return CallNextHookEx(self._hook, nCode, wParam, lParam) + + def _get_device_name(self, hDevice): + if hDevice in self._device_name_cache: + return self._device_name_cache[hDevice] + try: + size = c_uint(0) + GetRawInputDeviceInfoW(hDevice, RIDI_DEVICENAME, None, byref(size)) + if size.value > 0: + buffer = ctypes.create_unicode_buffer(size.value + 1) + GetRawInputDeviceInfoW(hDevice, RIDI_DEVICENAME, buffer, byref(size)) + name = buffer.value + else: + name = "" + except Exception: + name = "" + self._device_name_cache[hDevice] = name + return name + + def _is_logitech(self, hDevice): + return "046d" in self._get_device_name(hDevice).lower() + + def _ri_wndproc(self, hwnd, msg, wParam, lParam): + if msg == WM_INPUT: + try: + self._process_raw_input(lParam) + except Exception as exc: + print(f"[MouseHook] Raw Input error: {exc}") + return 0 + + if msg == WM_APP_INJECT_VSCROLL: + delta = self._pending_vscroll + self._pending_vscroll = 0 + self._vscroll_posted = False + if delta != 0: + _inject_scroll_impl(MOUSEEVENTF_WHEEL, delta) + return 0 + + if msg == WM_APP_INJECT_HSCROLL: + delta = self._pending_hscroll + self._pending_hscroll = 0 + self._hscroll_posted = False + if delta != 0: + _inject_scroll_impl(MOUSEEVENTF_HWHEEL, delta) + return 0 + + if msg == WM_DEVICECHANGE: + if wParam == DBT_DEVNODES_CHANGED: + self._on_device_change() + return 0 + + return DefWindowProcW(hwnd, msg, wParam, lParam) + + def _process_raw_input(self, lParam): + size = c_uint(0) + GetRawInputData(lParam, RID_INPUT, None, byref(size), sizeof(RAWINPUTHEADER)) + if size.value == 0: + return + buffer = create_string_buffer(size.value) + ret = GetRawInputData( + lParam, + RID_INPUT, + buffer, + byref(size), + sizeof(RAWINPUTHEADER), + ) + if ret == 0xFFFFFFFF: + return + header = RAWINPUTHEADER.from_buffer_copy(buffer) + if not self._is_logitech(header.hDevice): + return + if header.dwType == RIM_TYPEMOUSE: + self._check_raw_mouse_gesture(header.hDevice, buffer) + + def _check_raw_mouse_gesture(self, hDevice, buffer): + if self._hid_gesture_available(): + return + mouse = RAWMOUSE.from_buffer_copy(buffer, sizeof(RAWINPUTHEADER)) + raw_buttons = mouse.ulRawButtons + prev_buttons = self._prev_raw_buttons.get(hDevice, 0) + self._prev_raw_buttons[hDevice] = raw_buttons + + extra_now = raw_buttons & ~STANDARD_BUTTON_MASK + extra_prev = prev_buttons & ~STANDARD_BUTTON_MASK + + if extra_now == extra_prev: + return + if extra_now and not extra_prev: + if not self._gesture_active: + self._gesture_active = True + self._gesture_triggered = False + print(f"[MouseHook] Gesture DOWN (rawBtns extra: 0x{extra_now:X})") + elif not extra_now and extra_prev: + if self._gesture_active: + self._gesture_active = False + print("[MouseHook] Gesture UP") + self._dispatch(MouseEvent(MouseEvent.GESTURE_CLICK)) + + def _setup_raw_input(self): + instance = GetModuleHandleW(None) + class_name = f"MouserRawInput_{id(self)}" + self._ri_wndproc_ref = WNDPROC_TYPE(self._ri_wndproc) + + window_class = WNDCLASSEXW() + window_class.cbSize = sizeof(WNDCLASSEXW) + window_class.lpfnWndProc = self._ri_wndproc_ref + window_class.hInstance = instance + window_class.lpszClassName = class_name + RegisterClassExW(byref(window_class)) + + self._ri_hwnd = CreateWindowExW( + 0, + class_name, + "Mouser RI", + 0, + 0, + 0, + 1, + 1, + None, + None, + instance, + None, + ) + if not self._ri_hwnd: + print("[MouseHook] CreateWindowExW failed — gesture detection unavailable") + return False + + ShowWindow(self._ri_hwnd, SW_HIDE) + + devices = (RAWINPUTDEVICE * 4)() + devices[0].usUsagePage = 0x01 + devices[0].usUsage = 0x02 + devices[0].dwFlags = RIDEV_INPUTSINK + devices[0].hwndTarget = self._ri_hwnd + devices[1].usUsagePage = 0xFF43 + devices[1].usUsage = 0x0202 + devices[1].dwFlags = RIDEV_INPUTSINK + devices[1].hwndTarget = self._ri_hwnd + devices[2].usUsagePage = 0xFF43 + devices[2].usUsage = 0x0204 + devices[2].dwFlags = RIDEV_INPUTSINK + devices[2].hwndTarget = self._ri_hwnd + devices[3].usUsagePage = 0x0C + devices[3].usUsage = 0x01 + devices[3].dwFlags = RIDEV_INPUTSINK + devices[3].hwndTarget = self._ri_hwnd + + if RegisterRawInputDevices(devices, 4, sizeof(RAWINPUTDEVICE)): + print("[MouseHook] Raw Input: mice + Logitech HID + consumer") + return True + if RegisterRawInputDevices(devices, 2, sizeof(RAWINPUTDEVICE)): + print("[MouseHook] Raw Input: mice + Logitech HID short") + return True + if RegisterRawInputDevices(devices, 1, sizeof(RAWINPUTDEVICE)): + print("[MouseHook] Raw Input: mice only") + return True + print("[MouseHook] Raw Input registration failed") + return False + + def _dispatch_worker(self): + while self._running: + try: + event = self._dispatch_queue.get(timeout=0.05) + except queue.Empty: + continue + try: + self._dispatch(event) + except Exception as exc: + print(f"[MouseHook] dispatch worker error: {exc}") + + def _run_hook(self): + self._thread_id = windll.kernel32.GetCurrentThreadId() + self._hook_proc = HOOKPROC(self._low_level_handler) + self._hook = SetWindowsHookExW( + WH_MOUSE_LL, + self._hook_proc, + GetModuleHandleW(None), + 0, + ) + if not self._hook: + self._startup_ok = False + self._startup_event.set() + print("[MouseHook] Failed to install hook!") + return + print("[MouseHook] Hook installed successfully") + self._setup_raw_input() + self._running = True + self._startup_ok = True + self._startup_event.set() + + message = wintypes.MSG() + while self._running: + result = GetMessageW(ctypes.byref(message), None, 0, 0) + if result == 0 or result == -1: + break + TranslateMessage(ctypes.byref(message)) + DispatchMessageW(ctypes.byref(message)) + + if self._ri_hwnd: + DestroyWindow(self._ri_hwnd) + self._ri_hwnd = None + if self._hook: + UnhookWindowsHookEx(self._hook) + self._hook = None + self._running = False + print("[MouseHook] Hook removed") + + def _on_device_change(self): + now = time.time() + if now - self._last_rehook_time < 2.0: + return + self._last_rehook_time = now + print("[MouseHook] Device change detected — refreshing hook") + self._device_name_cache.clear() + self._prev_raw_buttons.clear() + self._reinstall_hook() + + def _reinstall_hook(self): + if self._hook: + UnhookWindowsHookEx(self._hook) + self._hook = None + self._hook_proc = HOOKPROC(self._low_level_handler) + self._hook = SetWindowsHookExW( + WH_MOUSE_LL, + self._hook_proc, + GetModuleHandleW(None), + 0, + ) + if self._hook: + print("[MouseHook] Hook reinstalled successfully") + else: + print("[MouseHook] Failed to reinstall hook!") + + def _on_hid_gesture_down(self): + if not self._gesture_active: + self._gesture_active = True + self._gesture_triggered = False + self._emit_debug("HID gesture button down") + self._emit_gesture_event({"type": "button_down"}) + if self._gesture_direction_enabled and not self._gesture_cooldown_active(): + self._start_gesture_tracking() + else: + self._gesture_tracking = False + self._gesture_triggered = False + + def _on_hid_gesture_up(self): + if self._gesture_active: + should_click = not self._gesture_triggered + self._gesture_active = False + self._finish_gesture_tracking() + self._gesture_triggered = False + self._emit_debug( + f"HID gesture button up click_candidate={str(should_click).lower()}" + ) + self._emit_gesture_event( + { + "type": "button_up", + "click_candidate": should_click, + } + ) + if should_click: + self._dispatch(MouseEvent(MouseEvent.GESTURE_CLICK)) + + def _on_hid_mode_shift_down(self): + self._emit_debug("HID mode shift button down") + self._dispatch(MouseEvent(MouseEvent.MODE_SHIFT_DOWN)) + + def _on_hid_mode_shift_up(self): + self._emit_debug("HID mode shift button up") + self._dispatch(MouseEvent(MouseEvent.MODE_SHIFT_UP)) + + def _on_hid_dpi_switch_down(self): + self._emit_debug("HID DPI switch button down") + self._dispatch(MouseEvent(MouseEvent.DPI_SWITCH_DOWN)) + + def _on_hid_dpi_switch_up(self): + self._emit_debug("HID DPI switch button up") + self._dispatch(MouseEvent(MouseEvent.DPI_SWITCH_UP)) + + def _on_hid_gesture_move(self, delta_x, delta_y): + self._emit_debug(f"HID rawxy move dx={delta_x} dy={delta_y}") + self._emit_gesture_event( + { + "type": "move", + "source": "hid_rawxy", + "dx": delta_x, + "dy": delta_y, + } + ) + self._accumulate_gesture_delta(delta_x, delta_y, "hid_rawxy") + + def start(self): + if self._hook_thread and self._hook_thread.is_alive(): + return True + self._startup_ok = False + self._startup_event.clear() + self._hook_thread = threading.Thread(target=self._run_hook, daemon=True) + self._hook_thread.start() + if not self._startup_event.wait(2): + print("[MouseHook] Hook startup timed out") + self.stop() + return False + if not self._startup_ok: + return False + self._start_hid_listener() + self._dispatch_worker_thread = threading.Thread( + target=self._dispatch_worker, + daemon=True, + name="HookDispatch", + ) + self._dispatch_worker_thread.start() + return True + + def stop(self): + self._running = False + self._stop_hid_listener() + self._connected_device = None + if self._dispatch_worker_thread: + self._dispatch_worker_thread.join(timeout=1) + self._dispatch_worker_thread = None + if self._thread_id: + PostThreadMessageW(self._thread_id, WM_QUIT, 0, 0) + if self._hook_thread: + self._hook_thread.join(timeout=2) + self._hook = None + self._ri_hwnd = None + self._thread_id = None + self._startup_ok = False + self._startup_event.clear() + + +MouseHook._platform_module = sys.modules[__name__] + + +__all__ = [ + "MouseHook", + "HidGestureListener", + "MSLLHOOKSTRUCT", + "WM_XBUTTONDOWN", + "WM_XBUTTONUP", + "WM_MBUTTONDOWN", + "WM_MBUTTONUP", + "WM_MOUSEHWHEEL", + "WM_MOUSEWHEEL", +] diff --git a/tests/test_mouse_hook_contract.py b/tests/test_mouse_hook_contract.py new file mode 100644 index 00000000..254a452d --- /dev/null +++ b/tests/test_mouse_hook_contract.py @@ -0,0 +1,58 @@ +import sys +import unittest + +from core import mouse_hook +from core.mouse_hook_contract import MouseHookLike +from core.mouse_hook_types import MouseEvent + + +class MouseHookContractTests(unittest.TestCase): + def test_core_mouse_hook_reexports_mousehook_and_mouseevent(self): + self.assertIs(mouse_hook.MouseEvent, MouseEvent) + self.assertTrue(hasattr(mouse_hook, "MouseHook")) + + def test_dispatcher_selects_current_platform_module(self): + expected = { + "darwin": "core.mouse_hook_macos", + "linux": "core.mouse_hook_linux", + "win32": "core.mouse_hook_windows", + }.get(sys.platform, "core.mouse_hook_stub") + self.assertEqual(mouse_hook.MouseHook.__module__, expected) + + def test_selected_hook_exposes_engine_contract_surface(self): + hook = mouse_hook.MouseHook() + self.assertIsInstance(hook, MouseHookLike) + + def test_dispatcher_monkeypatch_forwards_to_platform_module(self): + platform_module = sys.modules[mouse_hook.MouseHook.__module__] + + if sys.platform == "darwin": + original = getattr(platform_module, "Quartz", None) + sentinel = object() + mouse_hook.Quartz = sentinel + try: + self.assertIs(platform_module.Quartz, sentinel) + self.assertIs(mouse_hook.Quartz, sentinel) + finally: + if original is None: + del mouse_hook.Quartz + else: + mouse_hook.Quartz = original + elif sys.platform == "linux": + original = getattr(platform_module, "_InputDevice", None) + sentinel = object() + mouse_hook._InputDevice = sentinel + try: + self.assertIs(platform_module._InputDevice, sentinel) + self.assertIs(mouse_hook._InputDevice, sentinel) + finally: + if original is None: + del mouse_hook._InputDevice + else: + mouse_hook._InputDevice = original + else: + self.skipTest("No platform-specific forwarding probe for this platform") + + +if __name__ == "__main__": + unittest.main() From 1d60ac0dcd95f1f86cd91a7d273b0d068f885bd8 Mon Sep 17 00:00:00 2001 From: asafzenou <68349855+asafzenou@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:54:09 +0300 Subject: [PATCH 13/17] Add architecture diagram to README - replace to mermaid chart (#131) * Add architecture diagram to README - replace to mermaid chart Added a mermaid diagram to illustrate the architecture of the application. * Improve styling in README for better clarity Updated styling for components in the README for better readability. * Refactor architecture diagram in README.md --- README.md | 46 +++++++++++++++++++++++++++++----------------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 83e6a60d..55885983 100644 --- a/README.md +++ b/README.md @@ -329,23 +329,35 @@ The output is in `dist/Mouser/`. Zip that entire folder and distribute it. ### Architecture -``` -┌────────────────┐ ┌──────────┐ ┌────────────────┐ -│ Logitech mouse │────▶│ Mouse │────▶│ Engine │ -│ / HID++ device │ │ Hook │ │ (orchestrator) │ -└────────────────┘ └──────────┘ └───────┬────────┘ - ▲ │ - block/pass ┌────▼────────┐ - │ Key │ -┌─────────────┐ ┌──────────┐ │ Simulator │ -│ QML UI │◀───▶│ Backend │ │ (SendInput) │ -│ (PySide6) │ │ (QObject)│ └─────────────┘ -└─────────────┘ └──────────┘ - ▲ - ┌────┴────────┐ - │ App │ - │ Detector │ - └─────────────┘ +```mermaid +graph LR + %% Nodes + Mouse["Logitech Mouse / HID++ Device"] + Hook["Mouse Hook"] + Engine["Engine (Orchestrator)"] + Simulator["Key Simulator (SendInput)"] + Backend["Backend (QObject)"] + UI["QML UI (PySide6)"] + Detector["App Detector"] + + %% Connections + Mouse --> Hook + Hook --> Engine + Engine -- "block/pass" --> Hook + Engine --> Simulator + + Engine <--> Backend + Backend <--> UI + Detector --> Backend + + %% Styling for better readability + style Engine fill:#e8eaff,stroke:#4f46e5,stroke-width:2px,color:#000 + style UI fill:#e1f9f0,stroke:#059669,stroke-width:2px,color:#000 + style Mouse fill:#fff7ed,stroke:#d97706,stroke-width:2px,color:#000 + style Hook fill:#f3f4f6,stroke:#374151,color:#000 + style Simulator fill:#f3f4f6,stroke:#374151,color:#000 + style Backend fill:#f3f4f6,stroke:#374151,color:#000 + style Detector fill:#f3f4f6,stroke:#374151,color:#000 ``` ### Mouse Hook (`mouse_hook.py` + `mouse_hook_*.py`) From b5535d0162b5b4d4d1922a4a1912add51e399e0a Mon Sep 17 00:00:00 2001 From: Luca <100935601+thisislvca@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:46:45 +0200 Subject: [PATCH 14/17] docs: refresh README, Chinese README, and dev guide (#140) * docs: clean up readme documentation * docs: compact readme screenshots * docs: refresh chinese readme --- DEVELOPMENT.md | 268 +++++++++++++++++++- README.md | 598 ++++++++++++++++++-------------------------- README_CN.md | 656 +++++++++++++++++-------------------------------- 3 files changed, 732 insertions(+), 790 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index ad4bb5b2..ac5ec7c6 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,8 +1,28 @@ # Development Guide -This document contains technical details designed to help new developers understand the core components of the Mouser project. +This document contains the technical details a developer needs to navigate Mouser. The user-facing tour lives in [README.md](README.md); this guide covers how the codebase is wired together, how the platform-specific hooks behave, and how to build / debug locally. -## Development Setup +## Contents + +- [Development setup](#development-setup) +- [Architecture](#architecture) +- [Entry point: `main_qml.py`](#entry-point-main_qmlpy) +- [How it works](#how-it-works) + - [Mouse hook](#mouse-hook) + - [Device catalog & layout registry](#device-catalog--layout-registry) + - [Gesture button detection](#gesture-button-detection) + - [App detector](#app-detector) + - [Engine](#engine) + - [Device reconnection](#device-reconnection) + - [Configuration](#configuration) +- [UI overview](#ui-overview) +- [Project structure](#project-structure) +- [CLI flags & debug overrides](#cli-flags--debug-overrides) +- [Build internals](#build-internals) +- [Desktop shortcut (Windows)](#desktop-shortcut-windows) +- [Debugging tips](#debugging-tips) + +## Development setup Install the project dependencies before running the app or test suite. Do not rely on the system Python unless it already has the requirements installed. @@ -64,3 +84,247 @@ python -m unittest discover -s tests - **macOS System Tray Contrast:** The system tray icon provides two different SVGs (black and white) marked as `Normal` and `Selected`. This macOS-specific trick ensures the menu bar icon automatically inverts color appropriately when the user selects it or toggles dark/light mode. - **macOS Debugging (`SIGUSR1`):** A custom signal handler `signal.signal(signal.SIGUSR1, _dump_threads)` is registered, providing developers a hidden way to dump all thread stack traces directly to the terminal via `kill -SIGUSR1 `. This is highly useful for debugging cross-thread freezing bugs without a debugger attached. - **Startup Benchmarks:** Explicit timing logic (`_t0`, `_t1`, ..., `_t8`) is used to profile startup times. Because importing heavy UI frameworks like Qt in Python can be slow, this enforces performance budgets. + +## Architecture + +```mermaid +graph LR + Mouse["Logitech Mouse / HID++ Device"] + Hook["Mouse Hook"] + Engine["Engine (Orchestrator)"] + Simulator["Key Simulator (SendInput / CGEvent / uinput)"] + Backend["Backend (QObject)"] + UI["QML UI (PySide6)"] + Detector["App Detector"] + + Mouse --> Hook + Hook --> Engine + Engine -- "block/pass" --> Hook + Engine --> Simulator + Engine <--> Backend + Backend <--> UI + Detector --> Backend +``` + +The arrows match the runtime call graph: the OS-level mouse hook feeds events into the `Engine`, which decides whether to suppress and rewrite them (firing `Key Simulator`) or pass them through. Connection state and device identity flow back through `Backend` and into QML so the UI stays in sync. + +## How it works + +### Mouse hook + +Mouser exposes a single `MouseHook` façade in [`core/mouse_hook.py`](core/mouse_hook.py) and dispatches to a per-platform implementation: + +- **Windows** — [`core/mouse_hook_windows.py`](core/mouse_hook_windows.py): `SetWindowsHookExW` with `WH_MOUSE_LL` on a dedicated background thread, plus Raw Input for extra mouse data. +- **macOS** — [`core/mouse_hook_macos.py`](core/mouse_hook_macos.py): `CGEventTap` for interception and Quartz events for key simulation. The callback is wrapped with `@_autoreleased` to recycle Foundation objects every event (closing a ~1.4 GB leak that appeared under load) and the tap auto re-enables itself when the system disables it on timeout. +- **Linux** — [`core/mouse_hook_linux.py`](core/mouse_hook_linux.py): `evdev` to grab the physical mouse and `uinput` to forward pass-through events through a virtual device. +- **Stub** — [`core/mouse_hook_stub.py`](core/mouse_hook_stub.py): inert hook for unsupported platforms / smoke tests. + +The shared base + types live in [`core/mouse_hook_base.py`](core/mouse_hook_base.py), [`core/mouse_hook_contract.py`](core/mouse_hook_contract.py), and [`core/mouse_hook_types.py`](core/mouse_hook_types.py). + +All paths feed the same internal event model and intercept: + +- `WM_XBUTTONDOWN/UP` — side buttons (back / forward) +- `WM_MBUTTONDOWN/UP` — middle click +- `WM_MOUSEHWHEEL` — horizontal scroll +- `WM_MOUSEWHEEL` — vertical scroll (for inversion) + +Intercepted events are either **blocked** (hook returns `1`) and replaced with an action, or **passed through** to the foreground application. Synthetic events Mouser injects itself are tagged so the hook ignores them on the way back in (Windows uses an event marker; macOS uses `kCGEventSourceUserData`). + +### Device catalog & layout registry + +- [`core/logi_devices.py`](core/logi_devices.py) resolves known product IDs and model aliases into a `ConnectedDeviceInfo` record with display name, DPI range, preferred gesture CIDs, and default UI layout key. +- [`core/device_layouts.py`](core/device_layouts.py) stores image assets, hotspot coordinates, layout notes, and whether a layout is interactive or only a generic fallback. `_FAMILY_FALLBACKS` maps per-model keys (`mx_master_4`, `mx_anywhere_3s`, …) to family layout keys until a dedicated overlay exists. +- [`ui/backend.py`](ui/backend.py) combines auto-detected device info with any persisted per-device layout override and exposes the effective layout to QML. + +### Gesture button detection + +Logitech gesture / thumb buttons do not always appear as standard mouse events. Mouser uses a layered detector inside [`core/hid_gesture.py`](core/hid_gesture.py): + +1. **HID++ 2.0 (primary)** — opens the Logitech HID collection, discovers `REPROG_CONTROLS_V4` (feature `0x1B04`), ranks gesture CID candidates from the device registry plus control-capability heuristics, and diverts the best candidate. When supported, RawXY movement data is also enabled. +2. **Raw Input (Windows fallback)** — registers for raw mouse input and detects extra button bits beyond the standard 5. +3. **Gesture tap / swipe dispatch** — a clean press/release emits `gesture_click`; once movement crosses the configured threshold, Mouser emits directional swipe actions instead. + +The same module owns the SmartShift integration. It prefers the enhanced feature `0x2111` (`FEAT_SMART_SHIFT_ENHANCED`) when available and falls back to `0x2110`, exposing both an enable flag and a sensitivity threshold; pending settings are re-applied on every reconnect (including wake-from-sleep). + +### App detector + +[`core/app_detector.py`](core/app_detector.py) polls the foreground window every 300ms. + +- **Windows:** `GetForegroundWindow` → `GetWindowThreadProcessId` → process name. UWP apps are resolved via `ApplicationFrameHost.exe` to the actual child process. +- **macOS:** `NSWorkspace.frontmostApplication`. +- **Linux:** `xdotool` (X11) and `kdotool` (KDE Wayland). Other Wayland compositors fall back to the default profile. + +### Engine + +[`core/engine.py`](core/engine.py) is the orchestrator. On app change, it performs a **lightweight profile switch** — clears and re-wires hook callbacks without tearing down the hook thread or HID++ connection. This avoids the latency and instability of a full hook restart. The engine also forwards connected-device identity to the backend so QML can render the right model name and layout state, and routes mouse-injection actions (`mouse_left_click`, `mouse_right_click`, …) through `inject_mouse_down` / `inject_mouse_up`. + +### Device reconnection + +Mouser handles mouse power-off / on cycles automatically: + +- **HID++ layer** — `HidGestureListener` detects device disconnection (read errors) and enters a reconnect loop, retrying every 2–5 seconds until the device returns. Pending SmartShift / scroll-mode settings are replayed on reconnect. +- **Hook layer** — `MouseHook` listens for `WM_DEVICECHANGE` (Windows) and platform equivalents elsewhere, reinstalling the low-level hook when devices are added or removed. +- **UI layer** — connection state and device identity flow from HID++ → MouseHook → Engine → Backend (cross-thread safe via Qt signals) → QML, updating the status badge, device name, and active layout in real time. + +### Configuration + +All settings live in `config.json` under the platform config dir (`%APPDATA%\Mouser`, `~/Library/Application Support/Mouser`, `~/.config/Mouser`). The schema supports: + +- Multiple named profiles with per-profile button mappings, including gesture tap + swipe actions +- Per-profile app associations (list of `.exe` / bundle / process names) +- Global settings: DPI, scroll inversion, macOS trackpad filtering, gesture tuning, appearance, debug flags, Smart Shift mode + sensitivity, language, and startup preferences (`start_at_login`, `start_minimized`) +- Per-device layout override selections for unsupported devices +- Automatic migration from older config versions (current version `9`) + +Logs are written via [`core/log_setup.py`](core/log_setup.py) to a 5 × 5 MB rotating file in `~/Library/Logs/Mouser`, `%APPDATA%\Mouser\logs`, or `$XDG_STATE_HOME/Mouser/logs`. The setup is idempotent and safe to call multiple times — `main_qml.py` invokes it before any Qt or core import so startup output is captured from the very first line. + +## UI overview + +Two pages accessible from a slim sidebar in [`ui/qml/Main.qml`](ui/qml/Main.qml): + +### Mouse & profiles + +- **Left panel** — list of profiles. The "Default (All Apps)" profile is always present. Per-app profiles show the app icon and name. Selecting a profile binds it as the active editing target. +- **Right panel** — device-aware mouse view. MX Master family devices get clickable hotspot dots on the image; unsupported layouts fall back to a generic device card with an experimental "try another supported map" picker. +- **Add profile** — combo box at the bottom lists known apps (Chrome, Edge, VS Code, VLC, etc.). Click `+` to create a per-app profile. + +### Point & scroll + +- **DPI slider** — 200 to the device max 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. +- **Ignore trackpad (macOS)** — keep trackpad and Magic Mouse continuous scroll out of Mouser mappings. Disable only if you intentionally want Mouser to handle them. +- **Smart Shift** — toggle ratchet ↔ free-spin (HID++ `0x2111`) plus a sensitivity threshold; status syncs every 15 s and on every reconnect. +- **Startup controls** — **Start at login** (Windows + macOS) and **Start minimized** (all platforms). + +The window itself is resizable: default 1060 × 700 with a 920 × 620 minimum (`ApplicationWindow` in [`ui/qml/Main.qml`](ui/qml/Main.qml)). Inner pages use `Layout.fillWidth` / `Layout.fillHeight`, so panels reflow as the window grows. + +## Project structure + +``` +mouser/ +├── main_qml.py # Application entry point (PySide6 + QML) +├── Mouser.bat # Quick-launch batch file +├── Mouser.spec / Mouser-mac.spec / Mouser-linux.spec # PyInstaller specs +├── build.bat # Windows build (installs deps, verifies hidapi, packages) +├── build_macos_app.sh # macOS bundle build + icon/signing flow +├── packaging/linux/ # 69-mouser-logitech.rules + install-linux-permissions.sh +├── .github/workflows/ +│ ├── ci.yml # CI checks (compile, tests, QML lint) +│ └── release.yml # Automated release builds (Windows / macOS arm64+intel / Linux) +├── README.md / README_CN.md / readme_mac_osx.md / CONTRIBUTING_DEVICES.md / DEVELOPMENT.md +├── requirements.txt +│ +├── core/ # Backend logic +│ ├── accessibility.py # macOS Accessibility trust checks +│ ├── app_catalog.py # Known apps + per-profile metadata +│ ├── app_detector.py # Foreground app polling +│ ├── config.py # Config manager (JSON load/save/migrate) +│ ├── device_layouts.py # Device-family layout registry for QML overlays +│ ├── engine.py # Core engine — wires hook ↔ simulator ↔ config +│ ├── hid_gesture.py # HID++ 2.0 gesture button + SmartShift (0x2110/0x2111) +│ ├── key_simulator.py # Platform-specific action simulator +│ ├── linux_permissions.py # hidraw / event / uinput permission report +│ ├── log_setup.py # Rotating file log + stdout redirection +│ ├── logi_devices.py # Known Logitech device catalog + connected-device metadata +│ ├── mouse_hook.py # Platform dispatcher façade +│ ├── mouse_hook_base.py # Shared base class +│ ├── mouse_hook_contract.py # Hook protocol / type stubs +│ ├── mouse_hook_types.py # Event enums +│ ├── mouse_hook_windows.py # WH_MOUSE_LL + Raw Input +│ ├── mouse_hook_macos.py # CGEventTap + Quartz +│ ├── mouse_hook_linux.py # evdev + uinput +│ ├── mouse_hook_stub.py # Inert hook (unsupported platforms / tests) +│ ├── startup.py # Login startup (Windows registry + macOS LaunchAgent) +│ └── version.py # APP_VERSION / commit / build mode +│ +├── ui/ # UI layer +│ ├── backend.py # QML ↔ Python bridge (QObject) +│ ├── locale_manager.py # en / zh_CN / zh_TW translations + button/action labels +│ └── qml/ +│ ├── Main.qml # App shell (sidebar + page stack + tray toast) +│ ├── MousePage.qml # Merged mouse diagram + profile manager +│ ├── ScrollPage.qml # DPI slider + scroll/SmartShift toggles +│ ├── KeyCaptureDialog.qml # Custom shortcut recorder +│ ├── HotspotDot.qml # Interactive button overlay on mouse image +│ ├── ActionChip.qml # Selectable action pill +│ ├── AppIcon.qml # File-icon helper for known apps +│ └── Theme.js # Shared colors and constants +│ +├── tests/ # unittest suite (logi_devices, hid_gesture, engine, hooks, …) +└── images/ # Logos, app icons, mouse diagrams, screenshots +``` + +## CLI flags & debug overrides + +Parsed in [`main_qml.py`](main_qml.py) (`_parse_cli_args`): + +| Flag | Behavior | +|---|---| +| `--start-hidden` | Boot directly into the tray / menu bar; combined with the `start_minimized` config preference. | +| `--hid-backend=` | Force a specific HID transport. macOS defaults to `iokit`; other platforms default to `auto`. Use only for debugging. | + +Example: + +```bash +python main_qml.py --hid-backend=hidapi +python main_qml.py --start-hidden +``` + +## Build internals + +### Windows + +```powershell +build.bat # standard packaged build (installs deps, verifies hidapi, runs PyInstaller) +build.bat --clean # nuke build/ and dist/ before rebuilding + +# Manual path +pip install -r requirements.txt pyinstaller +pyinstaller Mouser.spec --noconfirm +``` + +`build.bat` fails early if `hidapi` is not importable, which prevents shipping a build that cannot detect Logitech devices. Output: `dist\Mouser\` — zip the folder for distribution. + +### macOS + +```bash +pip install -r requirements.txt pyinstaller +./build_macos_app.sh +``` + +The script reuses `images/AppIcon.icns` when present, otherwise generates one from `images/logo_icon.png`, runs PyInstaller with `Mouser-mac.spec`, and ad-hoc signs the bundle (`codesign --sign -`). Output: `dist/Mouser.app`. The bundle runs as `LSUIElement`. + +- Build on the architecture you want to ship. `arm64` Python → Apple Silicon, `x86_64` Python → Intel. +- Set `PYINSTALLER_TARGET_ARCH=arm64|x86_64|universal2` to override (when your Python supports the target). +- Release CI publishes both `Mouser-macOS.zip` and `Mouser-macOS-intel.zip`. + +### Linux + +```bash +sudo apt-get install libhidapi-dev +pip install pyinstaller +pyinstaller Mouser-linux.spec --noconfirm +``` + +Output: `dist/Mouser/`. The release pipeline additionally bundles `69-mouser-logitech.rules` and `install-linux-permissions.sh`, runs `ldd` on the resulting binary to flag missing libraries, and performs an offscreen smoke test (`QT_QPA_PLATFORM=offscreen`). + +## Desktop shortcut (Windows) + +Create a `Mouser.lnk` shortcut that launches via `pythonw.exe` if you want to run from source without a console window: + +```powershell +$s = (New-Object -ComObject WScript.Shell).CreateShortcut("$([Environment]::GetFolderPath('Desktop'))\Mouser.lnk") +$s.TargetPath = "C:\path\to\mouser\.venv\Scripts\pythonw.exe" +$s.Arguments = "main_qml.py" +$s.WorkingDirectory = "C:\path\to\mouser" +$s.IconLocation = "C:\path\to\mouser\images\logo.ico, 0" +$s.Save() +``` + +## Debugging tips + +- **Thread dump:** `kill -USR1 $(pgrep -f main_qml.py)` triggers `_dump_threads` and prints all stack traces to the terminal — useful for cross-thread freezes without an attached debugger. +- **Startup timing:** `_t0`–`_t8` markers in `main_qml.py` log per-phase startup costs (env setup, PySide6 imports, core imports). Watch for regressions when adding heavy imports. +- **HID transport override:** `--hid-backend=iokit|hidapi|auto` lets you isolate transport-specific bugs (e.g. Bolt receivers, BLE quirks). +- **Logs:** `~/Library/Logs/Mouser/mouser.log`, `%APPDATA%\Mouser\logs\mouser.log`, or `$XDG_STATE_HOME/Mouser/logs/mouser.log`. Stdout is redirected through the rotating file handler; stderr is preserved so logging-handler errors don't recurse. +- **Linux permissions:** [`core/linux_permissions.py`](core/linux_permissions.py) emits a `LinuxPermissionReport` describing which `/dev/hidraw*`, `/dev/input/event*`, and `/dev/uinput` nodes are blocked. Mouser surfaces this via the UI banner and the log. diff --git a/README.md b/README.md index 55885983..c5a750cb 100644 --- a/README.md +++ b/README.md @@ -8,95 +8,26 @@ English | [中文文档](README_CN.md) A lightweight, open-source, fully local alternative to **Logitech Options+** for remapping Logitech HID++ mice. The current best experience is on the **MX Master** -family, with early detection and fallback UI support for additional Logitech models. +family, with detection and fallback UI support for additional Logitech models. -No telemetry. No cloud. No Logitech account required. +**No telemetry. No cloud. No Logitech account required.** --- -## Features - -### 🖱️ Button Remapping -- **Remap any programmable button** — middle click, gesture button, back, forward, mode shift, and horizontal scroll -- **Per-application profiles** — automatically switch mappings when you switch apps (e.g., Chrome vs. VS Code) -- **Custom keyboard shortcuts** — define arbitrary key combinations (e.g., Ctrl+Shift+P) as button actions -- **30+ built-in actions** — navigation, browser, editing, media, and desktop shortcuts that adapt per platform - -### ⚙️ Device Control -- **DPI / pointer speed** — slider from 200–8000 DPI with quick presets, synced live via HID++ -- **Smart Shift toggle** — enable or disable Logitech's ratchet-to-free-spin scroll mode switching -- **Scroll direction inversion** — independent toggles for vertical and horizontal scroll -- **Gesture button + swipe actions** — tap for one action, swipe up/down/left/right for others - -### 🖥️ 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 -- **Single instance guard** — launching a second copy brings the existing window to the front - -### 🔌 Smart Connectivity -- **Bluetooth and Logi Bolt** — works with both Bluetooth and Logi Bolt USB receivers; connection type shown in the UI -- **Auto-reconnection** — detects power-off/on and restores full functionality without restarting -- **Live connection status** — real-time "Connected" / "Not Connected" badge in the UI -- **Device-aware UI** — interactive MX Master diagram with clickable hotspots; generic fallback for other models - -### 🌐 Multi-Language UI -- **English / Simplified Chinese / Traditional Chinese** — switch instantly in-app, no restart required -- Language preference is automatically saved to `config.json` and restored on next launch -- Covers all major UI surfaces: navigation, mouse page, settings page, dialogs, system tray/menu bar, and permission prompts - -### 🤖 CLI -- **CLI for text-based configuration** — Run `python main_cli.py -h` for details - -### 🛡️ Privacy First -- **Fully local** — config is a JSON file, all processing happens on your machine -- **System tray / menu bar** — runs quietly in the background with quick access from the tray -- **Zero telemetry, zero cloud, zero account required** - -## Screenshots - -

- Mouser — Mouse & Profiles page -

- -

- Mouser — Point & Scroll settings -

- -## Current Device Coverage - -| Family / model | Detection + HID++ probing | UI support | -|---|---|---| -| MX Master 4 / 3S / 3 / 2S / MX Master | Yes | Dedicated interactive `mx_master` layout | -| MX Anywhere 3S / 3 / 2S | Yes | Generic fallback card, experimental manual override | -| MX Vertical | Yes | Generic fallback card | -| Unknown Logitech HID++ mice | Best effort by PID/name | Generic fallback card | - -> **Note:** Only the MX Master family currently has a dedicated visual overlay. Other devices can still be detected, show their model name in the UI, and try the experimental layout override picker, but button positions may not line up until a real overlay is added. - -## Default Mappings - -| Button | Default Action | -|---|---| -| Back button | Alt + Tab (Switch Windows) | -| Forward button | Alt + Tab (Switch Windows) | -| Middle click | Pass-through | -| Gesture button | Pass-through | -| Mode shift (scroll click) | Pass-through | -| Horizontal scroll left | Browser Back | -| Horizontal scroll right | Browser Forward | - -## Available Actions - -Action labels adapt by platform. For example, Windows exposes `Win+D` and `Task View`, while macOS exposes `Mission Control`, `Show Desktop`, `App Expose`, and `Launchpad`. - -| 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) | -| **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 | -| **Custom** | User-defined keyboard shortcuts (any key combination) | -| **Other** | Do Nothing (pass-through) | +## Contents + +- [Download & Run](#download--run) +- [Screenshots](#screenshots) +- [Features](#features) +- [Device coverage](#device-coverage) +- [Default mappings](#default-mappings) +- [Available actions](#available-actions) +- [Build from source](#build-from-source) +- [Limitations](#limitations) +- [Roadmap](#roadmap) +- [Contributing](#contributing) +- [Acknowledgments](#acknowledgments) +- [License](#license) --- @@ -121,61 +52,160 @@ Action labels adapt by platform. For example, Windows exposes `Win+D` and `Task Downloads

-### Steps +1. Open the [**latest release page**](https://github.com/TomBadash/Mouser/releases/latest). +2. Download the zip for your platform: + - **Windows** — `Mouser-Windows.zip` + - **macOS (Apple Silicon)** — `Mouser-macOS.zip` + - **macOS (Intel)** — `Mouser-macOS-intel.zip` + - **Linux** — `Mouser-Linux.zip` +3. Extract it anywhere (Desktop, Documents, `/Applications`, wherever). +4. Run the executable: `Mouser.exe`, `Mouser.app`, or `./Mouser`. -1. Go to the [**latest release page**](https://github.com/TomBadash/Mouser/releases/latest) -2. Download the zip for your platform: **Mouser-Windows.zip**, **Mouser-macOS.zip** (Apple Silicon), **Mouser-macOS-intel.zip** (Intel macOS), or **Mouser-Linux.zip** -3. **Extract** the zip to any folder (Desktop, Documents, wherever you like) -4. **Run** the executable: `Mouser.exe` (Windows), `Mouser.app` (macOS), or `./Mouser` (Linux) +That's it. The app opens, drops a tray / menu-bar icon, and starts remapping immediately. -That's it. The app will open and start remapping your mouse buttons immediately. +### What to expect on first launch -For macOS Accessibility permissions and login-item notes, see the [macOS Setup Guide](readme_mac_osx.md). +- The settings window opens to the device-aware **Mouse & Profiles** page. +- A tray icon appears (next to the clock on Windows / Linux, in the menu bar on macOS). +- Closing the window keeps Mouser running in the tray. Right-click the tray icon → **Quit Mouser** to fully exit. +- Mouser remembers language and startup behavior between runs. -### What to expect +### First-time notes -- The **settings window** opens showing the current device-aware mouse page -- A **system tray icon** appears near the clock (bottom-right) -- Button remapping is **active immediately** -- Closing the window does not quit the app — it keeps running in the tray -- To fully quit: right-click the tray icon and select **Quit Mouser** +- **Windows SmartScreen** may warn the first time — click **More info** → **Run anyway**. +- **Logitech Options+ must not be running.** Both apps fight over HID++ access; quit Options+ before launching Mouser. +- **macOS** asks for **Accessibility** permission so the event tap can intercept mouse events. See [readme_mac_osx.md](readme_mac_osx.md) for the full setup walkthrough. +- **Linux** needs read access to `/dev/hidraw*`, `/dev/input/event*`, and write access to `/dev/uinput`. Run the bundled helper once after extracting: + ```bash + cd /path/to/extracted/Mouser + ./install-linux-permissions.sh + ``` + Reconnect the mouse, then relaunch. +- Config is saved automatically to: + - `%APPDATA%\Mouser\config.json` (Windows) + - `~/Library/Application Support/Mouser/config.json` (macOS) + - `~/.config/Mouser/config.json` (Linux) +- Logs rotate automatically (5 × 5 MB) under `%APPDATA%\Mouser\logs`, `~/Library/Logs/Mouser`, or `$XDG_STATE_HOME/Mouser/logs`. -### First-time notes +--- + +## Screenshots + +| Mouse & Profiles | Point & Scroll | +|---|---| +| Mouser — Mouse & Profiles page | Mouser — Point & Scroll settings | + +--- + +## Features + +### Button remapping + +- **Remap any programmable button** — middle click, gesture button, back, forward, mode shift, DPI switch (MX Vertical), and horizontal scroll. +- **Mouse-to-mouse remap** — bind any button to act as left, right, middle, back, or forward click. +- **Per-application profiles** — Mouser auto-switches mappings when the foreground app changes (e.g. Chrome vs. VS Code). +- **Custom keyboard shortcuts** — record any key combination (e.g. `Ctrl+Shift+P`) directly in the UI. +- **40+ built-in actions** — navigation, browser, editing, media, scroll-mode, and DPI shortcuts that adapt per platform. + +### Device control + +- **DPI / pointer speed** — slider from 200 to the device max (8000 on MX Master) with quick presets, plus a `Cycle DPI Presets` action you can map to a button. +- **Smart Shift** — toggle Logitech's ratchet ↔ free-spin scroll mode (HID++ `0x2111`), with a sensitivity threshold and a mappable `Toggle SmartShift` action. +- **Switch scroll mode** — bind a button to flip ratchet / free-spin without opening the UI; defaults to mode-shift. +- **Scroll direction inversion** — independent toggles for vertical and horizontal scroll. +- **Gesture button + swipe actions** — tap for one action, swipe up/down/left/right for four others. + +### Cross-platform + +- **Windows, macOS, and Linux** — native hooks per platform (`WH_MOUSE_LL`, `CGEventTap`, `evdev` + `uinput`). +- **Native Intel and Apple Silicon macOS builds** — separate `Mouser-macOS-intel.zip` and `Mouser-macOS.zip` artifacts; the menu-bar app runs as `LSUIElement` (no Dock icon). +- **Resizable UI** — main window starts at 1060 × 700 with a 920 × 620 minimum; the mouse diagram and controls reflow as you resize. +- **Start at login** — Windows registry key on Windows, per-user LaunchAgent on macOS, with an independent **Start minimized** option that boots straight into the tray. +- **Single-instance guard** — launching a second copy brings the existing window to the front instead of starting a duplicate. + +### Smart connectivity + +- **Bluetooth and Logi Bolt** — both transports are supported on all three platforms; the UI labels the live connection (`Logi Bolt` only when the receiver PID is positively identified). +- **Auto-reconnection** — Mouser watches for power-off / on cycles and rebinds HID++ + the OS mouse hook without a restart; SmartShift settings are replayed on every reconnect (including wake-from-sleep). +- **Live connection status** — real-time Connected / Not Connected badge, model name, and active layout in the UI. +- **Device-aware UI** — interactive MX Master diagram with clickable hotspots; generic fallback card for other models, with an experimental layout-override picker. + +### Multi-language UI + +- **English / Simplified Chinese / Traditional Chinese** — switch instantly, no restart required. +- Language preference is saved to `config.json` and restored on next launch. +- Covers nav, mouse page, settings page, dialogs, system tray / menu bar, and permission prompts. + +### Privacy first + +- **Fully local** — config is a plain JSON file, all processing happens on your machine. +- **System tray / menu bar** — runs quietly in the background. +- **Zero telemetry, zero cloud, zero account required.** + +--- + +## Device coverage + +| Family / model | Detection + HID++ probing | UI support | +|---|---|---| +| MX Master 4 / 3S / 3 / 2S / MX Master | Yes | Dedicated interactive `mx_master` layout | +| MX Anywhere 3S / 3 / 2S | Yes | Generic fallback card, experimental manual override | +| MX Vertical | Yes | Generic fallback card (with DPI switch button support) | +| Unknown Logitech HID++ mice | Best effort by PID/name | Generic fallback card | + +> Only the MX Master family currently has a dedicated visual overlay. Other devices are still detected, show their model name, and can opt into an experimental layout override — button positions just may not line up until a real overlay lands. See [CONTRIBUTING_DEVICES.md](CONTRIBUTING_DEVICES.md) to add yours. + +--- + +## Default mappings + +| Button | Default action | +|---|---| +| Back button (XButton1) | Alt + Tab (Switch Windows) | +| Forward button (XButton2) | Alt + Tab (Switch Windows) | +| Middle click | Pass-through | +| Gesture button | Pass-through | +| Gesture swipes (up / down / left / right) | Pass-through | +| Mode shift (scroll click) | Switch Scroll Mode (Ratchet / Free Spin) | +| Horizontal scroll left | Browser Back | +| Horizontal scroll right | Browser Forward | +| DPI switch (MX Vertical) | Pass-through | + +--- + +## Available actions + +Action labels adapt per platform. Windows exposes `Win+D` and `Task View`; macOS exposes `Mission Control`, `Show Desktop`, `App Exposé`, and `Launchpad`; Linux falls back to compositor-native equivalents. -- **Windows SmartScreen** may show a warning the first time — click **More info** then **Run anyway** -- **Logitech Options+** must not be running (it conflicts with HID++ access and will cause Mouser to malfunction or crash) -- Config is saved automatically to `%APPDATA%\Mouser` (Windows), `~/Library/Application Support/Mouser` (macOS), or `~/.config/Mouser` (Linux) +| Category | Actions | +|---|---| +| **Navigation** | Alt+Tab, Alt+Shift+Tab, Show Desktop, Previous Desktop, Next Desktop, Task View (Windows), Mission Control / App Exposé / Launchpad (macOS), Page Up / Page Down / Home / End | +| **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 | +| **Scroll** | Switch Scroll Mode (Ratchet / Free Spin), Toggle SmartShift, Cycle DPI Presets | +| **Mouse** | Left Click, Right Click, Middle Click, Back (Mouse Button 4), Forward (Mouse Button 5) | +| **Custom** | User-defined keyboard shortcuts (any key combination, captured in the UI) | +| **Other** | Do Nothing (pass-through) | --- -## Installation (from source) +## Build from source -### Prerequisites +You only need this if you want to hack on Mouser or run a development build. Most users should grab a release zip — see [Download & Run](#download--run). -- **Windows 10/11**, **macOS 12+ (Monterey)**, or **Linux (experimental; X11 plus KDE Wayland app detection)** -- **Python 3.10+** (tested with 3.14) -- **A supported Logitech HID++ mouse** paired via Bluetooth or USB receiver. MX Master-family devices currently have the most complete UI support. -- **Logitech Options+ must NOT be running** (it conflicts with HID++ access) -- **macOS only:** Accessibility permission required (System Settings → Privacy & Security → Accessibility) -- **Linux only:** `xdotool` enables per-app profile switching on X11; `kdotool` additionally enables KDE Wayland detection -- **Linux only:** access to Logitech `/dev/hidraw*`, `/dev/input/event*`, and `/dev/uinput` is required. The Linux release includes `install-linux-permissions.sh` to install Mouser's udev rule. +### Common prerequisites -### Steps +- **Windows 10/11**, **macOS 12+ (Monterey)**, or **Linux** (X11; KDE Wayland for app detection) +- **Python 3.10+** (tested up to 3.14) +- A supported Logitech HID++ mouse paired via Bluetooth or a USB receiver +- **Logitech Options+ must NOT be running** — it conflicts with HID++ access +- `git` and a working build toolchain ```bash -# 1. Clone the repository git clone https://github.com/TomBadash/Mouser.git cd Mouser - -# 2. Create a virtual environment python -m venv .venv - -# 3. Activate it -.venv\Scripts\activate # Windows (PowerShell / CMD) -source .venv/bin/activate # macOS / Linux - -# 4. Install dependencies -pip install -r requirements.txt ``` ### Dependencies @@ -263,256 +293,99 @@ platforms continue to default to `auto`. A `Mouser.lnk` shortcut is included. To create one manually: ```powershell -$s = (New-Object -ComObject WScript.Shell).CreateShortcut("$([Environment]::GetFolderPath('Desktop'))\Mouser.lnk") -$s.TargetPath = "C:\path\to\mouser\.venv\Scripts\pythonw.exe" -$s.Arguments = "main_qml.py" -$s.WorkingDirectory = "C:\path\to\mouser" -$s.IconLocation = "C:\path\to\mouser\images\logo.ico, 0" -$s.Save() -``` - -### Building Distribution Artifacts - -Windows portable build: - -```bash -# Preferred: run the build script -# It installs requirements, verifies `hidapi`, and packages the app -build.bat +.\.venv\Scripts\activate +pip install -r requirements.txt -# For packaging/debugging issues, force a clean rebuild -build.bat --clean +# Run from source +python main_qml.py -# Manual path: install build/runtime dependencies first -pip install -r requirements.txt pyinstaller +# Or start straight into the tray +python main_qml.py --start-hidden -# Then build using the included spec file -pyinstaller Mouser.spec --noconfirm +# Build a portable zip +build.bat # standard +build.bat --clean # force clean rebuild ``` -The output is in `dist\Mouser\`. Zip that entire folder and distribute it. `build.bat` -fails early if `hidapi` is not importable, which avoids producing a packaged app that -cannot detect Logitech devices. - -macOS native bundle: - -```bash -# 1. Install PyInstaller (inside your venv) -pip install pyinstaller +`build.bat` installs requirements, verifies that `hidapi` is importable, and packages with PyInstaller. The output lives in `dist\Mouser\` — zip the folder and ship it. -# 2. Build the native menu-bar app bundle -./build_macos_app.sh -``` +To launch a source checkout without a console window, create a shortcut that uses `pythonw.exe`; see [DEVELOPMENT.md](DEVELOPMENT.md#desktop-shortcut-windows). -The output is `dist/Mouser.app`. The script prefers `images/AppIcon.icns` when present, otherwise it generates an `.icns` icon from `images/logo_icon.png`, then ad-hoc signs the bundle with `codesign --sign -`. + -Linux portable build: +
+macOS ```bash -# 1. Install system dependencies -sudo apt-get install libhidapi-dev - -# 2. Install PyInstaller (inside your venv) -pip install pyinstaller - -# 3. Build using the Linux-specific spec file -pyinstaller Mouser-linux.spec --noconfirm -``` - -The output is in `dist/Mouser/`. Zip that entire folder and distribute it. - -> **Automated releases:** Pushing a `v*` tag triggers the [release workflow](.github/workflows/release.yml), which builds all three platforms in CI and publishes them as GitHub Release assets. +source .venv/bin/activate +pip install -r requirements.txt ---- +# Run from source +python main_qml.py +python main_qml.py --start-hidden # launch directly to menu bar -## How It Works - -### Architecture - -```mermaid -graph LR - %% Nodes - Mouse["Logitech Mouse / HID++ Device"] - Hook["Mouse Hook"] - Engine["Engine (Orchestrator)"] - Simulator["Key Simulator (SendInput)"] - Backend["Backend (QObject)"] - UI["QML UI (PySide6)"] - Detector["App Detector"] - - %% Connections - Mouse --> Hook - Hook --> Engine - Engine -- "block/pass" --> Hook - Engine --> Simulator - - Engine <--> Backend - Backend <--> UI - Detector --> Backend - - %% Styling for better readability - style Engine fill:#e8eaff,stroke:#4f46e5,stroke-width:2px,color:#000 - style UI fill:#e1f9f0,stroke:#059669,stroke-width:2px,color:#000 - style Mouse fill:#fff7ed,stroke:#d97706,stroke-width:2px,color:#000 - style Hook fill:#f3f4f6,stroke:#374151,color:#000 - style Simulator fill:#f3f4f6,stroke:#374151,color:#000 - style Backend fill:#f3f4f6,stroke:#374151,color:#000 - style Detector fill:#f3f4f6,stroke:#374151,color:#000 +# Build the native menu-bar bundle +pip install pyinstaller +./build_macos_app.sh ``` -### Mouse Hook (`mouse_hook.py` + `mouse_hook_*.py`) - -Mouser uses a shared `MouseHook` façade in `core/mouse_hook.py`, with the -platform implementations split into dedicated modules behind the same -abstraction: - -- **Windows** — `SetWindowsHookExW` with `WH_MOUSE_LL` on a dedicated background thread, plus Raw Input for extra mouse data -- **macOS** — `CGEventTap` for mouse interception and Quartz events for key simulation -- **Linux** — `evdev` to grab the physical mouse and `uinput` to forward pass-through events via a virtual device - -Both paths feed the same internal event model and intercept: - -- `WM_XBUTTONDOWN/UP` — side buttons (back/forward) -- `WM_MBUTTONDOWN/UP` — middle click -- `WM_MOUSEHWHEEL` — horizontal scroll -- `WM_MOUSEWHEEL` — vertical scroll (for inversion) +The output is `dist/Mouser.app`. The script reuses `images/AppIcon.icns` when present, otherwise generates one from `images/logo_icon.png`, then ad-hoc signs the bundle with `codesign --sign -`. -Intercepted events are either **blocked** (hook returns 1) and replaced with an action, or **passed through** to the application. +- Build on the architecture you want to ship: an `arm64` Python produces an Apple Silicon bundle, an `x86_64` Python produces an Intel bundle. Set `PYINSTALLER_TARGET_ARCH=arm64|x86_64|universal2` to override. +- Release CI publishes both `Mouser-macOS.zip` (Apple Silicon) and `Mouser-macOS-intel.zip` (Intel) automatically on tag pushes. +- Accessibility permission is required. See [readme_mac_osx.md](readme_mac_osx.md) for the full grant flow and platform-specific notes. -### Device Catalog & Layout Registry +
-- `core/logi_devices.py` resolves known product IDs and model aliases into a `ConnectedDeviceInfo` record with display name, DPI range, preferred gesture CIDs, and default UI layout key -- `core/device_layouts.py` stores image assets, hotspot coordinates, layout notes, and whether a layout is interactive or only a generic fallback -- `ui/backend.py` combines auto-detected device info with any persisted per-device layout override and exposes the effective layout to QML +
+Linux -### Gesture Button Detection - -Logitech gesture/thumb buttons do not always appear as standard mouse events. Mouser uses a layered detector: - -1. **HID++ 2.0** (primary) — Opens the Logitech HID collection, discovers `REPROG_CONTROLS_V4` (feature `0x1B04`), ranks gesture CID candidates from the device registry plus control-capability heuristics, and diverts the best candidate. When supported, Mouser also enables RawXY movement data. -2. **Raw Input** (Windows fallback) — Registers for raw mouse input and detects extra button bits beyond the standard 5. -3. **Gesture tap/swipe dispatch** — A clean press/release emits `gesture_click`; once movement crosses the configured threshold, Mouser emits directional swipe actions instead. - -### App Detector (`app_detector.py`) +```bash +source .venv/bin/activate +pip install -r requirements.txt -Polls the foreground window every 300ms using `GetForegroundWindow` → `GetWindowThreadProcessId` → process name. Handles UWP apps by resolving `ApplicationFrameHost.exe` to the actual child process. +# Run from source +python main_qml.py -### Engine (`engine.py`) +# Install device permissions (only needed once, then reconnect the mouse) +./packaging/linux/install-linux-permissions.sh -The central orchestrator. On app change, it performs a **lightweight profile switch** — clears and re-wires hook callbacks without tearing down the hook thread or HID++ connection. This avoids the latency and instability of a full hook restart. The engine also forwards connected-device identity to the backend so QML can render the right model name and layout state. +# Build a portable bundle +sudo apt-get install libhidapi-dev +pip install pyinstaller +pyinstaller Mouser-linux.spec --noconfirm +``` -### Device Reconnection +The helper installs `69-mouser-logitech.rules`, reloads `udev`, and tries to `modprobe uinput`. After a successful run, reconnect the mouse, fully quit Mouser, and launch normally — no `sudo`. On systems without logind / `uaccess`, adding the user to the `input` group is the distro-specific fallback. -Mouser handles mouse power-off/on cycles automatically: +`xdotool` enables per-app profile switching on X11; `kdotool` adds KDE Wayland support. Other Wayland compositors fall back to the default profile. -- **HID++ layer** — `HidGestureListener` detects device disconnection (read errors) and enters a reconnect loop, retrying every 2–5 seconds until the device is back -- **Hook layer** — `MouseHook` listens for `WM_DEVICECHANGE` notifications and reinstalls the low-level mouse hook when devices are added or removed -- **UI layer** — connection state and device identity flow from HID++ → MouseHook → Engine → Backend (cross-thread safe via Qt signals) → QML, updating the status badge, device name, and active layout in real time +
-### Configuration +> **Automated releases:** pushing a `v*` tag triggers [`.github/workflows/release.yml`](.github/workflows/release.yml), which builds Windows, macOS (Apple Silicon + Intel), and Linux artifacts in CI and uploads them to the GitHub Release. -All settings are stored in `%APPDATA%\Mouser\config.json` (Windows) or `~/Library/Application Support/Mouser/config.json` (macOS). The config supports: -- Multiple named profiles with per-profile button mappings, including gesture tap + swipe actions -- Per-profile app associations (list of `.exe` names) -- Global settings: DPI, scroll inversion, macOS trackpad filtering, gesture tuning, appearance, debug flags, Smart Shift, and startup preferences (`start_at_login`, `start_minimized`) -- Per-device layout override selections for unsupported devices -- Automatic migration from older config versions +For project layout, the architecture diagram, the HID++ gesture detector, the Engine + reconnection flow, debug CLI flags (`--hid-backend=iokit|hidapi|auto`), and how to run the test suite, see [DEVELOPMENT.md](DEVELOPMENT.md). To add a new device, see [CONTRIBUTING_DEVICES.md](CONTRIBUTING_DEVICES.md). --- -## Project Structure - -``` -mouser/ -├── main_qml.py # Application entry point (PySide6 + QML) -├── Mouser.bat # Quick-launch batch file -├── Mouser-mac.spec # Native macOS app-bundle spec -├── Mouser-linux.spec # Linux PyInstaller spec -├── build_macos_app.sh # macOS bundle build + icon/signing flow -├── .github/workflows/ -│ ├── ci.yml # CI checks (compile, tests, QML lint) -│ └── release.yml # Automated release builds (Win/macOS/Linux) -├── README.md -├── readme_mac_osx.md -├── requirements.txt -├── .gitignore -│ -├── core/ # Backend logic -│ ├── accessibility.py # macOS Accessibility trust checks -│ ├── engine.py # Core engine — wires hook ↔ simulator ↔ config -│ ├── mouse_hook.py # Platform dispatcher shim for MouseHook -│ ├── mouse_hook_*.py # Platform-specific MouseHook implementations -│ ├── hid_gesture.py # HID++ 2.0 gesture button divert (Bluetooth + Logi Bolt) -│ ├── 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) -│ ├── config.py # Config manager (JSON load/save/migrate) -│ └── app_detector.py # Foreground app polling -│ -├── ui/ # UI layer -│ ├── backend.py # QML ↔ Python bridge (QObject with properties/slots) -│ └── qml/ -│ ├── Main.qml # App shell (sidebar + page stack + tray toast) -│ ├── MousePage.qml # Merged mouse diagram + profile manager -│ ├── ScrollPage.qml # DPI slider + scroll inversion toggles -│ ├── HotspotDot.qml # Interactive button overlay on mouse image -│ ├── ActionChip.qml # Selectable action pill -│ └── Theme.js # Shared colors and constants -│ -└── images/ - ├── AppIcon.icns # Committed macOS app-bundle icon - ├── mouse.png # MX Master 3S top-down diagram - ├── icons/mouse-simple.svg # Generic fallback device card artwork - ├── logo.png # Mouser logo (source) - ├── logo.ico # Multi-size icon for shortcuts - ├── logo_icon.png # Square icon with background - ├── chrom.png # App icon: Chrome - ├── VSCODE.png # App icon: VS Code - ├── VLC.png # App icon: VLC - └── media.webp # App icon: Windows Media Player -``` - -## UI Overview - -The app has two pages accessible from a slim sidebar: - -### Mouse & Profiles (Page 1) - -- **Left panel:** List of profiles. The "Default (All Apps)" profile is always present. Per-app profiles show the app icon and name. Select a profile to edit its mappings. -- **Right panel:** Device-aware mouse view. MX Master-family devices get clickable hotspot dots on the image; unsupported layouts fall back to a generic device card with an experimental "try another supported map" picker. -- **Add profile:** ComboBox at the bottom lists known apps (Chrome, Edge, VS Code, VLC, etc.). Click "+" to create a per-app profile. - -### Point & Scroll (Page 2) +## Limitations -- **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. -- **Ignore trackpad (macOS):** Keep trackpad and Magic Mouse continuous scroll gestures out of Mouser mappings. Disable this only if you intentionally want Mouser to handle Magic Mouse or trackpad scroll events. -- **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. +- **Per-device mappings aren't fully separated yet** — layout overrides are stored per detected device, but profile mappings are still global. +- **Conflicts with Logitech Options+** — both apps fight over HID++ access. Quit Options+ before running Mouser. +- **Scroll inversion** uses coalesced post-injection on Windows to avoid LL-hook deadlocks; it's stable in mainstream apps but may misbehave in some games or low-level drivers. +- **Admin not required** — but injected keystrokes may not reach elevated windows or some games. Run Mouser elevated if you need that path. +- **Linux app detection is partial** — X11 works via `xdotool`, KDE Wayland works via `kdotool`, GNOME / other Wayland compositors still fall back to the default profile. +- **Linux device permissions** — Mouser needs access to `/dev/hidraw*`, `/dev/input/event*`, and `/dev/uinput`. Use [`install-linux-permissions.sh`](packaging/linux/install-linux-permissions.sh) once instead of running as root. --- -## Known Limitations +## Roadmap -- **Early multi-device support** — only the MX Master family currently has a dedicated interactive overlay; MX Anywhere, MX Vertical, and unknown Logitech mice still use the generic fallback card -- **Per-device mappings are not fully separated yet** — layout overrides are stored per detected device, but profile mappings are still global rather than truly device-specific -- **Bluetooth and Logi Bolt supported** — HID++ gesture button divert works over both Bluetooth and Logi Bolt USB receivers -- **Conflicts with Logitech Options+** — both apps fight over HID++ access; quit Options+ before running Mouser -- **Scroll inversion is experimental** — uses coalesced `PostMessage` injection to avoid LL hook deadlocks; may not work perfectly in all apps -- **Admin not required** — but some games or elevated windows may not receive injected keystrokes -- **Linux app detection is still limited** — X11 works via `xdotool`, KDE Wayland works via `kdotool`, and GNOME / other Wayland compositors still fall back to the default profile -- **Linux remapping needs device permissions** — Mouser must be able to access Logitech `/dev/hidraw*`, read `/dev/input/event*`, and write `/dev/uinput`. Use the bundled `install-linux-permissions.sh` helper to install the udev rule, then reconnect the mouse and restart Mouser. - -## Future Work - -- [ ] **Dedicated overlays for more devices** — add real hotspot maps and artwork for MX Anywhere, MX Vertical, and other Logitech families -- [ ] **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 +- [ ] **Dedicated overlays for more devices** — real hotspot maps and artwork for MX Anywhere, MX Vertical, and other Logitech families +- [ ] **True per-device config** — separate mappings 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 the current fixed sets - [ ] **Improved scroll inversion** — explore driver-level or interception-driver approaches -- [ ] **Gesture swipe tuning** — improve swipe reliability and defaults across more Logitech devices +- [ ] **Gesture swipe tuning** — improve swipe reliability and defaults across more devices - [ ] **Per-app profile auto-creation** — detect new apps and prompt to create a profile - [x] **Export/import config** — share configurations between machines (CLI only for now) - [ ] **Tray icon badge** — show active profile name in tray tooltip @@ -520,23 +393,21 @@ The app has two pages accessible from a slim sidebar: - [ ] **Broader Wayland support and Linux validation** — extend app detection beyond KDE Wayland / X11 and validate across more distros and desktop environments - [ ] **Plugin system** — allow third-party action providers -## Contributing - -Contributions are welcome! To get started: +--- -1. Fork the repo and create a feature branch -2. Set up the dev environment (see [Installation](#installation)) -3. Make your changes and test with a supported Logitech HID++ mouse (MX Master family preferred for now) -4. Submit a pull request with a clear description +## Contributing -### Areas where help is needed +Contributions are welcome. -- Testing with other Logitech HID++ devices -- Scroll inversion improvements -- Broader Linux/Wayland validation -- UI/UX polish and accessibility +- **Code, fixes, and features:** fork → branch → PR. The dev setup, architecture overview, debug flags, and test instructions live in [DEVELOPMENT.md](DEVELOPMENT.md). +- **Adding a new Logitech mouse:** follow the discovery-dump walkthrough in [CONTRIBUTING_DEVICES.md](CONTRIBUTING_DEVICES.md). Even a partial dump helps. +- **Help wanted:** + - Testing with other Logitech HID++ devices + - Scroll inversion improvements + - Broader Linux / Wayland validation + - UI/UX polish, accessibility, and translations -## Support the Project +## Support the project If Mouser saves you from installing Logitech Options+, consider supporting development: @@ -546,24 +417,27 @@ If Mouser saves you from installing Logitech Options+, consider supporting devel

-Every bit helps keep the project going — thank you! - -## License - -This project is licensed under the [MIT License](LICENSE). +Every bit helps keep the project going — thank you. --- ## Acknowledgments -- **[@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 -- **[@hieshima](https://github.com/hieshima)** — Linux support (evdev + HID++ + uinput), mode shift button mapping, Smart Shift toggle, and custom keyboard shortcut support; Linux connection state stabilization (evdev/HID++ split readiness, HID settings replay on reconnect); macOS CGEventTap reliability (auto re-enable on timeout, trackpad scroll filtering) -- **[@pavelzaichyk](https://github.com/pavelzaichyk)** — Next Tab and Previous Tab browser actions, persistent rotating log file storage, Smart Shift enhanced support (HID++ 0x2111) with sensitivity control and scroll mode sync -- **[@nellwhoami](https://github.com/nellwhoami)** — Multi-language UI system (English, Simplified Chinese, Traditional Chinese) and Page Up/Down/Home/End navigation actions -- **[@guilamu](https://github.com/guilamu)** — Mouse-to-mouse button remapping (left, right, middle, back, forward click), HID++ stability fixes (stuck button auto-release, auto-reconnect after consecutive timeouts, async dispatch queue for Windows hook) +- **[@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 triage of 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. +- **[@hieshima](https://github.com/hieshima)** — Linux support (evdev + HID++ + uinput), mode-shift mapping, Smart Shift toggle, custom keyboard shortcut support, Linux connection-state stabilization, and macOS CGEventTap reliability fixes (auto re-enable on timeout, trackpad scroll filtering). +- **[@pavelzaichyk](https://github.com/pavelzaichyk)** — Next Tab / Previous Tab browser actions, persistent rotating log file storage, Smart Shift enhanced support (HID++ `0x2111`) with sensitivity control and scroll-mode sync. +- **[@nellwhoami](https://github.com/nellwhoami)** — Multi-language UI system (English, Simplified Chinese, Traditional Chinese) and Page Up / Page Down / Home / End navigation actions. +- **[@guilamu](https://github.com/guilamu)** — Mouse-to-mouse button remapping (left, right, middle, back, forward click) and HID++ stability fixes (stuck-button auto-release, auto-reconnect after consecutive timeouts, async dispatch queue for the Windows hook). +- **[@vcanuel](https://github.com/vcanuel)** — Logi Bolt receiver support on macOS via the `hidapi` fallback path. +- **[@farfromrefug](https://github.com/farfromrefug)** — smaller macOS bundle (Qt Quick Controls trim, QtDBus, Qt asset filtering). +- **[@MysticalMike60t](https://github.com/MysticalMike60t)** — README structure ideas (collapsible per-OS build sections). --- +## License + +This project is licensed under the [MIT License](LICENSE). + **Mouser** is not affiliated with or endorsed by Logitech. "Logitech", "MX Master", and "Options+" are trademarks of Logitech International S.A. diff --git a/README_CN.md b/README_CN.md index b58301d7..744338e8 100644 --- a/README_CN.md +++ b/README_CN.md @@ -6,99 +6,29 @@ 中文文档|[English README](README.md) -一个轻量、开源、**完全本地运行** 的 **Logitech Options+** 替代品,用于对罗技 HID++ 鼠标进行按键/手势重映射。当前对 **MX Master** 系列体验最佳,同时也对更多罗技型号提供早期识别与通用回退 UI。 +一个轻量、开源、**完全本地运行** 的 **Logitech Options+** 替代品,用于对罗技 HID++ 鼠标进行按键 / 手势重映射。当前对 **MX Master** 系列体验最佳,并对更多罗技型号提供识别与通用回退 UI。 -无需云端、无需罗技账号、纯本地运行。 - -注: 在原项目的基础上添加平滑滚动,添加了中文. +**零遥测,零云端,无需罗技账号。** --- -## 功能特性 - -### 🖱️ 按键重映射 -- **重映射任意可编程按键**:中键、手势键、前进/后退、模式切换(滚轮按压)、水平滚轮等 -- **按应用配置 Profile**:切换前台应用时自动切换映射(例如 Chrome vs. VS Code) -- **自定义快捷键**:可将任意组合键(如 Ctrl+Shift+P)设为按键动作 -- **30+ 内置动作**:导航/浏览器/编辑/媒体/桌面等,动作标签会随平台自适配 - -### ⚙️ 设备控制 -- **DPI / 指针速度**:200–8000 DPI 滑块与预设档位,HID++ 实时同步 -- **Smart Shift 开关**:启用/关闭罗技“棘轮 ↔ 自由滚动”的自动切换 -- **滚动方向反转**:垂直/水平滚动可分别反转 -- **手势键 + 方向滑动**:轻点一个动作,上/下/左/右滑动可绑定不同动作 - -### 🖥️ 跨平台 -- **Windows / macOS / Linux**:各平台使用原生 hook(WH_MOUSE_LL、CGEventTap、evdev/uinput) -- **开机自启**:Windows 注册表 + macOS LaunchAgent,并支持“启动后最小化到托盘/菜单栏” -- **单实例**:重复启动会将已运行窗口置前 - -### 🔌 智能连接 -- **蓝牙和 Logi Bolt**:同时支持蓝牙和 Logi Bolt USB 接收器;连接方式显示在 UI 中 -- **自动重连**:检测鼠标断电/重连,无需重启即可恢复完整功能 -- **实时连接状态**:UI 中显示”Connected / Not Connected” -- **设备感知 UI**:MX Master 提供可点击热区的交互示意图;其他型号使用通用回退卡片 - -### 🌐 多语言 UI -- **English / 简体中文 / 繁體中文**:应用内即时切换,无需重启 -- 语言偏好会自动保存到 `config.json` 并在下次启动恢复 -- 已覆盖主要 UI:导航、鼠标页、设置页、对话框、托盘/菜单栏、权限提示等 - -### 🛡️ 隐私优先 -- **完全本地**:配置为 JSON 文件,所有处理都在本机完成 -- **托盘 / 菜单栏**:后台安静运行,随时从托盘快速访问 -- **零遥测 / 零云端 / 零账号** - -## 截图 - -

- Mouser — Mouse & Profiles page -

- -

- Mouser — Point & Scroll settings -

- -## 当前支持的设备范围 - -| Family / model | Detection + HID++ probing | UI support | -|---|---|---| -| MX Master 4 / 3S / 3 / 2S / MX Master | Yes | Dedicated interactive `mx_master` layout | -| MX Anywhere 3S / 3 / 2S | Yes | Generic fallback card, experimental manual override | -| MX Vertical | Yes | Generic fallback card | -| Unknown Logitech HID++ mice | Best effort by PID/name | Generic fallback card | - -> **说明:** 目前只有 MX Master 系列有专用的可视化热区覆盖层。其他设备仍可被识别并在 UI 中显示型号,并可尝试实验性的布局覆盖选择器;但在加入专用覆盖层前,按键热区位置可能不够精确。 - -## 默认映射 - -| Button | Default Action | -|---|---| -| Back button | Alt + Tab (Switch Windows) | -| Forward button | Alt + Tab (Switch Windows) | -| Middle click | Pass-through | -| Gesture button | Pass-through | -| Mode shift (scroll click) | Pass-through | -| Horizontal scroll left | Browser Back | -| Horizontal scroll right | Browser Forward | - -## 可用动作 - -动作标签会随平台变化。例如 Windows 提供 `Win+D` 与 `Task View`,而 macOS 提供 `Mission Control`、`Show Desktop`、`App Expose`、`Launchpad` 等。 - -| 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) | -| **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 | -| **Custom** | User-defined keyboard shortcuts (any key combination) | -| **Other** | Do Nothing (pass-through) | +## 目录 + +- [下载与运行](#下载与运行) +- [截图](#截图) +- [功能特性](#功能特性) +- [设备支持范围](#设备支持范围) +- [默认映射](#默认映射) +- [可用动作](#可用动作) +- [从源码构建](#从源码构建) +- [已知限制](#已知限制) +- [路线图](#路线图) +- [贡献指南](#贡献指南) +- [致谢](#致谢) +- [许可证](#许可证) --- - - ## 下载与运行 > **无需安装。** 下载 → 解压 → 双击运行即可。 @@ -108,7 +38,10 @@ Windows Downloads - macOS Downloads + macOS Apple Silicon Downloads + + + macOS Intel Downloads Linux Downloads @@ -117,415 +50,282 @@ Downloads

-### 步骤 +1. 打开 [**最新 Release 页面**](https://github.com/TomBadash/Mouser/releases/latest)。 +2. 下载对应平台的 zip: + - **Windows** — `Mouser-Windows.zip` + - **macOS(Apple Silicon)** — `Mouser-macOS.zip` + - **macOS(Intel)** — `Mouser-macOS-intel.zip` + - **Linux** — `Mouser-Linux.zip` +3. 解压到任意目录(桌面、文档、`/Applications` 等均可)。 +4. 运行可执行文件:`Mouser.exe`、`Mouser.app` 或 `./Mouser`。 + +完成。程序启动后会立即在托盘 / 菜单栏放置图标,并开始接管按键映射。 + +### 首次启动会看到什么 + +- 设置窗口打开后会进入设备感知的 **鼠标与配置(Mouse & Profiles)** 页。 +- 系统托盘出现一个图标(Windows / Linux 在时钟旁,macOS 在菜单栏)。 +- 关闭窗口不会退出程序,Mouser 会继续在托盘运行。要完全退出,请右键托盘图标 → **Quit Mouser**。 +- Mouser 会记住语言和启动行为,下次启动自动恢复。 + +### 首次运行注意事项 + +- **Windows SmartScreen** 首次可能弹警告:点击 **More info** → **Run anyway**。 +- **必须退出 Logitech Options+**。两者都会争夺 HID++ 访问权,请先退出 Options+ 再启动 Mouser。 +- **macOS** 会请求 **辅助功能(Accessibility)** 权限,以便事件流(CGEventTap)能拦截鼠标事件。完整步骤请见 [readme_mac_osx.md](readme_mac_osx.md)。 +- **Linux** 需要读取 `/dev/hidraw*`、`/dev/input/event*` 以及写入 `/dev/uinput`。解压后请运行一次随包附带的辅助脚本: + ```bash + cd /path/to/extracted/Mouser + ./install-linux-permissions.sh + ``` + 之后重新插拔鼠标并重启 Mouser。 +- 配置文件自动保存到: + - `%APPDATA%\Mouser\config.json`(Windows) + - `~/Library/Application Support/Mouser/config.json`(macOS) + - `~/.config/Mouser/config.json`(Linux) +- 日志按 5 × 5 MB 自动滚动,保存于 `%APPDATA%\Mouser\logs`、`~/Library/Logs/Mouser` 或 `$XDG_STATE_HOME/Mouser/logs`。 -1. 进入 [**最新 Release 页面**](https://github.com/TomBadash/Mouser/releases/latest) -2. 下载对应平台的 zip:**Mouser-Windows.zip**、**Mouser-macOS.zip**、**Mouser-Linux.zip** -3. **解压**到任意目录(桌面/文档等均可) -4. **运行**:`Mouser.exe`(Windows)、`Mouser.app`(macOS)、`./Mouser`(Linux) +--- -完成。程序启动后会立即开始接管并重映射鼠标按键。 +## 截图 -macOS 的辅助功能权限与登录项注意事项,请参见 [macOS 安装/权限指南](readme_mac_osx.md)。 +| 鼠标与配置 | 指针与滚动 | +|---|---| +| Mouser — Mouse & Profiles page | Mouser — Point & Scroll settings | -### 你会看到什么 +--- -- 打开 **设置窗口**,显示当前设备对应的鼠标页面 -- 系统托盘(右下角)出现 **托盘图标** -- 按键重映射 **立即生效** -- 关闭窗口并不会退出程序:它会继续在托盘运行 -- 完全退出:右键托盘图标 → **Quit Mouser** +## 功能特性 -### 首次运行提示 +### 按键重映射 -- **Windows SmartScreen** 首次可能弹警告:点击 **More info** → **Run anyway** -- **Logitech Options+** 必须退出(会与 HID++ 访问冲突,导致 Mouser 异常或崩溃) -- 配置文件默认保存于:`%APPDATA%\Mouser`(Windows)、`~/Library/Application Support/Mouser`(macOS)、`~/.config/Mouser`(Linux) +- **重映射任意可编程按键** — 中键、手势键、后退、前进、模式切换(Mode Shift)、DPI 切换(MX Vertical)以及水平滚轮。 +- **鼠标按键互映** — 任意按键都可绑定为左键 / 右键 / 中键 / 后退 / 前进。 +- **按应用 Profile** — 切换前台应用时(如 Chrome → VS Code)自动切换映射。 +- **自定义快捷键** — 在 UI 中直接录制任意组合键(例如 `Ctrl+Shift+P`)。 +- **40+ 内置动作** — 导航、浏览器、编辑、媒体、滚动模式、DPI 等动作,会按平台自适配标签。 ---- +### 设备控制 -
+- **DPI / 指针速度** — 滑块从 200 到设备上限(MX Master 为 8000),含快速预设;并提供可绑定到按键的 `Cycle DPI Presets` 动作。 +- **Smart Shift** — 切换罗技“棘轮 ↔ 自由滚动”模式(HID++ `0x2111`),支持灵敏度阈值和可绑定的 `Toggle SmartShift` 动作。 +- **滚动模式切换** — 将按键绑定为滚动模式切换,无需打开 UI 即可切换;默认绑定到 mode shift 按键。 +- **滚动方向反转** — 垂直 / 水平方向可独立反转。 +- **手势键 + 方向滑动** — 轻点为一个动作,上 / 下 / 左 / 右四个方向滑动各自绑定不同动作。 -## 从源码安装 +### 跨平台 -### 前置条件 +- **Windows、macOS、Linux** — 各平台原生 hook(`WH_MOUSE_LL`、`CGEventTap`、`evdev` + `uinput`)。 +- **macOS Intel 与 Apple Silicon 原生构建** — 分别提供 `Mouser-macOS-intel.zip` 与 `Mouser-macOS.zip`;菜单栏 App 以 `LSUIElement` 方式运行(不在 Dock 中显示)。 +- **可调窗口大小** — 主窗口默认 1060 × 700,最小 920 × 620;鼠标示意图与控件会随窗口尺寸自适应排布。 +- **开机自启** — Windows 注册表 / macOS 用户级 LaunchAgent,并提供独立的 **启动时最小化(Start minimized)** 选项,可直接启动到托盘。 +- **单实例守护** — 重复启动会将已运行的窗口置前,而不会创建第二个实例。 -- **Windows 10/11**、**macOS 12+(Monterey)**、或 **Linux(实验性;X11 + KDE Wayland 应用检测)** -- **Python 3.10+**(已在 3.14 上测试) -- 一只支持的罗技 HID++ 鼠标(蓝牙或 USB 接收器均可);当前 MX Master 系列 UI 支持最完整 -- **必须退出 Logitech Options+**(会与 HID++ 访问冲突) -- **仅 macOS:** 需要两个隐私权限: - - **Accessibility / 辅助功能**(系统设置 → 隐私与安全性 → 辅助功能):用于 CGEventTap 拦截鼠标按键 - - **Input Monitoring / 输入监控**(系统设置 → 隐私与安全性 → 输入监控):用于 HID++(手势键、DPI、Smart Shift、设备名) -- **仅 Linux:** `xdotool` 用于 X11 的按应用 Profile 切换;`kdotool` 额外用于 KDE Wayland 检测 -- **仅 Linux:** 需要访问 Logitech `/dev/hidraw*`、读取 `/dev/input/event*`、写入 `/dev/uinput`。Linux 发布包内附带 `install-linux-permissions.sh`,用于安装 Mouser 的 udev 规则。 +### 智能连接 -### 安装步骤 +- **蓝牙与 Logi Bolt** — 三个平台都支持两种连接方式;UI 实时显示当前连接类型(仅在确认接收器 PID 时才显示 `Logi Bolt`)。 +- **自动重连** — Mouser 监听断电 / 上电循环,无需重启即可重新绑定 HID++ 与系统鼠标 hook;每次重连(包括从睡眠唤醒)都会回放 SmartShift 设置。 +- **实时连接状态** — UI 显示 Connected / Not Connected 徽标、设备型号和当前布局。 +- **设备感知 UI** — MX Master 系列提供带可点击热区的交互示意图;其他型号使用通用回退卡片,并支持实验性的布局覆盖选择器。 -```bash -# 1. 克隆仓库 -git clone https://github.com/TomBadash/Mouser.git -cd Mouser +### 多语言 UI -# 2. 创建虚拟环境 -python -m venv .venv +- **English / 简体中文 / 繁體中文** — 在应用内即时切换,无需重启。 +- 语言偏好会保存到 `config.json`,下次启动自动恢复。 +- 已覆盖:导航、鼠标页、设置页、对话框、托盘 / 菜单栏、权限提示等所有主要界面。 -# 3. 激活虚拟环境 -.venv\Scripts\activate # Windows (PowerShell / CMD) -source .venv/bin/activate # macOS / Linux +### 隐私优先 -# 4. 安装依赖 -pip install -r requirements.txt -``` +- **完全本地** — 配置为纯 JSON 文件,所有处理都在本机完成。 +- **托盘 / 菜单栏** — 安静地后台运行。 +- **零遥测、零云端、无需账号。** -### 依赖说明 +--- -| Package | Purpose | -|---|---| -| `PySide6` | Qt Quick / QML UI framework | -| `hidapi` | HID++ communication with the mouse (gesture button, DPI) | -| `Pillow` | Image processing for icon generation | -| `pyobjc-framework-Quartz` | macOS CGEventTap / Quartz event support | -| `pyobjc-framework-Cocoa` | macOS app detection and media-key support | -| `evdev` | Linux mouse grab and virtual device forwarding (uinput) | +## 设备支持范围 -### Linux 设备权限 +| 系列 / 型号 | 识别 + HID++ 探测 | UI 支持 | +|---|---|---| +| MX Master 4 / 3S / 3 / 2S / MX Master | 是 | 专用交互布局 `mx_master` | +| MX Anywhere 3S / 3 / 2S | 是 | 通用回退卡片,支持实验性手动覆盖 | +| MX Vertical | 是 | 通用回退卡片(含 DPI 切换按键支持) | +| 其他罗技 HID++ 鼠标 | 按 PID / 名称尽力识别 | 通用回退卡片 | -Mouser 的 Linux 便携版应以普通用户运行。HID++ 功能需要 Logitech -`hidraw` 权限,按键重映射需要读取 `/dev/input/event*` 并写入 -`/dev/uinput`。如果只有 `sudo ./Mouser` 能连接鼠标,请安装附带的 udev -规则,而不是长期以 root 运行应用: +> 目前只有 MX Master 系列拥有专用的可视化覆盖层。其他设备同样可被识别、显示型号名,并可启用实验性布局覆盖;但在专用覆盖层加入前,按键热区位置可能不够精确。要为你的设备添加支持,请见 [CONTRIBUTING_DEVICES.md](CONTRIBUTING_DEVICES.md)。 -```bash -cd /path/to/extracted/Mouser -./install-linux-permissions.sh -``` +--- -从源码运行时,可使用仓库内的同一个脚本: +## 默认映射 -```bash -./packaging/linux/install-linux-permissions.sh -``` +| 按键 | 默认动作 | +|---|---| +| 后退(XButton1) | Alt + Tab(切换窗口) | +| 前进(XButton2) | Alt + Tab(切换窗口) | +| 中键 | 透传(Pass-through) | +| 手势键 | 透传 | +| 手势滑动(上 / 下 / 左 / 右) | 透传 | +| 模式切换(Mode shift,滚轮按压) | 切换滚动模式(棘轮 / 自由滚动) | +| 水平滚动左 | 浏览器后退 | +| 水平滚动右 | 浏览器前进 | +| DPI 切换(MX Vertical) | 透传 | -该脚本会安装 `69-mouser-logitech.rules`、重新加载 udev,并尝试加载 -`uinput`。之后请重新连接鼠标,完全退出 Mouser,再以普通方式启动。如果 -桌面启动器或开机启动项仍无法访问设备,请注销并重新登录一次,让会话获得新的 -设备 ACL。某些不支持 logind/uaccess 的发行版可能仍需要将用户加入 `input` -组作为兜底方案。 +--- -### 运行方式 +## 可用动作 -```bash -# 方式 A:直接运行 -python main_qml.py +动作标签会随平台自适配。Windows 提供 `Win+D` 与 `Task View`;macOS 提供 `Mission Control`、`Show Desktop`、`App Exposé`、`Launchpad`;Linux 回退到对应桌面环境的等价动作。 -# 方式 B:直接后台启动(托盘/菜单栏) -python main_qml.py --start-hidden +| 类别 | 动作 | +|---|---| +| **Navigation(导航)** | Alt+Tab、Alt+Shift+Tab、Show Desktop、Previous Desktop、Next Desktop、Task View(Windows)、Mission Control / App Exposé / Launchpad(macOS)、Page Up / Page Down / Home / End | +| **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 | +| **Scroll(滚动)** | Switch Scroll Mode(棘轮 / 自由滚动)、Toggle SmartShift、Cycle DPI Presets | +| **Mouse(鼠标)** | Left Click、Right Click、Middle Click、Back(鼠标按键 4)、Forward(鼠标按键 5) | +| **Custom(自定义)** | 用户定义的快捷键组合(在 UI 中录制) | +| **Other(其他)** | Do Nothing(透传) | + +--- -# 方式 C:使用批处理(会显示控制台窗口) -Mouser.bat +## 从源码构建 -# 方式 D:使用桌面快捷方式(不显示控制台) -# 双击 Mouser.lnk -``` +只有在你想参与开发或运行开发版时才需要从源码构建。普通用户请直接下载 release zip — 见 [下载与运行](#下载与运行)。 -> **提示:** 如需不显示控制台窗口,请使用 `pythonw.exe main_qml.py` 或 `.lnk` 快捷方式。 -> macOS 上 `--start-hidden` 等价于“托盘优先”的后台启动路径;登录项会使用你保存的启动设置。 +### 共同前置条件 -macOS 传输后端临时切换(仅用于排障): +- **Windows 10/11**、**macOS 12+(Monterey)** 或 **Linux**(X11;KDE Wayland 用于应用检测) +- **Python 3.10+**(已在 3.14 上测试) +- 一只支持的罗技 HID++ 鼠标(蓝牙或 USB 接收器) +- **必须退出 Logitech Options+** — 它会与 HID++ 访问冲突 +- 已安装 `git` 与可用的构建工具链 ```bash -python main_qml.py --hid-backend=iokit -python main_qml.py --hid-backend=hidapi -python main_qml.py --hid-backend=auto +git clone https://github.com/TomBadash/Mouser.git +cd Mouser +python -m venv .venv ``` -仅用于故障排查。macOS 默认使用 `iokit`;`hidapi` 与 `auto` 仍可作为手动覆盖选项。其他平台仍默认 `auto`。 +
+Windows -### 创建桌面快捷方式 +```powershell +.\.venv\Scripts\activate +pip install -r requirements.txt -仓库自带 `Mouser.lnk`。也可手动创建: +# 直接从源码运行 +python main_qml.py -```powershell -$s = (New-Object -ComObject WScript.Shell).CreateShortcut("$([Environment]::GetFolderPath('Desktop'))\Mouser.lnk") -$s.TargetPath = "C:\path\to\mouser\.venv\Scripts\pythonw.exe" -$s.Arguments = "main_qml.py" -$s.WorkingDirectory = "C:\path\to\mouser" -$s.IconLocation = "C:\path\to\mouser\images\logo.ico, 0" -$s.Save() +# 或直接启动到托盘 +python main_qml.py --start-hidden + +# 构建便携版 zip +build.bat # 标准构建 +build.bat --clean # 强制清理后重建 ``` -### 构建发布包(Distribution Artifacts) +`build.bat` 会自动安装依赖、校验 `hidapi` 是否可导入,再用 PyInstaller 打包。输出位于 `dist\Mouser\`,将整个目录打包 zip 即可分发。 -#### 快速总览 +如需源码版无控制台窗口启动,可以创建一个使用 `pythonw.exe` 的快捷方式,详见 [DEVELOPMENT.md](DEVELOPMENT.md#desktop-shortcut-windows)。 -| Platform | Command | Output | -|---|---|---| -| Windows | `build.bat` or `pyinstaller Mouser.spec --noconfirm` | `dist\Mouser\` | -| macOS | `./build_macos_app.sh` | `dist/Mouser.app` | -| Linux | `pyinstaller Mouser-linux.spec --noconfirm` | `dist/Mouser/` | +
-#### Windows 便携版构建 +
+macOS ```bash -# 推荐:直接运行构建脚本 -# 它会安装依赖、校验 `hidapi`,然后再打包 -build.bat - -# 如果在排查打包问题,建议强制完整重建 -build.bat --clean +source .venv/bin/activate +pip install -r requirements.txt -# 手动方式:先安装构建和运行依赖 -pip install -r requirements.txt pyinstaller +# 直接从源码运行 +python main_qml.py +python main_qml.py --start-hidden # 直接启动到菜单栏 -# 然后使用 spec 文件构建 -pyinstaller Mouser.spec --noconfirm +# 构建原生菜单栏 App Bundle +pip install pyinstaller +./build_macos_app.sh ``` -输出目录为 `dist\Mouser\`,将整个目录打包 zip 即可分发。`build.bat` -会在打包前先检查 `hidapi` 是否可导入,避免生成一个无法检测 Logitech -设备的安装包。 +输出为 `dist/Mouser.app`。脚本优先使用 `images/AppIcon.icns`;若不存在,则从 `images/logo_icon.png` 生成 `.icns`,并使用 `codesign --sign -` 进行 ad-hoc 签名。 -#### macOS 原生 App Bundle 构建 +- 构建时使用与目标架构一致的 Python:`arm64` Python 产出 Apple Silicon Bundle,`x86_64` Python 产出 Intel Bundle。可设置 `PYINSTALLER_TARGET_ARCH=arm64|x86_64|universal2` 来覆盖。 +- 推送 tag 后,Release CI 会自动同时发布 `Mouser-macOS.zip`(Apple Silicon)与 `Mouser-macOS-intel.zip`(Intel)。 +- 需要授予辅助功能(Accessibility)权限。完整步骤与平台差异请见 [readme_mac_osx.md](readme_mac_osx.md)。 -```bash -# 1. 安装 PyInstaller(在 venv 内) -pip install pyinstaller +
-# 2. 构建菜单栏 App Bundle -./build_macos_app.sh -``` +
+Linux -输出为 `dist/Mouser.app`。脚本优先使用 `images/AppIcon.icns`;若不存在,则从 `images/logo_icon.png` 生成 `.icns`,并使用 `codesign --sign -` 对 bundle 进行 ad-hoc 签名。 +```bash +source .venv/bin/activate +pip install -r requirements.txt -#### Linux 便携版构建 +# 直接从源码运行 +python main_qml.py -```bash -# 1. 安装系统依赖 -sudo apt-get install libhidapi-dev +# 安装设备权限(仅需运行一次,之后重新插拔鼠标) +./packaging/linux/install-linux-permissions.sh -# 2. 安装 PyInstaller(在 venv 内) +# 构建便携版 +sudo apt-get install libhidapi-dev pip install pyinstaller - -# 3. 使用 Linux spec 构建 pyinstaller Mouser-linux.spec --noconfirm ``` -输出目录为 `dist/Mouser/`,将整个目录打包 zip 即可分发。 +辅助脚本会安装 `69-mouser-logitech.rules`、重新加载 `udev`,并尝试 `modprobe uinput`。运行成功后,请重新插拔鼠标,完全退出 Mouser,然后以普通用户方式启动 — 无需 `sudo`。在不支持 logind / `uaccess` 的发行版上,将用户加入 `input` 组是兜底方案。 -> **自动化发布:** 推送 `v*` 标签会触发 [release 工作流](.github/workflows/release.yml),在 CI 中构建三平台产物并发布到 GitHub Releases。 +`xdotool` 用于 X11 的按应用 Profile 切换;`kdotool` 提供 KDE Wayland 支持。其他 Wayland 桌面环境会回退到默认 Profile。 -#### 多语言支持(无需额外构建步骤) +
-翻译内容直接以 Python 字典形式内置在 `ui/locale_manager.py`。没有 `.ts`/`.qm` 文件,也不需要 `lupdate`/`lrelease` —— Windows/macOS/Linux 的打包流程都会自动包含该模块。新增语言步骤: +> **自动化发布:** 推送 `v*` 标签会触发 [`.github/workflows/release.yml`](.github/workflows/release.yml),CI 会构建 Windows、macOS(Apple Silicon + Intel)、Linux 产物,并上传到对应的 GitHub Release。 -1. Add a new entry to `_TRANSLATIONS` in `ui/locale_manager.py` -2. Append the language to `AVAILABLE_LANGUAGES` -3. Rebuild as usual +项目结构、架构图、HID++ 手势检测、引擎与重连流程、调试用 CLI 选项(`--hid-backend=iokit|hidapi|auto`)、运行测试套件等开发者文档,请见 [DEVELOPMENT.md](DEVELOPMENT.md)(英文)。要新增设备支持,请见 [CONTRIBUTING_DEVICES.md](CONTRIBUTING_DEVICES.md)。 --- -## 工作原理 - -### 架构 - -``` -┌────────────────┐ ┌──────────┐ ┌────────────────┐ -│ Logitech mouse │────▶│ Mouse │────▶│ Engine │ -│ / HID++ device │ │ Hook │ │ (orchestrator) │ -└────────────────┘ └──────────┘ └───────┬────────┘ - ▲ │ - block/pass ┌────▼────────┐ - │ Key │ -┌─────────────┐ ┌──────────┐ │ Simulator │ -│ QML UI │◀───▶│ Backend │ │ (SendInput) │ -│ (PySide6) │ │ (QObject)│ └─────────────┘ -└─────────────┘ └──────────┘ - ▲ - ┌────┴────────┐ - │ App │ - │ Detector │ - └─────────────┘ -``` - -### Mouse Hook(`mouse_hook.py`) - -Mouser 在统一的 `MouseHook` 抽象后面,为不同平台提供原生实现: - -- **Windows** — `SetWindowsHookExW` with `WH_MOUSE_LL` on a dedicated background thread, plus Raw Input for extra mouse data -- **macOS** — `CGEventTap` for mouse interception and Quartz events for key simulation -- **Linux** — `evdev` to grab the physical mouse and `uinput` to forward pass-through events via a virtual device - -三平台都会进入同一套内部事件模型,并可拦截: - -- `WM_XBUTTONDOWN/UP` — side buttons (back/forward) -- `WM_MBUTTONDOWN/UP` — middle click -- `WM_MOUSEHWHEEL` — horizontal scroll -- `WM_MOUSEWHEEL` — vertical scroll (for inversion) - -被拦截的事件要么被 **block**(hook 返回 1)并替换成动作,要么原样 **pass-through** 交给应用处理。 - -### 设备目录与布局注册 - -- `core/logi_devices.py` resolves known product IDs and model aliases into a `ConnectedDeviceInfo` record with display name, DPI range, preferred gesture CIDs, and default UI layout key -- `core/device_layouts.py` stores image assets, hotspot coordinates, layout notes, and whether a layout is interactive or only a generic fallback -- `ui/backend.py` combines auto-detected device info with any persisted per-device layout override and exposes the effective layout to QML - -### 手势键检测 - -罗技的手势/拇指键并不总是以标准鼠标事件出现。Mouser 采用分层检测: - -1. **HID++ 2.0** (primary) — Opens the Logitech HID collection, discovers `REPROG_CONTROLS_V4` (feature `0x1B04`), ranks gesture CID candidates from the device registry plus control-capability heuristics, and diverts the best candidate. When supported, Mouser also enables RawXY movement data. -2. **Raw Input** (Windows fallback) — Registers for raw mouse input and detects extra button bits beyond the standard 5. -3. **Gesture tap/swipe dispatch** — A clean press/release emits `gesture_click`; once movement crosses the configured threshold, Mouser emits directional swipe actions instead. - -### 前台应用检测(`app_detector.py`) - -Polls the foreground window every 300ms using `GetForegroundWindow` → `GetWindowThreadProcessId` → process name. Handles UWP apps by resolving `ApplicationFrameHost.exe` to the actual child process. - -### 引擎(`engine.py`) - -核心编排器。前台应用变化时会做 **轻量化的 Profile 切换**:只清理并重新绑定回调,不会销毁 hook 线程或 HID++ 连接,从而避免完整重启带来的延迟与不稳定。引擎也会将已连接设备信息传递给 backend,让 QML 渲染正确的型号与布局状态。 - -### 设备重连 - -Mouser handles mouse power-off/on cycles automatically: - -- **HID++ layer** — `HidGestureListener` detects device disconnection (read errors) and enters a reconnect loop, retrying every 2–5 seconds until the device is back -- **Hook layer** — `MouseHook` listens for `WM_DEVICECHANGE` notifications and reinstalls the low-level mouse hook when devices are added or removed -- **UI layer** — connection state and device identity flow from HID++ → MouseHook → Engine → Backend (cross-thread safe via Qt signals) → QML, updating the status badge, device name, and active layout in real time - -### 配置 +## 已知限制 -All settings are stored in `%APPDATA%\Mouser\config.json` (Windows) or `~/Library/Application Support/Mouser/config.json` (macOS). The config supports: -- Multiple named profiles with per-profile button mappings, including gesture tap + swipe actions -- Per-profile app associations (list of `.exe` names) -- Global settings: DPI, scroll inversion, macOS trackpad filtering, gesture tuning, appearance, debug flags, Smart Shift, startup preferences (`start_at_login`, `start_minimized`), and display language (`language`: `"en"` / `"zh_CN"` / `"zh_TW"`) -- Per-device layout override selections for unsupported devices -- Automatic migration from older config versions +- **每设备映射尚未完全分离** — 布局覆盖按设备保存,但 Profile 中的按键映射仍是全局共享的。 +- **与 Logitech Options+ 冲突** — 两者会争夺 HID++ 访问权,运行 Mouser 前请先退出 Options+。 +- **滚动反转** 在 Windows 上使用合并后的事件注入,避免 LL hook 死锁;在主流应用中表现稳定,但在某些游戏或低级驱动中可能不正常。 +- **不需要管理员权限** — 但被注入的按键事件可能无法到达提权窗口或某些游戏;如有需要可以以提权方式运行 Mouser。 +- **Linux 应用检测有限** — X11 通过 `xdotool` 工作,KDE Wayland 通过 `kdotool` 工作;GNOME / 其他 Wayland 桌面环境仍回退到默认 Profile。 +- **Linux 设备权限** — Mouser 需要访问 `/dev/hidraw*`、`/dev/input/event*` 与 `/dev/uinput`。请使用 [`install-linux-permissions.sh`](packaging/linux/install-linux-permissions.sh) 脚本配置一次,而不是长期以 root 运行。 --- -## 项目结构 - -``` -mouser/ -├── main_qml.py # Application entry point (PySide6 + QML) -├── Mouser.bat # Quick-launch batch file -├── Mouser-mac.spec # Native macOS app-bundle spec -├── Mouser-linux.spec # Linux PyInstaller spec -├── build_macos_app.sh # macOS bundle build + icon/signing flow -├── .github/workflows/ -│ ├── ci.yml # CI checks (compile, tests, QML lint) -│ └── release.yml # Automated release builds (Win/macOS/Linux) -├── README.md -├── readme_mac_osx.md -├── requirements.txt -├── .gitignore -│ -├── core/ # Backend logic -│ ├── accessibility.py # macOS Accessibility trust checks -│ ├── engine.py # Core engine — wires hook ↔ simulator ↔ config -│ ├── mouse_hook.py # Low-level mouse hook + HID++ gesture listener -│ ├── hid_gesture.py # HID++ 2.0 gesture button divert (Bluetooth + Logi Bolt) -│ ├── 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) -│ ├── config.py # Config manager (JSON load/save/migrate) -│ └── app_detector.py # Foreground app polling -│ -├── ui/ # UI layer -│ ├── backend.py # QML ↔ Python bridge (QObject with properties/slots) -│ ├── locale_manager.py # i18n: English / Simplified Chinese / Traditional Chinese -│ └── qml/ -│ ├── Main.qml # App shell (sidebar + page stack + tray toast) -│ ├── MousePage.qml # Merged mouse diagram + profile manager -│ ├── ScrollPage.qml # DPI slider + scroll inversion toggles + language picker -│ ├── KeyCaptureDialog.qml # Custom keyboard shortcut input dialog -│ ├── HotspotDot.qml # Interactive button overlay on mouse image -│ ├── ActionChip.qml # Selectable action pill -│ └── Theme.js # Shared colors and constants -│ -└── images/ - ├── AppIcon.icns # Committed macOS app-bundle icon - ├── mouse.png # MX Master 3S top-down diagram - ├── icons/mouse-simple.svg # Generic fallback device card artwork - ├── logo.png # Mouser logo (source) - ├── logo.ico # Multi-size icon for shortcuts - ├── logo_icon.png # Square icon with background - ├── chrom.png # App icon: Chrome - ├── VSCODE.png # App icon: VS Code - ├── VLC.png # App icon: VLC - └── media.webp # App icon: Windows Media Player -``` - -## UI 概览 - -应用通过左侧侧边栏切换两页: - -### 鼠标与配置(Mouse & Profiles,页面 1) - -- **Left panel:** List of profiles. The "Default (All Apps)" profile is always present. Per-app profiles show the app icon and name. Select a profile to edit its mappings. -- **Right panel:** Device-aware mouse view. MX Master-family devices get clickable hotspot dots on the image; unsupported layouts fall back to a generic device card with an experimental "try another supported map" picker. -- **Add profile:** ComboBox at the bottom lists known apps (Chrome, Edge, VS Code, VLC, etc.). Click "+" to create a per-app profile. +## 路线图 -### 指针与滚动(Point & Scroll,页面 2) - -- **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. -- **Ignore trackpad (macOS):** Keep trackpad and Magic Mouse continuous scroll gestures out of Mouser mappings. Disable this only if you intentionally want Mouser to handle Magic Mouse or trackpad scroll events. -- **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. +- [ ] **更多设备的专用覆盖层** — 为 MX Anywhere、MX Vertical 及其他罗技系列添加真实热区图与示意图素材 +- [ ] **真正的每设备配置** — 当一台机器接入多只罗技鼠标时,干净地分离各自的映射 +- [ ] **动态按键清单** — 基于发现的 `REPROG_CONTROLS_V4` 控件构建按键列表,而不是依赖当前的固定按键集合 +- [ ] **更好的滚动反转** — 探索驱动级或拦截驱动方案 +- [ ] **手势滑动调优** — 提升滑动可靠性,并改进各设备的默认值 +- [ ] **按应用 Profile 自动创建** — 检测新应用并提示创建 Profile +- [ ] **配置导入 / 导出** — 在多台机器间共享配置 +- [ ] **托盘图标徽标** — 在托盘 tooltip 中显示当前 Profile 名 +- [ ] **更广的 Wayland 支持** — 把应用检测扩展到 X11 / KDE 之外,并在更多发行版上验证 +- [ ] **插件系统** — 允许第三方动作提供者 --- -## 已知限制 - -- **Early multi-device support** — only the MX Master family currently has a dedicated interactive overlay; MX Anywhere, MX Vertical, and unknown Logitech mice still use the generic fallback card -- **Per-device mappings are not fully separated yet** — layout overrides are stored per detected device, but profile mappings are still global rather than truly device-specific -- **Bluetooth and Logi Bolt supported** — HID++ gesture button divert works over both Bluetooth and Logi Bolt USB receivers -- **Conflicts with Logitech Options+** — both apps fight over HID++ access; quit Options+ before running Mouser -- **Scroll inversion is experimental** — uses coalesced `PostMessage` injection to avoid LL hook deadlocks; may not work perfectly in all apps -- **Admin not required** — but some games or elevated windows may not receive injected keystrokes -- **Linux app detection is still limited** — X11 works via `xdotool`, KDE Wayland works via `kdotool`, and GNOME / other Wayland compositors still fall back to the default profile -- **Linux remapping needs device permissions** — Mouser must be able to access Logitech `/dev/hidraw*`, read `/dev/input/event*`, and write `/dev/uinput`. Use the bundled `install-linux-permissions.sh` helper to install the udev rule, then reconnect the mouse and restart Mouser. - -## 未来计划 - -- [ ] **Dedicated overlays for more devices** — add real hotspot maps and artwork for MX Anywhere, MX Vertical, and other Logitech families -- [ ] **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 -- [ ] **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 -- [ ] **Export/import config** — share configurations between machines -- [ ] **Tray icon badge** — show active profile name in tray tooltip -- [x] **macOS support** — added via CGEventTap, Quartz CGEvent, and NSWorkspace -- [ ] **Broader Wayland support and Linux validation** — extend app detection beyond KDE Wayland / X11 and validate across more distros and desktop environments -- [ ] **Plugin system** — allow third-party action providers - ## 贡献指南 -欢迎贡献!快速开始: +非常欢迎贡献。 -1. Fork the repo and create a feature branch -2. 搭建开发环境(参见 [从源码安装](#installation)) -3. Make your changes and test with a supported Logitech HID++ mouse (MX Master family preferred for now) -4. Submit a pull request with a clear description +- **代码、修复、新功能:** Fork → 分支 → PR。开发环境、架构概览、调试选项与测试说明请见 [DEVELOPMENT.md](DEVELOPMENT.md)(英文)。 +- **新增罗技鼠标支持:** 按 [CONTRIBUTING_DEVICES.md](CONTRIBUTING_DEVICES.md) 中的 discovery dump 流程操作即可,哪怕只提交一份 dump 也很有帮助。 +- **目前需要帮助的方向:** + - 在更多罗技 HID++ 设备上测试 + - 改进滚动反转 + - 更广的 Linux / Wayland 验证 + - UI / UX 打磨、无障碍、翻译 -### 需要帮助的方向 +## 支持本项目 -- Testing with other Logitech HID++ devices -- Scroll inversion improvements -- Broader Linux/Wayland validation -- UI/UX polish and accessibility - -## 支持项目 - -If Mouser saves you from installing Logitech Options+, consider supporting development: +如果 Mouser 让你避开了 Logitech Options+,欢迎赞助开发:

@@ -533,23 +333,27 @@ If Mouser saves you from installing Logitech Options+, consider supporting devel

-Every bit helps keep the project going — thank you! - -## 许可证 - -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 -- **[@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, persistent rotating log file storage, Smart Shift enhanced support (HID++ 0x2111) with sensitivity control and scroll mode sync -- **[@nellwhoami](https://github.com/nellwhoami)** - Multi-language UI system (English, Simplified Chinese, Traditional Chinese) and Page Up/Down/Home/End navigation actions +- **[@andrew-sz](https://github.com/andrew-sz)** — macOS 移植:CGEventTap 鼠标 hook、Quartz 按键模拟、NSWorkspace 应用检测以及 NSEvent 媒体键支持。 +- **[@thisislvca](https://github.com/thisislvca)** — 项目重大扩展:包括 macOS 兼容性改进、多设备支持、新 UI 功能,以及对 Issue 的积极分类与跟进。 +- **[@awkure](https://github.com/awkure)** — 跨平台开机自启(Windows 注册表 + macOS LaunchAgent)、单实例守护、启动时最小化选项、MX Master 4 识别。 +- **[@hieshima](https://github.com/hieshima)** — Linux 支持(evdev + HID++ + uinput)、mode shift 按键映射、Smart Shift 开关、自定义快捷键支持、Linux 连接状态稳定化,以及 macOS CGEventTap 可靠性修复(超时自动重启、触摸板滚动过滤)。 +- **[@pavelzaichyk](https://github.com/pavelzaichyk)** — Next Tab / Previous Tab 浏览器动作、持久滚动日志、Smart Shift 增强支持(HID++ `0x2111`,含灵敏度控制与滚动模式同步)。 +- **[@nellwhoami](https://github.com/nellwhoami)** — 多语言 UI 系统(English、简体中文、繁體中文)以及 Page Up / Page Down / Home / End 导航动作。 +- **[@guilamu](https://github.com/guilamu)** — 鼠标按键互映(左 / 右 / 中 / 后退 / 前进)以及 HID++ 稳定性修复(按键卡住自动释放、连续超时后自动重连、Windows hook 异步分发队列)。 +- **[@vcanuel](https://github.com/vcanuel)** — macOS 上通过 `hidapi` 回退路径支持 Logi Bolt 接收器。 +- **[@farfromrefug](https://github.com/farfromrefug)** — 缩小 macOS Bundle 体积(Qt Quick Controls 精简、QtDBus、Qt 资源过滤)。 +- **[@MysticalMike60t](https://github.com/MysticalMike60t)** — README 结构思路(按 OS 折叠的构建小节)。 --- +## 许可证 + +本项目使用 [MIT 协议](LICENSE)。 + **Mouser** 与罗技(Logitech)无隶属关系亦未获其背书。“Logitech”“MX Master”“Options+” 为 Logitech International S.A. 的商标。 From 61cc911163c0eb82b54d7399f12c063fe74cc8ce Mon Sep 17 00:00:00 2001 From: ravibrock <66334356+ravibrock@users.noreply.github.com> Date: Thu, 30 Apr 2026 17:13:49 -0500 Subject: [PATCH 15/17] Improve gesture recognition --- core/hid_gesture.py | 174 +++++++++++++++++++- core/mouse_hook_macos.py | 337 ++++++++++++++++++++++++++++++++------ tests/test_hid_gesture.py | 19 ++- 3 files changed, 471 insertions(+), 59 deletions(-) diff --git a/core/hid_gesture.py b/core/hid_gesture.py index bbd66d9c..4cf82f00 100644 --- a/core/hid_gesture.py +++ b/core/hid_gesture.py @@ -684,6 +684,11 @@ def _parse(raw): return dev, feat, func, sw, params +def _decode_s8(value): + value = int(value) & 0xFF + return value - 0x100 if value & 0x80 else value + + def _hex_bytes(data): if not data: return "-" @@ -745,6 +750,13 @@ def __init__(self, on_down=None, on_up=None, on_move=None, self._connected_device_info = None self._last_controls = [] # REPROG_V4 controls from last connection self._consecutive_request_timeouts = 0 + self._last_reported_cids = None + self._last_rawxy_diag = None + self._last_feat_report_diag = None + self._rx_sequence = 0 + self._last_short_report_diag = None + self._short_report_prev_xy = None + self._short_report_origin_xy = None # ── public API ──────────────────────────────────────────────── @@ -943,6 +955,11 @@ def _tx(self, report_id, feat, func, params): for i, b in enumerate(params): if 4 + i < LONG_LEN: buf[4 + i] = b & 0xFF + print( + "[HidGesture] TX " + f"rid=0x{LONG_ID:02X} dev=0x{self._dev_idx:02X} feat=0x{feat:02X} " + f"func=0x{func:X} params=[{_hex_bytes(params)}] raw=[{_hex_bytes(buf)}]" + ) self._dev.write(buf) def _rx(self, timeout_ms=2000): @@ -953,7 +970,16 @@ def _rx(self, timeout_ms=2000): if dev is None: return None d = dev.read(64, timeout_ms) - return list(d) if d else None + if not d: + return None + raw = list(d) + self._rx_sequence += 1 + print( + "[HidGesture] RX " + f"#{self._rx_sequence} timeout_ms={timeout_ms} " + f"bytes=[{_hex_bytes(raw)}]" + ) + return raw def _request(self, feat, func, params, timeout_ms=2000): """Send a long HID++ request, wait for matching response.""" @@ -1169,7 +1195,16 @@ def add_candidate(cid): if raw_xy_capable and virtual_or_named and flags & 0x0020: add_candidate(cid) - return ordered or list(preferred) + ordered = ordered or list(preferred) + + # On macOS, some devices expose both the physical gesture button + # (0x00C3) and a virtual gesture button (0x00D7). Prefer the + # virtual CID first when both are present; it tends to provide a + # cleaner logical press/release stream for gesture handling. + if sys.platform == "darwin" and 0x00D7 in ordered and 0x00C3 in ordered: + ordered = [0x00D7] + [cid for cid in ordered if cid != 0x00D7] + + return ordered def _divert(self): """Divert the selected gesture control and enable raw XY when supported.""" @@ -1180,13 +1215,19 @@ def _divert(self): resp = self._set_cid_reporting(cid, 0x33) if resp is not None: self._rawxy_enabled = True - print(f"[HidGesture] Divert {_format_cid(cid)} with RawXY: OK") + print( + f"[HidGesture] Divert {_format_cid(cid)} with RawXY: OK " + f"(selected gesture CID={_format_cid(self._gesture_cid)})" + ) return True self._rawxy_enabled = False resp = self._set_cid_reporting(cid, 0x03) ok = resp is not None - print(f"[HidGesture] Divert {_format_cid(cid)}: " - f"{'OK' if ok else 'FAILED'}") + print( + f"[HidGesture] Divert {_format_cid(cid)}: " + f"{'OK' if ok else 'FAILED'} " + f"(selected gesture CID={_format_cid(self._gesture_cid)})" + ) if ok: return True self._gesture_cid = DEFAULT_GESTURE_CID @@ -1521,6 +1562,8 @@ def _force_release_stale_holds(self): if self._held: self._held = False print("[HidGesture] Gesture force-released (stale hold)") + self._short_report_prev_xy = None + self._short_report_origin_xy = None if self._on_up: try: self._on_up() @@ -1539,21 +1582,50 @@ def _force_release_stale_holds(self): def _on_report(self, raw): """Inspect an incoming HID++ report for diverted button / raw XY events.""" + if self._on_short_report(raw): + return msg = _parse(raw) if msg is None: + print(f"[HidGesture] Unparsed report raw=[{_hex_bytes(raw)}]") return - _, feat, func, _sw, params = msg + report_id, feat, func, sw, params = msg if feat != self._feat_idx: return + feat_diag = (report_id, feat, func, sw, tuple(params)) + self._last_feat_report_diag = feat_diag + print( + "[HidGesture] Feature report " + f"rid=0x{report_id:02X} feat=0x{feat:02X} " + f"func=0x{func:X} sw=0x{sw:01X} " + f"params=[{_hex_bytes(params)}] raw=[{_hex_bytes(raw)}]" + ) + if func == 1: if not self._rawxy_enabled: + print( + "[HidGesture] Ignoring func=0x1 report because RawXY " + "is not enabled" + ) return - if len(params) < 4 or not self._held: + if len(params) < 4: + print( + "[HidGesture] Ignoring short RawXY report " + f"params=[{_hex_bytes(params)}]" + ) return dx = self._decode_s16(params[0], params[1]) dy = self._decode_s16(params[2], params[3]) + rawxy_diag = (dx, dy, self._held) + self._last_rawxy_diag = rawxy_diag + print( + "[HidGesture] RawXY report " + f"dx={dx} dy={dy} held={self._held} " + f"selected gesture CID={_format_cid(self._gesture_cid)}" + ) + if not self._held: + return if (dx or dy) and self._on_move: try: self._on_move(dx, dy) @@ -1562,6 +1634,10 @@ def _on_report(self, raw): return if func != 0: + print( + "[HidGesture] Unhandled feature report " + f"func=0x{func:X} params=[{_hex_bytes(params)}]" + ) return # Params: sequential CID pairs terminated by 0x0000 @@ -1574,6 +1650,15 @@ def _on_report(self, raw): cids.add(c) i += 2 + reported_cids = tuple(sorted(cids)) + self._last_reported_cids = reported_cids + formatted = ", ".join(_format_cid(cid) for cid in reported_cids) or "-" + print( + "[HidGesture] Button CID report " + f"selected={_format_cid(self._gesture_cid)} " + f"reported=[{formatted}] held={self._held}" + ) + gesture_now = self._gesture_cid in cids if gesture_now and not self._held: @@ -1616,6 +1701,80 @@ def _on_report(self, raw): except Exception as e: print(f"[HidGesture] extra up callback error: {e}") + def _on_short_report(self, raw): + """Handle the short 8-byte report shape seen on some macOS devices. + + Observed pattern: + - byte0 == 0x02 + - byte1 bit 0x20 reflects gesture-button held state + - bytes3/4 appear to carry small signed movement deltas while held + """ + if len(raw) != 8 or raw[0] != 0x02: + return False + + held_now = bool(raw[1] & 0x20) + x = _decode_s8(raw[3]) + # The short 0x02 macOS report stream appears to encode vertical + # movement with the opposite sign from the long RawXY path. + y = -_decode_s8(raw[4]) + diag = (held_now, x, y, tuple(raw)) + self._last_short_report_diag = diag + print( + "[HidGesture] Short report " + f"held={held_now} x={x} y={y} bytes=[{_hex_bytes(raw)}]" + ) + if held_now and not self._held: + self._held = True + self._short_report_prev_xy = (x, y) + self._short_report_origin_xy = (x, y) + print("[HidGesture] Gesture DOWN (short report)") + if self._on_down: + try: + self._on_down() + except Exception as e: + print(f"[HidGesture] down callback error: {e}") + elif not held_now and self._held: + self._held = False + self._short_report_prev_xy = None + self._short_report_origin_xy = None + print("[HidGesture] Gesture UP (short report)") + if self._on_up: + try: + self._on_up() + except Exception as e: + print(f"[HidGesture] up callback error: {e}") + + rel_x = 0 + rel_y = 0 + if held_now: + prev = self._short_report_prev_xy + origin = self._short_report_origin_xy + if prev is None: + self._short_report_prev_xy = (x, y) + elif abs(x - prev[0]) > 48 or abs(y - prev[1]) > 48: + print( + "[HidGesture] Short report outlier ignored " + f"x={x} y={y} prev_x={prev[0]} prev_y={prev[1]} held={held_now}" + ) + else: + self._short_report_prev_xy = (x, y) + if origin is None: + self._short_report_origin_xy = (x, y) + origin = self._short_report_origin_xy + rel_x = x - origin[0] + rel_y = y - origin[1] + + if held_now and (rel_x or rel_y) and self._on_move: + print( + "[HidGesture] Short report move " + f"rel_x={rel_x} rel_y={rel_y} held={held_now}" + ) + try: + self._on_move(rel_x, rel_y, {"mode": "absolute"}) + except Exception as e: + print(f"[HidGesture] move callback error: {e}") + return True + # ── connect / main loop ─────────────────────────────────────── def _try_connect(self): @@ -1903,6 +2062,7 @@ def _main_loop(self): self._consecutive_request_timeouts = 0 if self._held: self._held = False + self._short_report_prev_xy = None print("[HidGesture] Gesture force-released on disconnect") if self._on_up: try: diff --git a/core/mouse_hook_macos.py b/core/mouse_hook_macos.py index e6d5e7a8..b2e59f9b 100644 --- a/core/mouse_hook_macos.py +++ b/core/mouse_hook_macos.py @@ -36,6 +36,7 @@ def _autoreleased(fn): def wrapper(*args, **kwargs): with objc.autorelease_pool(): return fn(*args, **kwargs) + return wrapper @@ -66,6 +67,49 @@ def __init__(self): self._dispatch_queue = queue.Queue() self._dispatch_thread = None self._first_event_logged = False + self._gesture_prefer_hid_until = 0.0 + self._pending_event_tap_dx = 0.0 + self._pending_event_tap_dy = 0.0 + self._gesture_consumed = False + + _EVENT_TAP_HID_GRACE_MS = 30 + _MAX_EFFECTIVE_GESTURE_COOLDOWN_MS = 70 + + def _clear_pending_event_tap_gesture(self): + self._pending_event_tap_dx = 0.0 + self._pending_event_tap_dy = 0.0 + + def _gesture_cooldown_duration_ms(self): + return min( + self._gesture_cooldown_ms, + self._MAX_EFFECTIVE_GESTURE_COOLDOWN_MS, + ) + + def _buffer_event_tap_gesture(self, delta_x, delta_y): + self._pending_event_tap_dx += delta_x + self._pending_event_tap_dy += delta_y + + def _has_pending_event_tap_gesture(self): + return bool(self._pending_event_tap_dx or self._pending_event_tap_dy) + + def _should_buffer_event_tap_gesture(self): + if not self._gesture_active or self._gesture_input_source is not None: + return False + if not self._hid_gesture_available(): + return False + return time.monotonic() < self._gesture_prefer_hid_until + + def _flush_pending_event_tap_gesture(self): + if not self._has_pending_event_tap_gesture(): + return + delta_x = self._pending_event_tap_dx + delta_y = self._pending_event_tap_dy + self._clear_pending_event_tap_gesture() + self._emit_debug( + "Gesture using buffered event_tap motion " + f"dx={delta_x} dy={delta_y}" + ) + self._accumulate_gesture_delta(delta_x, delta_y, "event_tap") def _negate_scroll_axis(self, cg_event, axis): for field_name in ( @@ -132,20 +176,38 @@ def _post_inverted_scroll_event(self, cg_event): Quartz.CGEventPost(Quartz.kCGHIDEventTap, inverted) return True + def _gesture_distance(self): + return (self._gesture_delta_x ** 2 + self._gesture_delta_y ** 2) ** 0.5 + + def _gesture_click_radius(self): + return max(8.0, min(self._gesture_threshold * 0.5, self._gesture_deadzone)) + + def _classify_gesture_displacement(self): + delta_x = self._gesture_delta_x + delta_y = self._gesture_delta_y + abs_x = abs(delta_x) + abs_y = abs(delta_y) + if max(abs_x, abs_y) <= self._gesture_click_radius(): + return MouseEvent.GESTURE_CLICK + if abs_x >= abs_y: + return ( + MouseEvent.GESTURE_SWIPE_RIGHT + if delta_x > 0 + else MouseEvent.GESTURE_SWIPE_LEFT + ) + return ( + MouseEvent.GESTURE_SWIPE_DOWN + if delta_y > 0 + else MouseEvent.GESTURE_SWIPE_UP + ) + def _accumulate_gesture_delta(self, delta_x, delta_y, source): if not (self._gesture_direction_enabled and self._gesture_active): return - if self._gesture_cooldown_active(): + if self._gesture_consumed: self._emit_debug( - f"Gesture cooldown active source={source} dx={delta_x} dy={delta_y}" - ) - self._emit_gesture_event( - { - "type": "cooldown_active", - "source": source, - "dx": delta_x, - "dy": delta_y, - } + f"Gesture move ignored after consume source={source} " + f"dx={delta_x} dy={delta_y}" ) return if not self._gesture_tracking: @@ -199,52 +261,133 @@ def _accumulate_gesture_delta(self, delta_x, delta_y, source): } ) - while True: - gesture_event = self._detect_gesture_event() - if not gesture_event: - return + gesture_event = self._detect_gesture_event() + if not gesture_event: + self._emit_debug( + "Gesture threshold not yet reached " + f"source={source} distance={self._gesture_distance():.1f} " + f"threshold={self._gesture_threshold:.1f}" + ) + return + + self._gesture_triggered = True + self._gesture_consumed = True + self._emit_debug( + "Gesture detected " + f"{gesture_event} source={source} " + f"delta_x={self._gesture_delta_x} delta_y={self._gesture_delta_y}" + ) + self._emit_gesture_event( + { + "type": "detected", + "event_name": gesture_event, + "source": source, + "dx": self._gesture_delta_x, + "dy": self._gesture_delta_y, + } + ) + self._dispatch_queue.put( + MouseEvent( + gesture_event, + { + "delta_x": self._gesture_delta_x, + "delta_y": self._gesture_delta_y, + "source": source, + }, + ) + ) + self._finish_gesture_tracking() + return - self._gesture_triggered = True + def _accumulate_gesture_position(self, pos_x, pos_y, source): + if not (self._gesture_direction_enabled and self._gesture_active): + return + if self._gesture_consumed: self._emit_debug( - "Gesture detected " - f"{gesture_event} source={source} " - f"delta_x={self._gesture_delta_x} delta_y={self._gesture_delta_y}" + f"Gesture position ignored after consume source={source} " + f"x={pos_x} y={pos_y}" ) + return + if not self._gesture_tracking: + self._emit_debug(f"Gesture tracking started source={source}") self._emit_gesture_event( { - "type": "detected", - "event_name": gesture_event, + "type": "tracking_started", "source": source, - "dx": self._gesture_delta_x, - "dy": self._gesture_delta_y, } ) - self._dispatch_queue.put( - MouseEvent( - gesture_event, - { - "delta_x": self._gesture_delta_x, - "delta_y": self._gesture_delta_y, - "source": source, - }, - ) + self._start_gesture_tracking() + + now = time.monotonic() + idle_ms = (now - self._gesture_last_move_at) * 1000.0 + if idle_ms > self._gesture_timeout_ms: + self._emit_debug( + f"Gesture segment reset timeout source={source} " + f"accum_x={self._gesture_delta_x} accum_y={self._gesture_delta_y}" ) - self._gesture_cooldown_until = ( - time.monotonic() + self._gesture_cooldown_ms / 1000.0 + self._start_gesture_tracking() + + if self._gesture_input_source not in (None, source): + self._emit_debug( + f"Gesture source locked to {self._gesture_input_source}; " + f"ignoring {source} x={pos_x} y={pos_y}" ) + return + self._gesture_input_source = source + + self._gesture_delta_x = pos_x + self._gesture_delta_y = pos_y + self._gesture_last_move_at = now + self._emit_debug( + f"Gesture position source={source} " + f"x={self._gesture_delta_x} y={self._gesture_delta_y}" + ) + self._emit_gesture_event( + { + "type": "segment", + "source": source, + "dx": self._gesture_delta_x, + "dy": self._gesture_delta_y, + } + ) + + gesture_event = self._detect_gesture_event() + if not gesture_event: self._emit_debug( - f"Gesture cooldown started source={source} " - f"for_ms={self._gesture_cooldown_ms}" + "Gesture threshold not yet reached " + f"source={source} distance={self._gesture_distance():.1f} " + f"threshold={self._gesture_threshold:.1f}" ) - self._emit_gesture_event( + return + + self._gesture_triggered = True + self._gesture_consumed = True + self._emit_debug( + "Gesture detected " + f"{gesture_event} source={source} " + f"delta_x={self._gesture_delta_x} delta_y={self._gesture_delta_y}" + ) + self._emit_gesture_event( + { + "type": "detected", + "event_name": gesture_event, + "source": source, + "dx": self._gesture_delta_x, + "dy": self._gesture_delta_y, + } + ) + self._dispatch_queue.put( + MouseEvent( + gesture_event, { - "type": "cooldown_started", + "delta_x": self._gesture_delta_x, + "delta_y": self._gesture_delta_y, "source": source, - "for_ms": self._gesture_cooldown_ms, - } + }, ) - self._finish_gesture_tracking() - return + ) + self._finish_gesture_tracking() + return def _dispatch_worker(self): while self._running: @@ -315,6 +458,31 @@ def _event_tap_callback(self, proxy, event_type, cg_event, refcon): ) if self._gesture_input_source == "hid_rawxy": return None + if self._should_buffer_event_tap_gesture(): + self._buffer_event_tap_gesture( + Quartz.CGEventGetIntegerValueField( + cg_event, Quartz.kCGMouseEventDeltaX + ), + Quartz.CGEventGetIntegerValueField( + cg_event, Quartz.kCGMouseEventDeltaY + ), + ) + self._emit_debug( + "Gesture buffering event_tap motion while waiting " + "for hid_rawxy" + ) + return None + if self._gesture_input_source is None and self._has_pending_event_tap_gesture(): + self._buffer_event_tap_gesture( + Quartz.CGEventGetIntegerValueField( + cg_event, Quartz.kCGMouseEventDeltaX + ), + Quartz.CGEventGetIntegerValueField( + cg_event, Quartz.kCGMouseEventDeltaY + ), + ) + self._flush_pending_event_tap_gesture() + return None self._accumulate_gesture_delta( Quartz.CGEventGetIntegerValueField( cg_event, Quartz.kCGMouseEventDeltaX @@ -423,9 +591,17 @@ def _on_hid_gesture_down(self): if not self._gesture_active: self._gesture_active = True self._gesture_triggered = False + self._gesture_consumed = False + self._clear_pending_event_tap_gesture() + if self._hid_gesture_available(): + self._gesture_prefer_hid_until = ( + time.monotonic() + self._EVENT_TAP_HID_GRACE_MS / 1000.0 + ) + else: + self._gesture_prefer_hid_until = 0.0 self._emit_debug("HID gesture button down") self._emit_gesture_event({"type": "button_down"}) - if self._gesture_direction_enabled and not self._gesture_cooldown_active(): + if self._gesture_direction_enabled: self._start_gesture_tracking() else: self._gesture_tracking = False @@ -434,7 +610,51 @@ def _on_hid_gesture_down(self): def _on_hid_gesture_up(self): if self._gesture_active: should_click = not self._gesture_triggered + dispatch_event = None + if ( + self._gesture_direction_enabled + and not self._gesture_consumed + and self._gesture_tracking + ): + gesture_event = self._classify_gesture_displacement() + self._emit_debug( + "Gesture release classification " + f"event={gesture_event} delta_x={self._gesture_delta_x} " + f"delta_y={self._gesture_delta_y} " + f"distance={self._gesture_distance():.1f}" + ) + if gesture_event == MouseEvent.GESTURE_CLICK: + should_click = True + else: + should_click = False + dispatch_event = MouseEvent( + gesture_event, + { + "delta_x": self._gesture_delta_x, + "delta_y": self._gesture_delta_y, + "source": self._gesture_input_source, + }, + ) + self._gesture_triggered = True + self._gesture_consumed = True + self._emit_debug( + "Gesture released -> detected " + f"{gesture_event} delta_x={self._gesture_delta_x} " + f"delta_y={self._gesture_delta_y}" + ) + self._emit_gesture_event( + { + "type": "detected", + "event_name": gesture_event, + "source": self._gesture_input_source, + "dx": self._gesture_delta_x, + "dy": self._gesture_delta_y, + } + ) self._gesture_active = False + self._gesture_consumed = False + self._gesture_prefer_hid_until = 0.0 + self._clear_pending_event_tap_gesture() self._finish_gesture_tracking() self._gesture_triggered = False self._emit_debug( @@ -446,6 +666,8 @@ def _on_hid_gesture_up(self): "click_candidate": should_click, } ) + if dispatch_event is not None: + self._dispatch(dispatch_event) if should_click: self._dispatch(MouseEvent(MouseEvent.GESTURE_CLICK)) @@ -465,8 +687,18 @@ def _on_hid_dpi_switch_up(self): self._emit_debug("HID DPI switch button up") self._dispatch(MouseEvent(MouseEvent.DPI_SWITCH_UP)) - def _on_hid_gesture_move(self, delta_x, delta_y): - self._emit_debug(f"HID rawxy move dx={delta_x} dy={delta_y}") + def _on_hid_gesture_move(self, delta_x, delta_y, meta=None): + if self._has_pending_event_tap_gesture(): + self._emit_debug( + "Gesture discarding buffered event_tap motion in favor of " + "hid_rawxy" + ) + self._clear_pending_event_tap_gesture() + self._gesture_prefer_hid_until = 0.0 + self._emit_debug( + f"HID rawxy move dx={delta_x} dy={delta_y} " + f"mode={(meta or {}).get('mode', 'delta')}" + ) self._emit_gesture_event( { "type": "move", @@ -475,7 +707,10 @@ def _on_hid_gesture_move(self, delta_x, delta_y): "dy": delta_y, } ) - self._accumulate_gesture_delta(delta_x, delta_y, "hid_rawxy") + if (meta or {}).get("mode") == "absolute": + self._accumulate_gesture_position(delta_x, delta_y, "hid_rawxy") + else: + self._accumulate_gesture_delta(delta_x, delta_y, "hid_rawxy") def _register_wake_observer(self): try: @@ -506,11 +741,13 @@ def _on_session_resign(notification): def _on_session_activate(notification): _re_enable_tap_and_reconnect("user-switch") - self._wake_observer = notification_center.addObserverForName_object_queue_usingBlock_( - "NSWorkspaceDidWakeNotification", - None, - None, - _on_wake, + self._wake_observer = ( + notification_center.addObserverForName_object_queue_usingBlock_( + "NSWorkspaceDidWakeNotification", + None, + None, + _on_wake, + ) ) self._session_resign_observer = ( notification_center.addObserverForName_object_queue_usingBlock_( diff --git a/tests/test_hid_gesture.py b/tests/test_hid_gesture.py index a2aeb1a8..f36a8973 100644 --- a/tests/test_hid_gesture.py +++ b/tests/test_hid_gesture.py @@ -103,7 +103,8 @@ def test_choose_gesture_candidates_prefers_known_device_cids(self): device_spec=device_spec, ) - self.assertEqual(candidates[:2], [0x00C3, 0x00D7]) + expected = [0x00D7, 0x00C3] if sys.platform == "darwin" else [0x00C3, 0x00D7] + self.assertEqual(candidates[:2], expected) def test_choose_gesture_candidates_uses_capability_heuristic(self): listener = hid_gesture.HidGestureListener() @@ -117,12 +118,26 @@ def test_choose_gesture_candidates_uses_capability_heuristic(self): self.assertEqual(candidates[0], 0x00F1) + def test_choose_gesture_candidates_prefers_virtual_gesture_button_on_macos(self): + listener = hid_gesture.HidGestureListener() + + with patch.object(sys, "platform", "darwin"): + candidates = listener._choose_gesture_candidates( + [ + {"cid": 0x00D7, "flags": 0x03B0, "mapping_flags": 0x0051}, + {"cid": 0x00C3, "flags": 0x0130, "mapping_flags": 0x0011}, + ], + ) + + self.assertEqual(candidates[:2], [0x00D7, 0x00C3]) + def test_choose_gesture_candidates_falls_back_to_defaults(self): listener = hid_gesture.HidGestureListener() self.assertEqual( listener._choose_gesture_candidates([]), - list(hid_gesture.DEFAULT_GESTURE_CIDS), + ([0x00D7, 0x00C3] if sys.platform == "darwin" + else list(hid_gesture.DEFAULT_GESTURE_CIDS)), ) From 44b4a80c98f3ea843c919371ebc4dfddb936f670 Mon Sep 17 00:00:00 2001 From: ravibrock <66334356+ravibrock@users.noreply.github.com> Date: Thu, 30 Apr 2026 17:24:47 -0500 Subject: [PATCH 16/17] Fix waterfox passthrough bug --- core/mouse_hook_macos.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/core/mouse_hook_macos.py b/core/mouse_hook_macos.py index b2e59f9b..1307d312 100644 --- a/core/mouse_hook_macos.py +++ b/core/mouse_hook_macos.py @@ -47,6 +47,7 @@ def wrapper(*args, **kwargs): _INJECTED_EVENT_MARKER = 0x4D4F5554 _kCGEventTapDisabledByTimeout = 0xFFFFFFFE _kCGEventTapDisabledByUserInput = 0xFFFFFFFF +_BTN_GESTURE_RAW_CANDIDATES = {5, 6} class MouseHook(BaseMouseHook): @@ -111,6 +112,21 @@ def _flush_pending_event_tap_gesture(self): ) self._accumulate_gesture_delta(delta_x, delta_y, "event_tap") + def _gesture_binding_active(self): + if self._callbacks.get(MouseEvent.GESTURE_CLICK): + return True + if not self._gesture_direction_enabled: + return False + for event_type in ( + MouseEvent.GESTURE_SWIPE_LEFT, + MouseEvent.GESTURE_SWIPE_RIGHT, + MouseEvent.GESTURE_SWIPE_UP, + MouseEvent.GESTURE_SWIPE_DOWN, + ): + if self._callbacks.get(event_type): + return True + return False + def _negate_scroll_axis(self, cg_event, axis): for field_name in ( f"kCGScrollWheelEventDeltaAxis{axis}", @@ -428,6 +444,23 @@ def _event_tap_callback(self, proxy, event_type, cg_event, refcon): pass mouse_event = None should_block = False + if event_type in ( + Quartz.kCGEventOtherMouseDown, + Quartz.kCGEventOtherMouseUp, + Quartz.kCGEventOtherMouseDragged, + ): + btn = Quartz.CGEventGetIntegerValueField( + cg_event, Quartz.kCGMouseEventButtonNumber + ) + if ( + btn in _BTN_GESTURE_RAW_CANDIDATES + and self._gesture_binding_active() + ): + self._emit_debug( + "Swallowing raw gesture button event " + f"type={int(event_type)} btn={btn}" + ) + return None if ( event_type From fe05b513da3c9e2998ed277d3806cec914e716f5 Mon Sep 17 00:00:00 2001 From: ravibrock <66334356+ravibrock@users.noreply.github.com> Date: Thu, 30 Apr 2026 21:18:42 -0500 Subject: [PATCH 17/17] Tweak gesture detection --- core/mouse_hook_macos.py | 45 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/core/mouse_hook_macos.py b/core/mouse_hook_macos.py index 1307d312..10b4ad12 100644 --- a/core/mouse_hook_macos.py +++ b/core/mouse_hook_macos.py @@ -75,6 +75,12 @@ def __init__(self): _EVENT_TAP_HID_GRACE_MS = 30 _MAX_EFFECTIVE_GESTURE_COOLDOWN_MS = 70 + _HID_ABSOLUTE_VERTICAL_WEIGHT = 0.55 + + def _normalized_gesture_components(self, delta_x, delta_y): + if self._gesture_input_source == "hid_rawxy": + return delta_x, delta_y * self._HID_ABSOLUTE_VERTICAL_WEIGHT + return delta_x, delta_y def _clear_pending_event_tap_gesture(self): self._pending_event_tap_dx = 0.0 @@ -193,14 +199,20 @@ def _post_inverted_scroll_event(self, cg_event): return True def _gesture_distance(self): - return (self._gesture_delta_x ** 2 + self._gesture_delta_y ** 2) ** 0.5 + delta_x, delta_y = self._normalized_gesture_components( + self._gesture_delta_x, + self._gesture_delta_y, + ) + return (delta_x ** 2 + delta_y ** 2) ** 0.5 def _gesture_click_radius(self): return max(8.0, min(self._gesture_threshold * 0.5, self._gesture_deadzone)) def _classify_gesture_displacement(self): - delta_x = self._gesture_delta_x - delta_y = self._gesture_delta_y + delta_x, delta_y = self._normalized_gesture_components( + self._gesture_delta_x, + self._gesture_delta_y, + ) abs_x = abs(delta_x) abs_y = abs(delta_y) if max(abs_x, abs_y) <= self._gesture_click_radius(): @@ -217,6 +229,33 @@ def _classify_gesture_displacement(self): else MouseEvent.GESTURE_SWIPE_UP ) + def _detect_gesture_event(self): + delta_x, delta_y = self._normalized_gesture_components( + self._gesture_delta_x, + self._gesture_delta_y, + ) + + abs_x = abs(delta_x) + abs_y = abs(delta_y) + dominant = max(abs_x, abs_y) + if dominant < self._gesture_threshold: + return None + + cross_limit = max(self._gesture_deadzone, dominant * 0.35) + + if abs_x > abs_y: + if abs_y > cross_limit: + return None + if delta_x > 0: + return MouseEvent.GESTURE_SWIPE_RIGHT + return MouseEvent.GESTURE_SWIPE_LEFT + + if abs_x > cross_limit: + return None + if delta_y > 0: + return MouseEvent.GESTURE_SWIPE_DOWN + return MouseEvent.GESTURE_SWIPE_UP + def _accumulate_gesture_delta(self, delta_x, delta_y, source): if not (self._gesture_direction_enabled and self._gesture_active): return