Skip to content

Commit 9493c5f

Browse files
dsarnoclaude
andauthored
Fix focus nudge launching Electron instead of restoring VS Code on macOS (#783)
* Fix focus nudge launching Electron instead of restoring VS Code on macOS The focus restore used process name (e.g. "Electron") which caused macOS to launch a standalone Electron instance instead of returning to VS Code. Now captures bundle identifier and uses `tell application id` for precise activation, falling back to name-based activation if bundle ID fails. Fixes #699 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Address review feedback: guard AppleScript missing value, fix escaping, improve logging - Guard AppleScript against `missing value` bundle identifier that would crash osascript and silently disable the entire nudge cycle (CodeRabbit) - Also filter "missing value" string on the Python side as belt-and-suspenders (Sourcery) - Fix AppleScript string escaping: use "" (AppleScript convention) not \" (Python convention) for both bundle_id and app_name (CodeRabbit) - Log AppleScript stderr when bundle_id activation fails (Sourcery) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3d846fa commit 9493c5f

1 file changed

Lines changed: 89 additions & 22 deletions

File tree

Server/src/utils/focus_nudge.py

Lines changed: 89 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import shutil
1616
import subprocess
1717
import time
18+
from dataclasses import dataclass
1819

1920
logger = 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+
5567
def _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

Comments
 (0)