1515import shutil
1616import subprocess
1717import time
18+ from dataclasses import dataclass
1819
1920logger = logging .getLogger (__name__ )
2021
@@ -52,6 +53,17 @@ def _parse_env_float(env_var: str, default: float) -> float:
5253_last_progress_time : float = 0.0
5354
5455
56+ @dataclass
57+ class _FrontmostAppInfo :
58+ """Info about the frontmost application for focus restore."""
59+
60+ name : str
61+ bundle_id : str | None = None # macOS only: bundle identifier for precise activation
62+
63+ def __str__ (self ) -> str :
64+ return self .name
65+
66+
5567def _is_available () -> bool :
5668 """Check if focus nudging is available on this platform."""
5769 system = platform .system ()
@@ -120,20 +132,43 @@ def reset_nudge_backoff() -> None:
120132 _last_progress_time = time .monotonic ()
121133
122134
123- def _get_frontmost_app_macos () -> str | None :
124- """Get the name of the frontmost application on macOS."""
135+ def _get_frontmost_app_macos () -> _FrontmostAppInfo | None :
136+ """Get the name and bundle identifier of the frontmost application on macOS.
137+
138+ Returns both process name and bundle ID so we can restore focus precisely.
139+ Using bundle ID avoids the Electron bug where `tell application "Electron"`
140+ launches a standalone Electron instance instead of returning to VS Code.
141+ """
125142 try :
126143 result = subprocess .run (
127144 [
128145 "osascript" , "-e" ,
129- 'tell application "System Events" to get name of first process whose frontmost is true'
146+ 'tell application "System Events"\n '
147+ ' set frontProc to first process whose frontmost is true\n '
148+ ' set procName to name of frontProc\n '
149+ ' set bundleID to ""\n '
150+ ' try\n '
151+ ' set bID to bundle identifier of frontProc\n '
152+ ' if bID is not missing value then set bundleID to bID\n '
153+ ' end try\n '
154+ ' return procName & "|" & bundleID\n '
155+ 'end tell' ,
130156 ],
131157 capture_output = True ,
132158 text = True ,
133159 timeout = 5 ,
134160 )
135161 if result .returncode == 0 :
136- return result .stdout .strip ()
162+ output = result .stdout .strip ()
163+ parts = output .split ("|" , 1 )
164+ name = parts [0 ]
165+ bundle_id : str | None = None
166+ if len (parts ) > 1 :
167+ raw_bundle_id = parts [1 ].strip ()
168+ # Some processes report "missing value" as bundle ID; treat as absent
169+ if raw_bundle_id and raw_bundle_id .lower () != "missing value" :
170+ bundle_id = raw_bundle_id
171+ return _FrontmostAppInfo (name = name , bundle_id = bundle_id )
137172 except Exception as e :
138173 logger .debug (f"Failed to get frontmost app: { e } " )
139174 return None
@@ -205,15 +240,23 @@ def _find_unity_pid_by_project_path(project_path: str) -> int | None:
205240 return None
206241
207242
208- def _focus_app_macos (app_name : str , unity_project_path : str | None = None ) -> bool :
209- """Focus an application by name on macOS.
243+ def _focus_app_macos (
244+ app_name : str ,
245+ unity_project_path : str | None = None ,
246+ bundle_id : str | None = None ,
247+ ) -> bool :
248+ """Focus an application on macOS.
210249
211250 For Unity, can target a specific instance by project path (multi-instance support).
251+ For other apps, prefers bundle_id activation to avoid the Electron bug where
252+ generic process names like "Electron" cause macOS to launch the wrong app.
212253
213254 Args:
214255 app_name: Application name to focus ("Unity" or specific app name)
215256 unity_project_path: For Unity apps, the full project root path to match against
216257 -projectpath command line arg (e.g., "/path/to/project" NOT "/path/to/project/Assets")
258+ bundle_id: Bundle identifier for precise activation (e.g. "com.microsoft.VSCode").
259+ Preferred over app_name for non-Unity apps.
217260 """
218261 try :
219262 # For Unity, use PID-based activation for precise targeting
@@ -255,9 +298,27 @@ def _focus_app_macos(app_name: str, unity_project_path: str | None = None) -> bo
255298 # No project path provided - activate any Unity process
256299 return _focus_any_unity_macos ()
257300 else :
258- # For other apps, use direct activation
259- # Escape double quotes in app_name to prevent AppleScript injection
260- escaped_app_name = app_name .replace ('"' , '\\ "' )
301+ # For non-Unity apps, prefer bundle_id to avoid the Electron bug:
302+ # VS Code's process name is "Electron", and `tell application "Electron"`
303+ # can launch a standalone Electron instance instead of returning to VS Code.
304+ if bundle_id :
305+ escaped_bundle_id = bundle_id .replace ('"' , '""' )
306+ result = subprocess .run (
307+ ["osascript" , "-e" , f'tell application id "{ escaped_bundle_id } " to activate' ],
308+ capture_output = True ,
309+ text = True ,
310+ timeout = 5 ,
311+ )
312+ if result .returncode == 0 :
313+ return True
314+ logger .debug (
315+ "Bundle ID activation failed for %s, falling back to name: %s" ,
316+ bundle_id ,
317+ result .stderr .strip () if result .stderr else "(no stderr)" ,
318+ )
319+
320+ # Fallback to name-based activation
321+ escaped_app_name = app_name .replace ('"' , '""' )
261322 result = subprocess .run (
262323 ["osascript" , "-e" , f'tell application "{ escaped_app_name } " to activate' ],
263324 capture_output = True ,
@@ -294,7 +355,7 @@ def _focus_any_unity_macos() -> bool:
294355 return False
295356
296357
297- def _get_frontmost_app_windows () -> str | None :
358+ def _get_frontmost_app_windows () -> _FrontmostAppInfo | None :
298359 """Get the title of the frontmost window on Windows."""
299360 try :
300361 # PowerShell command to get active window title
@@ -321,7 +382,7 @@ def _get_frontmost_app_windows() -> str | None:
321382 timeout = 5 ,
322383 )
323384 if result .returncode == 0 :
324- return result .stdout .strip ()
385+ return _FrontmostAppInfo ( name = result .stdout .strip () )
325386 except Exception as e :
326387 logger .debug (f"Failed to get frontmost window: { e } " )
327388 return None
@@ -381,7 +442,7 @@ def _focus_app_windows(window_title: str) -> bool:
381442 return False
382443
383444
384- def _get_frontmost_app_linux () -> str | None :
445+ def _get_frontmost_app_linux () -> _FrontmostAppInfo | None :
385446 """Get the window ID of the frontmost window on Linux."""
386447 try :
387448 result = subprocess .run (
@@ -391,7 +452,7 @@ def _get_frontmost_app_linux() -> str | None:
391452 timeout = 5 ,
392453 )
393454 if result .returncode == 0 :
394- return result .stdout .strip ()
455+ return _FrontmostAppInfo ( name = result .stdout .strip () )
395456 except Exception as e :
396457 logger .debug (f"Failed to get active window: { e } " )
397458 return None
@@ -425,7 +486,7 @@ def _focus_app_linux(window_id: str) -> bool:
425486 return False
426487
427488
428- def _get_frontmost_app () -> str | None :
489+ def _get_frontmost_app () -> _FrontmostAppInfo | None :
429490 """Get the frontmost application/window (platform-specific)."""
430491 system = platform .system ()
431492 if system == "Darwin" :
@@ -437,21 +498,27 @@ def _get_frontmost_app() -> str | None:
437498 return None
438499
439500
440- def _focus_app (app_or_window : str , unity_project_path : str | None = None ) -> bool :
501+ def _focus_app (
502+ app_info : _FrontmostAppInfo | str ,
503+ unity_project_path : str | None = None ,
504+ ) -> bool :
441505 """Focus an application/window (platform-specific).
442506
443507 Args:
444- app_or_window : Application name to focus
508+ app_info : Application info ( name + optional bundle_id) or plain name string
445509 unity_project_path: For Unity apps on macOS, the full project root path for
446510 multi-instance support
447511 """
512+ if isinstance (app_info , str ):
513+ app_info = _FrontmostAppInfo (name = app_info )
514+
448515 system = platform .system ()
449516 if system == "Darwin" :
450- return _focus_app_macos (app_or_window , unity_project_path )
517+ return _focus_app_macos (app_info . name , unity_project_path , app_info . bundle_id )
451518 elif system == "Windows" :
452- return _focus_app_windows (app_or_window )
519+ return _focus_app_windows (app_info . name )
453520 elif system == "Linux" :
454- return _focus_app_linux (app_or_window )
521+ return _focus_app_linux (app_info . name )
455522 return False
456523
457524
@@ -505,7 +572,7 @@ async def nudge_unity_focus(
505572 return False
506573
507574 # Check if Unity is already focused (no nudge needed)
508- if "Unity" in original_app :
575+ if "Unity" in original_app . name :
509576 logger .debug ("Unity already focused, no nudge needed" )
510577 return False
511578
@@ -523,7 +590,7 @@ async def nudge_unity_focus(
523590
524591 # Verify Unity is actually focused now
525592 current_app = _get_frontmost_app ()
526- if current_app and "Unity" not in current_app :
593+ if current_app and "Unity" not in current_app . name :
527594 logger .warning (f"Unity activation didn't complete - current app is { current_app } " )
528595 # Continue anyway in case Unity is processing in background
529596
@@ -535,7 +602,7 @@ async def nudge_unity_focus(
535602 await asyncio .sleep (focus_duration_s )
536603
537604 # Return focus to original app
538- if original_app and original_app != "Unity" :
605+ if original_app and original_app . name != "Unity" :
539606 if _focus_app (original_app ):
540607 logger .info (f"Returned focus to { original_app } after { focus_duration_s :.1f} s Unity focus" )
541608 else :
0 commit comments