Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@ No telemetry. No cloud. No Logitech account required.
## Features

- **macOS support** — full macOS compatibility using CGEventTap for mouse hooking, Quartz CGEvent for key simulation, and NSWorkspace for app detection. See [macOS Setup Guide](readme_mac_osx.md) for details.
- **Experimental Linux support** — evdev/uinput button remapping, HID++ gesture support, X11 foreground-app detection, and KDE Wayland app detection via `kdotool`
- **macOS start at login** — manages a per-user LaunchAgent from the UI, with an optional "Launch hidden after login" mode for menu-bar startup
- **Remap supported programmable controls** — MX Master-family layouts expose middle click, gesture button, back, forward, and horizontal scroll actions
- **Per-application profiles** — automatically switch button mappings when you switch apps (e.g., different bindings for Chrome vs. VS Code)
- **Desktop navigation actions** — includes previous/next desktop switching on both platforms, plus Mission Control, App Expose, Launchpad, and Show Desktop on macOS
- **Desktop navigation actions** — includes previous/next desktop switching on Windows, macOS, and Linux (GNOME/KDE defaults), plus Mission Control, App Expose, Launchpad, and Show Desktop on macOS
- **Platform-aware built-in actions** across navigation, browser, editing, and media categories
- **DPI / pointer speed control** — slider from 200–8000 DPI with quick presets, synced to the device via HID++
- **Scroll direction inversion** — independent toggles for vertical and horizontal scroll
Expand Down Expand Up @@ -116,11 +117,13 @@ For macOS setup, native bundle packaging, and Accessibility / login-item notes,

### Prerequisites

- **Windows 10/11** or **macOS 12+ (Monterey)**
- **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:** 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)

### Steps

Expand Down Expand Up @@ -254,6 +257,7 @@ Mouser uses a platform-specific mouse hook behind a shared `MouseHook` abstracti

- **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:

Expand Down Expand Up @@ -373,13 +377,14 @@ The app has two pages accessible from a slim sidebar:

## Known Limitations

- **Windows & macOS only** — Linux is not yet supported
- **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 recommended** — HID++ gesture button divert works best over Bluetooth; USB receiver has partial support
- **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 read `/dev/input/event*` and write `/dev/uinput`

## Future Work

Expand All @@ -394,7 +399,7 @@ The app has two pages accessible from a slim sidebar:
- [ ] **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
- [ ] **Linux support** — investigate `libevdev` / `evdev` hooks
- [ ] **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
Expand Down
209 changes: 207 additions & 2 deletions core/app_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@

import os
import plistlib
import shlex
import shutil
import posixpath
import sys
import threading
Expand Down Expand Up @@ -381,6 +383,8 @@ def _make_entry(app_id: str, label: str, *, path: str = "", aliases=None, legacy
normalized_path = (
posixpath.normpath(path) if os.path.isabs(path) else os.path.abspath(path)
)
elif sys.platform == "linux":
normalized_path = os.path.realpath(path)
else:
normalized_path = os.path.abspath(path)
alias_values = list(aliases or [])
Expand Down Expand Up @@ -524,7 +528,7 @@ def _expand_windows_path_hint(path_hint: str):
def _path_if_usable(path: str):
if not path:
return ""
normalized = os.path.abspath(path)
normalized = os.path.realpath(path) if sys.platform == "linux" else os.path.abspath(path)
return normalized if os.path.exists(normalized) else ""


Expand Down Expand Up @@ -687,11 +691,158 @@ def _discover_windows_apps():
return sorted(entries, key=_entry_sort_key)


def _linux_app_dirs():
data_home = os.environ.get(
"XDG_DATA_HOME",
os.path.join(os.path.expanduser("~"), ".local", "share"),
)
return [
os.path.join(data_home, "applications"),
os.path.join(os.path.expanduser("~"), ".local", "share", "flatpak", "exports", "share", "applications"),
"/var/lib/flatpak/exports/share/applications",
"/usr/local/share/applications",
"/usr/share/applications",
]


def _iter_linux_desktop_files():
seen = set()
for root in _linux_app_dirs():
if not os.path.isdir(root):
continue
for current_root, dirnames, filenames in os.walk(root):
dirnames.sort(key=str.casefold)
for filename in sorted(filenames, key=str.casefold):
if not filename.endswith(".desktop"):
continue
desktop_path = os.path.realpath(os.path.join(current_root, filename))
if desktop_path in seen:
continue
seen.add(desktop_path)
yield desktop_path


def _extract_linux_exec_command(exec_value: str):
if not exec_value:
return ""
try:
tokens = shlex.split(exec_value, posix=True)
except ValueError:
tokens = exec_value.split()
if not tokens:
return ""

index = 0
if os.path.basename(tokens[0]) == "env":
index = 1
while index < len(tokens):
token = tokens[index]
if token.startswith("%") or "=" not in token:
break
index += 1

while index < len(tokens):
token = tokens[index]
if token.startswith("%"):
index += 1
continue
return token
return ""


def _resolve_linux_exec_path(exec_value: str, try_exec: str = ""):
command = (try_exec or "").strip() or _extract_linux_exec_command(exec_value)
if not command:
return ""

expanded = os.path.expanduser(os.path.expandvars(command))
if os.path.isabs(expanded):
return _path_if_usable(expanded)

resolved = shutil.which(expanded)
return _path_if_usable(resolved)


def _read_linux_desktop_entry(desktop_path: str):
try:
lines = Path(desktop_path).read_text(encoding="utf-8", errors="replace").splitlines()
except OSError:
return None

in_desktop_entry = False
fields = {}
for raw_line in lines:
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if line.startswith("[") and line.endswith("]"):
in_desktop_entry = (line == "[Desktop Entry]")
continue
if not in_desktop_entry or "=" not in line:
continue
key, value = line.split("=", 1)
fields.setdefault(key, value.strip())

if fields.get("Type", "Application") != "Application":
return None
if fields.get("Hidden", "").lower() == "true":
return None
if fields.get("NoDisplay", "").lower() == "true":
return None

exec_path = _resolve_linux_exec_path(fields.get("Exec", ""), fields.get("TryExec", ""))
if not exec_path:
return None

label = fields.get("Name") or Path(desktop_path).stem
desktop_id = Path(desktop_path).name
hint = (
_hint_for(fields.get("StartupWMClass", ""))
or _hint_for(os.path.basename(exec_path))
or _hint_for(label)
)
aliases = [
desktop_id,
Path(desktop_path).stem,
label,
os.path.basename(exec_path),
Path(exec_path).stem,
]
startup_wm_class = fields.get("StartupWMClass", "")
if startup_wm_class:
aliases.append(startup_wm_class)
if hint:
aliases.extend(hint.get("aliases", []))

return _make_entry(
(hint or {}).get("id") or desktop_id,
(hint or {}).get("label") or label,
path=exec_path,
aliases=aliases,
legacy_icon=(hint or {}).get("legacy_icon", ""),
)


def _discover_linux_apps():
entries = {}
for desktop_path in _iter_linux_desktop_files():
entry = _read_linux_desktop_entry(desktop_path)
if not entry:
continue
entries[entry["id"].casefold()] = _merge_entry(
entry,
entries.get(entry["id"].casefold()),
)
return sorted(entries.values(), key=_entry_sort_key)


def _build_catalog():
if sys.platform == "darwin":
return _discover_macos_apps()
if sys.platform == "win32":
return _discover_windows_apps()
if sys.platform == "linux":
return _discover_linux_apps()
return []


Expand All @@ -717,16 +868,70 @@ def _find_catalog_entry(spec: str):
return None


def _linux_catalog_path_tokens(path: str):
normalized = os.path.realpath(path)
basename = os.path.basename(normalized)
stem = Path(normalized).stem
return normalized, basename, stem


def _linux_catalog_entry_for_path(path: str):
normalized, basename, stem = _linux_catalog_path_tokens(path)
entries = get_app_catalog()

def matches_for(value: str):
key = value.casefold()
return [
entry for entry in entries
if key == entry["id"].casefold()
or any(alias.casefold() == key for alias in entry.get("aliases", []))
]

exact_matches = matches_for(normalized)
if exact_matches:
return exact_matches[0]

basename_matches = matches_for(basename)
if len(basename_matches) == 1:
return basename_matches[0]

stem_matches = matches_for(stem)
if len(stem_matches) == 1:
return stem_matches[0]

return None


def _linux_catalog_matched_entry(catalog_entry: dict, observed_path: str):
normalized, basename, stem = _linux_catalog_path_tokens(observed_path)
aliases = list(catalog_entry.get("aliases", []))
aliases.extend([normalized, basename, stem])
return _make_entry(
catalog_entry["id"],
catalog_entry.get("label", catalog_entry["id"]),
path=catalog_entry.get("path") or observed_path,
aliases=aliases,
legacy_icon=catalog_entry.get("legacy_icon", ""),
)


def _resolve_path_entry(path: str):
if not path:
return None

if sys.platform == "darwin":
normalized = posixpath.normpath(path)
normalized = posixpath.normpath(path) if os.path.isabs(path) else os.path.abspath(path)
elif sys.platform == "linux":
normalized = os.path.realpath(path)
else:
normalized = os.path.abspath(path)
path_exists = os.path.exists(normalized)

if sys.platform == "linux" and path_exists:
catalog_entry = _linux_catalog_entry_for_path(normalized)
if catalog_entry:
return _linux_catalog_matched_entry(catalog_entry, normalized)

if sys.platform == "darwin" and normalized.endswith(".app"):
if not path_exists:
return None
Expand Down
52 changes: 52 additions & 0 deletions core/app_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,58 @@ def get_foreground_exe() -> str | None:
except Exception:
return None

elif sys.platform == "linux":
import subprocess as _subprocess

_WAYLAND = os.environ.get("XDG_SESSION_TYPE", "").lower() == "wayland"
_KDE = "KDE" in os.environ.get("XDG_CURRENT_DESKTOP", "").upper()

def _pid_to_exe(pid: int) -> str | None:
try:
return os.readlink(f"/proc/{pid}/exe")
except OSError:
return None

def _get_foreground_xdotool() -> str | None:
"""X11: use xdotool."""
try:
result = _subprocess.run(
["xdotool", "getactivewindow", "getwindowpid"],
capture_output=True, text=True, timeout=1,
)
if result.returncode == 0 and result.stdout.strip():
return _pid_to_exe(int(result.stdout.strip()))
except (FileNotFoundError, ValueError, OSError, _subprocess.TimeoutExpired):
pass
return None

def _get_foreground_kdotool() -> str | None:
"""KDE Wayland: use kdotool."""
try:
result = _subprocess.run(
["kdotool", "getactivewindow", "getwindowpid"],
capture_output=True, text=True, timeout=1,
)
if result.returncode == 0 and result.stdout.strip():
return _pid_to_exe(int(result.stdout.strip()))
except (FileNotFoundError, ValueError, OSError, _subprocess.TimeoutExpired):
pass
return None

def get_foreground_exe() -> str | None:
"""Return the foreground app executable path on Linux."""
if _WAYLAND:
if _KDE:
exe = _get_foreground_kdotool()
if exe:
return exe
# Fall back to xdotool so XWayland apps still work when
# kdotool is unavailable or cannot resolve the active window.
return _get_foreground_xdotool()
# GNOME / other Wayland compositors: not yet supported
return None
return _get_foreground_xdotool()

else:
def get_foreground_exe() -> str | None:
return None
Expand Down
Loading
Loading