55macOS: NSWorkspace.sharedWorkspace().frontmostApplication().
66"""
77
8+ import functools
89import os
10+ import plistlib
911import sys
1012import threading
1113import 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
18123if 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
166271elif 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
202299elif 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
254353else :
255- def get_foreground_exe () -> str | None :
256- return None
354+ def get_foreground_app_identity () -> tuple [ str , ...] :
355+ return ()
257356
258357
259358class 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 )
0 commit comments