Skip to content

Commit a9c9124

Browse files
David Noyesclaude
andcommitted
Merge and adapt the following upstream pull requests, resolving conflicts with MX Master 4 haptic and Actions Ring features: Clean merges: - PR TomBadash#193: symlink-safe config save via os.path.realpath() - PR TomBadash#198: fractional hscroll threshold (0.1 default, int/float compat) - PR TomBadash#185: pass through OS mouse events when no Logitech device connected - PR TomBadash#180: persist hardware-reported DPI across restarts - PR TomBadash#181: memoize Backend list properties for QML UI performance - PR TomBadash#195: ping-pong desktop cycling action (macOS Spaces) Conflict-resolution merges: - PR TomBadash#196: nested macOS app identity matching with tuple identities - PR TomBadash#191: Shift+wheel horizontal scroll translation (cross-platform) - PR TomBadash#216 (partial): phase-aware trackpad filter + 0.01 hscroll floor Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b6197ba commit a9c9124

15 files changed

Lines changed: 1768 additions & 126 deletions

core/app_detector.py

Lines changed: 137 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,119 @@
55
macOS: NSWorkspace.sharedWorkspace().frontmostApplication().
66
"""
77

8+
import functools
89
import os
10+
import plistlib
911
import sys
1012
import threading
1113
import time
1214

1315

16+
def _path_from_nsurl(url) -> str | None:
17+
if url is None:
18+
return None
19+
try:
20+
path_attr = getattr(url, "path", None)
21+
path = path_attr() if callable(path_attr) else path_attr
22+
return str(path) if path else None
23+
except Exception:
24+
return None
25+
26+
27+
def _call_ns_method(obj, name: str):
28+
try:
29+
attr = getattr(obj, name, None)
30+
return attr() if callable(attr) else attr
31+
except Exception:
32+
return None
33+
34+
35+
def _dedupe_keep_order(values) -> tuple[str, ...]:
36+
result = []
37+
seen = set()
38+
for value in values:
39+
if value is None:
40+
continue
41+
text = str(value)
42+
if not text:
43+
continue
44+
key = text.casefold()
45+
if key in seen:
46+
continue
47+
seen.add(key)
48+
result.append(text)
49+
return tuple(result)
50+
51+
52+
def _single_identity(value: str | None) -> tuple[str, ...]:
53+
return (value,) if value else ()
54+
55+
56+
def _macos_app_bundles_in_path(path: str | None) -> tuple[str, ...]:
57+
"""Return containing .app bundles ordered inner-most to outer-most."""
58+
if not path:
59+
return ()
60+
61+
normalized = os.path.abspath(path)
62+
parts = normalized.split(os.sep)
63+
bundles = []
64+
for idx, part in enumerate(parts):
65+
if part.endswith(".app"):
66+
if normalized.startswith(os.sep):
67+
bundles.append(os.path.join(os.sep, *parts[1:idx + 1]))
68+
else:
69+
bundles.append(os.path.join(*parts[:idx + 1]))
70+
return tuple(reversed(bundles))
71+
72+
73+
@functools.lru_cache(maxsize=256)
74+
def _read_macos_bundle_identifier(app_path: str | None) -> str | None:
75+
if not app_path:
76+
return None
77+
info_path = os.path.join(app_path, "Contents", "Info.plist")
78+
try:
79+
with open(info_path, "rb") as f:
80+
info = plistlib.load(f)
81+
ident = info.get("CFBundleIdentifier")
82+
return str(ident) if ident else None
83+
except (OSError, ValueError, TypeError):
84+
return None
85+
86+
87+
def _macos_running_app_identities(app) -> tuple[str, ...]:
88+
"""Return profile-matching identities, ordered most-specific first."""
89+
bundle_path = _path_from_nsurl(_call_ns_method(app, "bundleURL"))
90+
executable_path = _path_from_nsurl(_call_ns_method(app, "executableURL"))
91+
ident = _call_ns_method(app, "bundleIdentifier")
92+
localized_name = _call_ns_method(app, "localizedName")
93+
94+
identities = []
95+
if ident:
96+
identities.append(str(ident))
97+
98+
bundles = _dedupe_keep_order([
99+
*_macos_app_bundles_in_path(bundle_path),
100+
*_macos_app_bundles_in_path(executable_path),
101+
])
102+
for app_path in bundles:
103+
bundle_ident = _read_macos_bundle_identifier(app_path)
104+
if bundle_ident:
105+
identities.append(bundle_ident)
106+
identities.append(app_path)
107+
identities.append(os.path.basename(app_path))
108+
identities.append(os.path.splitext(os.path.basename(app_path))[0])
109+
110+
if executable_path:
111+
identities.append(executable_path)
112+
identities.append(os.path.basename(executable_path))
113+
if localized_name:
114+
identities.append(str(localized_name))
115+
116+
return _dedupe_keep_order(identities)
117+
118+
14119
# ==================================================================
15-
# Platform-specific get_foreground_exe()
120+
# Platform-specific foreground app identity resolution
16121
# ==================================================================
17122

18123
if sys.platform == "win32":
@@ -133,39 +238,37 @@ def _enum_cb(hwnd, _lparam):
133238
user32.EnumWindows(WNDENUMPROC(_enum_cb), 0)
134239
return result[0]
135240

136-
def get_foreground_exe() -> str | None:
137-
"""Return the foreground app path on Windows, or None."""
241+
def get_foreground_app_identity() -> tuple[str, ...]:
242+
"""Return the foreground app path on Windows, or an empty tuple."""
138243
hwnd = user32.GetForegroundWindow()
139244
if not hwnd:
140-
return None
245+
return ()
141246
pid = wt.DWORD()
142247
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
143248
if pid.value == 0:
144-
return None
249+
return ()
145250
exe_path = _path_from_pid(pid.value)
146251
if not exe_path:
147-
return None
252+
return ()
148253
exe_lower = os.path.basename(exe_path).lower()
149254
if exe_lower == "applicationframehost.exe":
150255
real = _resolve_uwp_child(hwnd)
151-
# If we can't resolve the real app (e.g. fullscreen UWP),
152-
# return None so the detector keeps the last known profile.
153-
return real
256+
# If we can't resolve the real app (e.g. fullscreen UWP), return
257+
# an empty tuple so the detector keeps the last known profile.
258+
return _single_identity(real)
154259
if exe_lower == "explorer.exe":
155260
wc = _get_window_class(hwnd)
156261
if wc not in _EXPLORER_CLASSES:
157262
title = _get_window_title(hwnd)
158263
print(f"[AppDetect] FG: explorer.exe class={wc} title='{title}'")
159264
real = _resolve_uwp_child(hwnd)
160265
if real:
161-
return real
266+
return _single_identity(real)
162267
real = _find_uwp_app_global()
163-
return real # None keeps last profile
164-
return exe_path
268+
return _single_identity(real)
269+
return _single_identity(exe_path)
165270

166271
elif sys.platform == "darwin":
167-
import functools
168-
169272
try:
170273
import objc as _objc
171274
except ImportError as exc:
@@ -182,22 +285,16 @@ def wrapper(*args, **kwargs):
182285
return wrapper
183286

184287
@_autoreleased
185-
def get_foreground_exe() -> str | None:
186-
"""Return a stable app identifier for the frontmost app on macOS."""
288+
def get_foreground_app_identity() -> tuple[str, ...]:
289+
"""Return stable frontmost app identities on macOS."""
187290
try:
188291
from AppKit import NSWorkspace
189292
app = NSWorkspace.sharedWorkspace().frontmostApplication()
190293
if app is None:
191-
return None
192-
ident = app.bundleIdentifier()
193-
if ident:
194-
return ident
195-
url = app.executableURL()
196-
if url:
197-
return os.path.basename(url.path())
198-
return app.localizedName()
294+
return ()
295+
return _macos_running_app_identities(app)
199296
except Exception:
200-
return None
297+
return ()
201298

202299
elif sys.platform == "linux":
203300
import subprocess as _subprocess
@@ -237,35 +334,37 @@ def _get_foreground_kdotool() -> str | None:
237334
pass
238335
return None
239336

240-
def get_foreground_exe() -> str | None:
337+
def get_foreground_app_identity() -> tuple[str, ...]:
241338
"""Return the foreground app executable path on Linux."""
242339
if _WAYLAND:
243340
if _KDE:
244341
exe = _get_foreground_kdotool()
245342
if exe:
246-
return exe
343+
return _single_identity(exe)
247344
# Fall back to xdotool so XWayland apps still work when
248345
# kdotool is unavailable or cannot resolve the active window.
249-
return _get_foreground_xdotool()
346+
exe = _get_foreground_xdotool()
347+
return _single_identity(exe)
250348
# GNOME / other Wayland compositors: not yet supported
251-
return None
252-
return _get_foreground_xdotool()
349+
return ()
350+
exe = _get_foreground_xdotool()
351+
return _single_identity(exe)
253352

254353
else:
255-
def get_foreground_exe() -> str | None:
256-
return None
354+
def get_foreground_app_identity() -> tuple[str, ...]:
355+
return ()
257356

258357

259358
class AppDetector:
260359
"""
261360
Polls the foreground window every *interval* seconds.
262-
Calls ``on_change(exe_name: str)`` when the foreground app changes.
361+
Calls ``on_change(app_identity)`` when the foreground app changes.
263362
"""
264363

265364
def __init__(self, on_change, interval: float = 0.3):
266365
self._on_change = on_change
267366
self._interval = interval
268-
self._last_exe: str | None = None
367+
self._last_app_identity: tuple[str, ...] | None = None
269368
self._stop = threading.Event()
270369
self._thread: threading.Thread | None = None
271370

@@ -285,10 +384,10 @@ def stop(self):
285384
def _poll(self):
286385
while not self._stop.is_set():
287386
try:
288-
exe = get_foreground_exe()
289-
if exe and exe != self._last_exe:
290-
self._last_exe = exe
291-
self._on_change(exe)
387+
app_identity = get_foreground_app_identity()
388+
if app_identity and app_identity != self._last_app_identity:
389+
self._last_app_identity = app_identity
390+
self._on_change(app_identity)
292391
except Exception:
293392
pass
294393
self._stop.wait(self._interval)

core/config.py

Lines changed: 80 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@
9595
"settings": {
9696
"start_minimized": True,
9797
"start_at_login": False,
98-
"hscroll_threshold": 1,
98+
"hscroll_threshold": 0.1,
9999
"invert_hscroll": False, # swap horizontal scroll directions
100100
"invert_vscroll": False, # swap vertical scroll directions
101101
"dpi": 1000, # pointer speed / DPI setting
@@ -186,15 +186,20 @@ def load_config():
186186
def save_config(cfg):
187187
"""Persist config to disk via atomic write with restrictive permissions."""
188188
ensure_config_dir()
189-
fd, tmp_path = tempfile.mkstemp(suffix=".tmp", dir=CONFIG_DIR)
189+
# Resolve symlinks so an atomic rename updates the link's target rather than
190+
# clobbering the link itself — preserves setups like GNU stow that symlink
191+
# config.json into a dotfiles repo.
192+
target_path = os.path.realpath(CONFIG_FILE)
193+
target_dir = os.path.dirname(target_path) or CONFIG_DIR
194+
fd, tmp_path = tempfile.mkstemp(suffix=".tmp", dir=target_dir)
190195
try:
191196
with os.fdopen(fd, "w", encoding="utf-8") as f:
192197
json.dump(cfg, f, indent=2)
193198
f.flush()
194199
os.fsync(f.fileno())
195200
if sys.platform != "win32":
196201
os.chmod(tmp_path, stat.S_IRUSR | stat.S_IWUSR)
197-
os.replace(tmp_path, CONFIG_FILE)
202+
os.replace(tmp_path, target_path)
198203
except BaseException:
199204
try:
200205
os.unlink(tmp_path)
@@ -309,16 +314,67 @@ def resolve_app_for_config(spec: str):
309314
return app_catalog.resolve_app_spec(spec)
310315

311316

312-
def get_profile_for_app(cfg, exe_name):
313-
"""Return the profile name that matches the given executable, or 'default'."""
314-
if not exe_name:
317+
def _dedupe_specs(candidates) -> list[str]:
318+
result = []
319+
seen = set()
320+
for candidate in candidates:
321+
if not candidate:
322+
continue
323+
candidate = str(candidate)
324+
key = candidate.casefold()
325+
if key in seen:
326+
continue
327+
seen.add(key)
328+
result.append(candidate)
329+
return result
330+
331+
332+
def _identity_specs(app_identity: tuple[str, ...] | None) -> list[str]:
333+
return _dedupe_specs(app_identity or ())
334+
335+
336+
def _configured_app_specs(app_spec: str | None) -> list[str]:
337+
return _dedupe_specs((app_spec,) if app_spec else ())
338+
339+
340+
def _app_identity_aliases(spec: str) -> set[str]:
341+
if not spec:
342+
return set()
343+
entry = resolve_app_for_config(spec)
344+
if not entry:
345+
return {spec.casefold()}
346+
aliases = [entry.get("id", ""), *entry.get("aliases", [])]
347+
return {alias.casefold() for alias in aliases if alias}
348+
349+
350+
def get_profile_for_app_identity(cfg, app_identity: tuple[str, ...] | None) -> str:
351+
"""
352+
Return the profile name that matches an app identity, or 'default'.
353+
354+
``app_identity`` is an ordered tuple of identifiers. Identifiers are matched
355+
most-specific first, allowing a nested app profile to win before falling
356+
back to its host app profile.
357+
"""
358+
identities = _identity_specs(app_identity)
359+
if not identities:
315360
return "default"
316-
entry = resolve_app_for_config(exe_name)
317-
aliases = {a.lower() for a in ([entry["id"]] + entry.get("aliases", []))} if entry else {exe_name.lower()}
318-
for pname, pdata in cfg.get("profiles", {}).items():
319-
for app in pdata.get("apps", []):
320-
if app.lower() in aliases:
321-
return pname
361+
362+
alias_cache = {}
363+
364+
def aliases_for(spec: str) -> set[str]:
365+
key = spec.casefold()
366+
if key not in alias_cache:
367+
alias_cache[key] = _app_identity_aliases(spec)
368+
return alias_cache[key]
369+
370+
profiles = list(cfg.get("profiles", {}).items())
371+
for identity in identities:
372+
aliases = aliases_for(identity)
373+
for pname, pdata in profiles:
374+
for app in pdata.get("apps", []):
375+
for app_spec in _configured_app_specs(app):
376+
if aliases & aliases_for(app_spec):
377+
return pname
322378
return "default"
323379

324380

@@ -453,6 +509,16 @@ def _merge_defaults(cfg, defaults):
453509
return cfg
454510

455511

512+
513+
def _is_compatible_type(value, default_val):
514+
"""Return True for values that are safe despite differing exact types."""
515+
return (
516+
isinstance(default_val, float)
517+
and isinstance(value, int)
518+
and not isinstance(value, bool)
519+
)
520+
521+
456522
def _validate_types(cfg, defaults, path=""):
457523
"""Reset values whose type doesn't match the defaults template."""
458524
for key, default_val in defaults.items():
@@ -465,6 +531,8 @@ def _validate_types(cfg, defaults, path=""):
465531
print(f"[Config] Type mismatch at {path}.{key}: "
466532
f"expected dict, got {type(cfg[key]).__name__}")
467533
cfg[key] = json.loads(json.dumps(default_val))
534+
elif _is_compatible_type(cfg[key], default_val):
535+
continue
468536
elif not isinstance(cfg[key], type(default_val)):
469537
print(f"[Config] Type mismatch at {path}.{key}: "
470538
f"expected {type(default_val).__name__}, "

0 commit comments

Comments
 (0)