Merge pull request 'fix: harden restart_claude_desktop — Linux refusal, macOS quit-wait, Windows MSIX guard (#33)' (#43) from fix/33-restart-hardening into main
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10) (push) Successful in 9s
CI / Tests (py3.12) (push) Successful in 9s

This commit was merged in pull request #43.
This commit is contained in:
2026-07-12 13:00:30 -04:00
3 changed files with 152 additions and 65 deletions
+49 -19
View File
@@ -1857,8 +1857,41 @@ def _run_quiet(cmd: list[str]) -> None:
subprocess.run(cmd, capture_output=True)
def restart_supported() -> bool:
"""
True only where restarting Claude Desktop makes sense (macOS, Windows).
There is no official Claude Desktop for Linux, and the obvious binary
name there ("claude") is the Claude Code CLI — killing or spawning it
would be actively harmful. GUI callers gate the Restart button on this.
"""
return sys.platform == "darwin" or sys.platform.startswith("win")
_MACOS_QUIT_WAIT_S = 5.0
def _macos_claude_running() -> bool:
try:
return subprocess.run(["pgrep", "-x", "Claude"], capture_output=True).returncode == 0
except OSError:
return False
def _restart_claude_desktop_macos() -> RestartResult:
_run_quiet(["pkill", "-x", "Claude"])
# Wait for the old instance to actually exit: `open -a` against a dying
# process can merely re-activate it, and the config is only re-read on a
# true relaunch. Blocks up to _MACOS_QUIT_WAIT_S — callers run this off
# the UI thread (see RestartWorker in bcc.py).
deadline = time.monotonic() + _MACOS_QUIT_WAIT_S
while _macos_claude_running():
if time.monotonic() > deadline:
return RestartResult(
False,
f"Claude Desktop didn't quit within {_MACOS_QUIT_WAIT_S:.0f}s — "
"quit it manually, then reopen it.",
)
time.sleep(0.15)
try:
result = subprocess.run(["open", "-a", "Claude"], capture_output=True, text=True)
except OSError as e:
@@ -1875,8 +1908,18 @@ def _claude_windows_start_menu_shortcut() -> Path:
def _restart_claude_desktop_windows() -> RestartResult:
_run_quiet(["taskkill", "/IM", "Claude.exe", "/F"])
shortcut = _claude_windows_start_menu_shortcut()
if not shortcut.is_file():
# Checked BEFORE killing: an MSIX/Store install has no Start-menu .lnk
# at this path, and killing without a relaunch path would leave the
# user with no running Claude at all.
return RestartResult(
False,
f"Claude's Start-menu shortcut wasn't found ({shortcut}). "
"If Claude Desktop is installed from the Microsoft Store, "
"quit and reopen it manually.",
)
_run_quiet(["taskkill", "/IM", "Claude.exe", "/F"])
try:
# `cmd /c start "" <target>` launches detached, the same as double-clicking
# the Start-menu shortcut, and returns immediately.
@@ -1893,32 +1936,19 @@ def _restart_claude_desktop_windows() -> RestartResult:
return RestartResult(True, "Claude Desktop restarted.")
def _restart_claude_desktop_linux() -> RestartResult:
_run_quiet(["pkill", "claude"])
try:
# The Linux launcher is the app itself (no "open"-style helper), so it
# has to be started detached rather than waited on.
subprocess.Popen(
["claude"],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
except OSError as e:
return RestartResult(False, f"Couldn't launch Claude Desktop: {e}")
return RestartResult(True, "Claude Desktop restarted.")
def restart_claude_desktop() -> RestartResult:
"""
Kill and relaunch the Claude Desktop app so a freshly saved config takes
effect. The app not currently running is NOT a failure -- pkill/taskkill
exiting non-zero just means "nothing to kill", and we go straight to
relaunching. Only a failed relaunch is reported as success=False.
macOS blocks for up to _MACOS_QUIT_WAIT_S while the old instance exits —
run off the UI thread. Unsupported platforms (see restart_supported())
refuse without touching any process.
"""
if sys.platform == "darwin":
return _restart_claude_desktop_macos()
if sys.platform.startswith("win"):
return _restart_claude_desktop_windows()
return _restart_claude_desktop_linux()
return RestartResult(False, "Restarting Claude Desktop isn't supported on this platform.")