Skip to content
Open
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
175 changes: 137 additions & 38 deletions core/app_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,119 @@
macOS: NSWorkspace.sharedWorkspace().frontmostApplication().
"""

import functools
import os
import plistlib
import sys
import threading
import time


def _path_from_nsurl(url) -> str | None:
if url is None:
return None
try:
path_attr = getattr(url, "path", None)
path = path_attr() if callable(path_attr) else path_attr
return str(path) if path else None
except Exception:
return None


def _call_ns_method(obj, name: str):
try:
attr = getattr(obj, name, None)
return attr() if callable(attr) else attr
except Exception:
return None


def _dedupe_keep_order(values) -> tuple[str, ...]:
result = []
seen = set()
for value in values:
if value is None:
continue
text = str(value)
if not text:
continue
key = text.casefold()
if key in seen:
continue
seen.add(key)
result.append(text)
return tuple(result)


def _single_identity(value: str | None) -> tuple[str, ...]:
return (value,) if value else ()


def _macos_app_bundles_in_path(path: str | None) -> tuple[str, ...]:
"""Return containing .app bundles ordered inner-most to outer-most."""
if not path:
return ()

normalized = os.path.abspath(path)
parts = normalized.split(os.sep)
bundles = []
for idx, part in enumerate(parts):
if part.endswith(".app"):
if normalized.startswith(os.sep):
bundles.append(os.path.join(os.sep, *parts[1:idx + 1]))
else:
bundles.append(os.path.join(*parts[:idx + 1]))
return tuple(reversed(bundles))


@functools.lru_cache(maxsize=256)
def _read_macos_bundle_identifier(app_path: str | None) -> str | None:
if not app_path:
return None
info_path = os.path.join(app_path, "Contents", "Info.plist")
try:
with open(info_path, "rb") as f:
info = plistlib.load(f)
ident = info.get("CFBundleIdentifier")
return str(ident) if ident else None
except (OSError, ValueError, TypeError):
return None


def _macos_running_app_identities(app) -> tuple[str, ...]:
"""Return profile-matching identities, ordered most-specific first."""
bundle_path = _path_from_nsurl(_call_ns_method(app, "bundleURL"))
executable_path = _path_from_nsurl(_call_ns_method(app, "executableURL"))
ident = _call_ns_method(app, "bundleIdentifier")
localized_name = _call_ns_method(app, "localizedName")

identities = []
if ident:
identities.append(str(ident))

bundles = _dedupe_keep_order([
*_macos_app_bundles_in_path(bundle_path),
*_macos_app_bundles_in_path(executable_path),
])
for app_path in bundles:
bundle_ident = _read_macos_bundle_identifier(app_path)
if bundle_ident:
identities.append(bundle_ident)
identities.append(app_path)
identities.append(os.path.basename(app_path))
identities.append(os.path.splitext(os.path.basename(app_path))[0])

if executable_path:
identities.append(executable_path)
identities.append(os.path.basename(executable_path))
if localized_name:
identities.append(str(localized_name))

return _dedupe_keep_order(identities)


# ==================================================================
# Platform-specific get_foreground_exe()
# Platform-specific foreground app identity resolution
# ==================================================================

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

def get_foreground_exe() -> str | None:
"""Return the foreground app path on Windows, or None."""
def get_foreground_app_identity() -> tuple[str, ...]:
"""Return the foreground app path on Windows, or an empty tuple."""
hwnd = user32.GetForegroundWindow()
if not hwnd:
return None
return ()
pid = wt.DWORD()
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
if pid.value == 0:
return None
return ()
exe_path = _path_from_pid(pid.value)
if not exe_path:
return None
return ()
exe_lower = os.path.basename(exe_path).lower()
if exe_lower == "applicationframehost.exe":
real = _resolve_uwp_child(hwnd)
# If we can't resolve the real app (e.g. fullscreen UWP),
# return None so the detector keeps the last known profile.
return real
# If we can't resolve the real app (e.g. fullscreen UWP), return
# an empty tuple so the detector keeps the last known profile.
return _single_identity(real)
if exe_lower == "explorer.exe":
wc = _get_window_class(hwnd)
if wc not in _EXPLORER_CLASSES:
title = _get_window_title(hwnd)
print(f"[AppDetect] FG: explorer.exe class={wc} title='{title}'")
real = _resolve_uwp_child(hwnd)
if real:
return real
return _single_identity(real)
real = _find_uwp_app_global()
return real # None keeps last profile
return exe_path
return _single_identity(real)
return _single_identity(exe_path)

elif sys.platform == "darwin":
import functools

try:
import objc as _objc
except ImportError as exc:
Expand All @@ -182,22 +285,16 @@ def wrapper(*args, **kwargs):
return wrapper

@_autoreleased
def get_foreground_exe() -> str | None:
"""Return a stable app identifier for the frontmost app on macOS."""
def get_foreground_app_identity() -> tuple[str, ...]:
"""Return stable frontmost app identities on macOS."""
try:
from AppKit import NSWorkspace
app = NSWorkspace.sharedWorkspace().frontmostApplication()
if app is None:
return None
ident = app.bundleIdentifier()
if ident:
return ident
url = app.executableURL()
if url:
return os.path.basename(url.path())
return app.localizedName()
return ()
return _macos_running_app_identities(app)
except Exception:
return None
return ()

elif sys.platform == "linux":
import subprocess as _subprocess
Expand Down Expand Up @@ -237,35 +334,37 @@ def _get_foreground_kdotool() -> str | None:
pass
return None

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

else:
def get_foreground_exe() -> str | None:
return None
def get_foreground_app_identity() -> tuple[str, ...]:
return ()


class AppDetector:
"""
Polls the foreground window every *interval* seconds.
Calls ``on_change(exe_name: str)`` when the foreground app changes.
Calls ``on_change(app_identity)`` when the foreground app changes.
"""

def __init__(self, on_change, interval: float = 0.3):
self._on_change = on_change
self._interval = interval
self._last_exe: str | None = None
self._last_app_identity: tuple[str, ...] | None = None
self._stop = threading.Event()
self._thread: threading.Thread | None = None

Expand All @@ -285,10 +384,10 @@ def stop(self):
def _poll(self):
while not self._stop.is_set():
try:
exe = get_foreground_exe()
if exe and exe != self._last_exe:
self._last_exe = exe
self._on_change(exe)
app_identity = get_foreground_app_identity()
if app_identity and app_identity != self._last_app_identity:
self._last_app_identity = app_identity
self._on_change(app_identity)
except Exception:
pass
self._stop.wait(self._interval)
69 changes: 60 additions & 9 deletions core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,16 +244,67 @@ def resolve_app_for_config(spec: str):
return app_catalog.resolve_app_spec(spec)


def get_profile_for_app(cfg, exe_name):
"""Return the profile name that matches the given executable, or 'default'."""
if not exe_name:
def _dedupe_specs(candidates) -> list[str]:
result = []
seen = set()
for candidate in candidates:
if not candidate:
continue
candidate = str(candidate)
key = candidate.casefold()
if key in seen:
continue
seen.add(key)
result.append(candidate)
return result


def _identity_specs(app_identity: tuple[str, ...] | None) -> list[str]:
return _dedupe_specs(app_identity or ())


def _configured_app_specs(app_spec: str | None) -> list[str]:
return _dedupe_specs((app_spec,) if app_spec else ())


def _app_identity_aliases(spec: str) -> set[str]:
if not spec:
return set()
entry = resolve_app_for_config(spec)
if not entry:
return {spec.casefold()}
aliases = [entry.get("id", ""), *entry.get("aliases", [])]
return {alias.casefold() for alias in aliases if alias}


def get_profile_for_app_identity(cfg, app_identity: tuple[str, ...] | None) -> str:
"""
Return the profile name that matches an app identity, or 'default'.

``app_identity`` is an ordered tuple of identifiers. Identifiers are matched
most-specific first, allowing a nested app profile to win before falling
back to its host app profile.
"""
identities = _identity_specs(app_identity)
if not identities:
return "default"
entry = resolve_app_for_config(exe_name)
aliases = {a.lower() for a in ([entry["id"]] + entry.get("aliases", []))} if entry else {exe_name.lower()}
for pname, pdata in cfg.get("profiles", {}).items():
for app in pdata.get("apps", []):
if app.lower() in aliases:
return pname

alias_cache = {}

def aliases_for(spec: str) -> set[str]:
key = spec.casefold()
if key not in alias_cache:
alias_cache[key] = _app_identity_aliases(spec)
return alias_cache[key]

profiles = list(cfg.get("profiles", {}).items())
for identity in identities:
aliases = aliases_for(identity)
for pname, pdata in profiles:
for app in pdata.get("apps", []):
for app_spec in _configured_app_specs(app):
if aliases & aliases_for(app_spec):
return pname
return "default"


Expand Down
9 changes: 5 additions & 4 deletions core/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
inject_mouse_down, inject_mouse_up,
)
from core.config import (
load_config, get_active_mappings, get_profile_for_app,
load_config, get_active_mappings, get_profile_for_app_identity,
BUTTON_TO_EVENTS, GESTURE_DIRECTION_BUTTONS, save_config,
)
from core.app_detector import AppDetector
Expand Down Expand Up @@ -380,12 +380,13 @@ def _hscroll_threshold(self):
# ------------------------------------------------------------------
# Per-app auto-switching
# ------------------------------------------------------------------
def _on_app_change(self, exe_name: str):
def _on_app_change(self, app_identity: tuple[str, ...]):
"""Called by AppDetector when foreground window changes."""
target = get_profile_for_app(self.cfg, exe_name)
target = get_profile_for_app_identity(self.cfg, app_identity)
if target == self._current_profile:
return
print(f"[Engine] App changed to {exe_name} -> profile '{target}'")
app_label = app_identity[0] if app_identity else ""
print(f"[Engine] App changed to {app_label} -> profile '{target}'")
self._switch_profile(target)

def _switch_profile(self, profile_name: str):
Expand Down
Loading