fix: harden restart_claude_desktop across platforms (#33)
- Linux (and any non-desktop platform): refuse instead of 'pkill claude', which substring-matched running Claude Code CLI sessions and relaunched the CLI, not a desktop app. New restart_supported() gates the button. - macOS: wait (<=5s) for the old instance to exit before 'open -a Claude' so the relaunch can't re-activate the dying process. Runs off the UI thread via a RestartWorker. - Windows: verify the Start-menu shortcut exists BEFORE taskkill, so an MSIX/Store install is never killed without a relaunch path. Closes #33 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1423,6 +1423,17 @@ class AboutDialog(QDialog):
|
|||||||
QDesktopServices.openUrl(QUrl(self._release_url or core.RELEASES_URL))
|
QDesktopServices.openUrl(QUrl(self._release_url or core.RELEASES_URL))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Restart worker: core.restart_claude_desktop() blocks up to ~5 s on macOS
|
||||||
|
# waiting for the old instance to exit, so it must run off the UI thread.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
class RestartWorker(QThread):
|
||||||
|
done = Signal(object) # core.RestartResult
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
self.done.emit(core.restart_claude_desktop())
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Main window
|
# Main window
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -2284,18 +2295,30 @@ class MainWindow(QMainWindow):
|
|||||||
def _offer_restart_button(self):
|
def _offer_restart_button(self):
|
||||||
"""Show the 'Restart Claude Desktop' button after a successful save,
|
"""Show the 'Restart Claude Desktop' button after a successful save,
|
||||||
but only when the just-saved profile is Claude Desktop -- restarting
|
but only when the just-saved profile is Claude Desktop -- restarting
|
||||||
makes no sense for Claude Code, which has no GUI process to bounce."""
|
makes no sense for Claude Code, which has no GUI process to bounce --
|
||||||
if self.current_profile and core.profile_targets_claude_desktop(self.current_profile):
|
and only on platforms where Claude Desktop exists (never Linux, where
|
||||||
|
'claude' is the Claude Code CLI)."""
|
||||||
|
if (
|
||||||
|
self.current_profile
|
||||||
|
and core.profile_targets_claude_desktop(self.current_profile)
|
||||||
|
and core.restart_supported()
|
||||||
|
):
|
||||||
self.restart_btn.show()
|
self.restart_btn.show()
|
||||||
else:
|
else:
|
||||||
self.restart_btn.hide()
|
self.restart_btn.hide()
|
||||||
|
|
||||||
def _restart_claude_desktop(self):
|
def _restart_claude_desktop(self):
|
||||||
self.restart_btn.setEnabled(False)
|
self.restart_btn.setEnabled(False)
|
||||||
try:
|
self.restart_btn.setText("Restarting…")
|
||||||
result = core.restart_claude_desktop()
|
# Held on self (MainWindow outlives the worker); replaced only after
|
||||||
finally:
|
# done re-enables the button, so a running thread is never dropped.
|
||||||
self.restart_btn.setEnabled(True)
|
self._restart_worker = RestartWorker()
|
||||||
|
self._restart_worker.done.connect(self._on_restart_done)
|
||||||
|
self._restart_worker.start()
|
||||||
|
|
||||||
|
def _on_restart_done(self, result):
|
||||||
|
self.restart_btn.setEnabled(True)
|
||||||
|
self.restart_btn.setText("Restart Claude Desktop")
|
||||||
self.restart_btn.hide()
|
self.restart_btn.hide()
|
||||||
if result.success:
|
if result.success:
|
||||||
self.status.setText(f"{self.status.text()} · {result.detail}")
|
self.status.setText(f"{self.status.text()} · {result.detail}")
|
||||||
|
|||||||
+49
-19
@@ -1837,8 +1837,41 @@ def _run_quiet(cmd: list[str]) -> None:
|
|||||||
subprocess.run(cmd, capture_output=True)
|
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:
|
def _restart_claude_desktop_macos() -> RestartResult:
|
||||||
_run_quiet(["pkill", "-x", "Claude"])
|
_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:
|
try:
|
||||||
result = subprocess.run(["open", "-a", "Claude"], capture_output=True, text=True)
|
result = subprocess.run(["open", "-a", "Claude"], capture_output=True, text=True)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
@@ -1855,8 +1888,18 @@ def _claude_windows_start_menu_shortcut() -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def _restart_claude_desktop_windows() -> RestartResult:
|
def _restart_claude_desktop_windows() -> RestartResult:
|
||||||
_run_quiet(["taskkill", "/IM", "Claude.exe", "/F"])
|
|
||||||
shortcut = _claude_windows_start_menu_shortcut()
|
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:
|
try:
|
||||||
# `cmd /c start "" <target>` launches detached, the same as double-clicking
|
# `cmd /c start "" <target>` launches detached, the same as double-clicking
|
||||||
# the Start-menu shortcut, and returns immediately.
|
# the Start-menu shortcut, and returns immediately.
|
||||||
@@ -1873,32 +1916,19 @@ def _restart_claude_desktop_windows() -> RestartResult:
|
|||||||
return RestartResult(True, "Claude Desktop restarted.")
|
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:
|
def restart_claude_desktop() -> RestartResult:
|
||||||
"""
|
"""
|
||||||
Kill and relaunch the Claude Desktop app so a freshly saved config takes
|
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
|
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
|
exiting non-zero just means "nothing to kill", and we go straight to
|
||||||
relaunching. Only a failed relaunch is reported as success=False.
|
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":
|
if sys.platform == "darwin":
|
||||||
return _restart_claude_desktop_macos()
|
return _restart_claude_desktop_macos()
|
||||||
if sys.platform.startswith("win"):
|
if sys.platform.startswith("win"):
|
||||||
return _restart_claude_desktop_windows()
|
return _restart_claude_desktop_windows()
|
||||||
return _restart_claude_desktop_linux()
|
return RestartResult(False, "Restarting Claude Desktop isn't supported on this platform.")
|
||||||
|
|||||||
+74
-40
@@ -934,11 +934,13 @@ class _FakeCompletedProcess:
|
|||||||
|
|
||||||
|
|
||||||
def test_restart_claude_desktop_macos_commands(monkeypatch):
|
def test_restart_claude_desktop_macos_commands(monkeypatch):
|
||||||
"""macOS: pkill -x "Claude" then open -a Claude, in that order."""
|
"""macOS: pkill -x "Claude", wait for exit (pgrep), then open -a Claude."""
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
def fake_run(cmd, **kwargs):
|
def fake_run(cmd, **kwargs):
|
||||||
calls.append(cmd)
|
calls.append(cmd)
|
||||||
|
if cmd[0] == "pgrep":
|
||||||
|
return _FakeCompletedProcess(returncode=1) # already exited
|
||||||
return _FakeCompletedProcess(returncode=0)
|
return _FakeCompletedProcess(returncode=0)
|
||||||
|
|
||||||
monkeypatch.setattr(c.sys, "platform", "darwin")
|
monkeypatch.setattr(c.sys, "platform", "darwin")
|
||||||
@@ -946,14 +948,14 @@ def test_restart_claude_desktop_macos_commands(monkeypatch):
|
|||||||
result = c.restart_claude_desktop()
|
result = c.restart_claude_desktop()
|
||||||
assert result.success
|
assert result.success
|
||||||
assert calls[0] == ["pkill", "-x", "Claude"]
|
assert calls[0] == ["pkill", "-x", "Claude"]
|
||||||
assert calls[1] == ["open", "-a", "Claude"]
|
assert calls[-1] == ["open", "-a", "Claude"]
|
||||||
|
|
||||||
|
|
||||||
def test_restart_claude_desktop_macos_pkill_not_running_is_fine(monkeypatch):
|
def test_restart_claude_desktop_macos_pkill_not_running_is_fine(monkeypatch):
|
||||||
"""pkill exiting non-zero (nothing to kill) must NOT be treated as failure."""
|
"""pkill exiting non-zero (nothing to kill) must NOT be treated as failure."""
|
||||||
|
|
||||||
def fake_run(cmd, **kwargs):
|
def fake_run(cmd, **kwargs):
|
||||||
if cmd[0] == "pkill":
|
if cmd[0] in ("pkill", "pgrep"):
|
||||||
return _FakeCompletedProcess(returncode=1) # no matching process
|
return _FakeCompletedProcess(returncode=1) # no matching process
|
||||||
return _FakeCompletedProcess(returncode=0)
|
return _FakeCompletedProcess(returncode=0)
|
||||||
|
|
||||||
@@ -977,12 +979,12 @@ def test_restart_claude_desktop_macos_relaunch_failure_reported(monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
def test_restart_claude_desktop_macos_pkill_binary_missing_does_not_raise(monkeypatch):
|
def test_restart_claude_desktop_macos_pkill_binary_missing_does_not_raise(monkeypatch):
|
||||||
"""pkill raising OSError (binary missing) must be swallowed, not propagated --
|
"""pkill/pgrep raising OSError (binary missing) must be swallowed, not
|
||||||
relaunch is still attempted."""
|
propagated -- relaunch is still attempted."""
|
||||||
|
|
||||||
def fake_run(cmd, **kwargs):
|
def fake_run(cmd, **kwargs):
|
||||||
if cmd[0] == "pkill":
|
if cmd[0] in ("pkill", "pgrep"):
|
||||||
raise OSError("pkill not found")
|
raise OSError("binary not found")
|
||||||
return _FakeCompletedProcess(returncode=0)
|
return _FakeCompletedProcess(returncode=0)
|
||||||
|
|
||||||
monkeypatch.setattr(c.sys, "platform", "darwin")
|
monkeypatch.setattr(c.sys, "platform", "darwin")
|
||||||
@@ -991,7 +993,45 @@ def test_restart_claude_desktop_macos_pkill_binary_missing_does_not_raise(monkey
|
|||||||
assert result.success
|
assert result.success
|
||||||
|
|
||||||
|
|
||||||
def test_restart_claude_desktop_windows_commands(monkeypatch):
|
def test_restart_claude_desktop_macos_gives_up_if_app_wont_quit(monkeypatch):
|
||||||
|
monkeypatch.setattr(c.sys, "platform", "darwin")
|
||||||
|
monkeypatch.setattr(c, "_run_quiet", lambda cmd: None)
|
||||||
|
monkeypatch.setattr(c, "_macos_claude_running", lambda: True) # never exits
|
||||||
|
monkeypatch.setattr(c, "_MACOS_QUIT_WAIT_S", 0.2)
|
||||||
|
res = c.restart_claude_desktop()
|
||||||
|
assert res.success is False
|
||||||
|
assert "quit" in res.detail.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_restart_claude_desktop_macos_waits_for_exit_then_relaunches(monkeypatch):
|
||||||
|
monkeypatch.setattr(c.sys, "platform", "darwin")
|
||||||
|
monkeypatch.setattr(c, "_run_quiet", lambda cmd: None)
|
||||||
|
alive = iter([True, True, False]) # exits on the third poll
|
||||||
|
monkeypatch.setattr(c, "_macos_claude_running", lambda: next(alive))
|
||||||
|
|
||||||
|
launched = []
|
||||||
|
|
||||||
|
def fake_run(cmd, **kwargs):
|
||||||
|
launched.append(cmd)
|
||||||
|
return _FakeCompletedProcess(returncode=0)
|
||||||
|
|
||||||
|
monkeypatch.setattr(c.subprocess, "run", fake_run)
|
||||||
|
res = c.restart_claude_desktop()
|
||||||
|
assert res.success is True
|
||||||
|
assert launched == [["open", "-a", "Claude"]]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def windows_shortcut(tmp_path, monkeypatch):
|
||||||
|
"""A fake %APPDATA% containing the Claude Start-menu shortcut."""
|
||||||
|
monkeypatch.setenv("APPDATA", str(tmp_path))
|
||||||
|
lnk = tmp_path / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Claude.lnk"
|
||||||
|
lnk.parent.mkdir(parents=True)
|
||||||
|
lnk.write_bytes(b"")
|
||||||
|
return lnk
|
||||||
|
|
||||||
|
|
||||||
|
def test_restart_claude_desktop_windows_commands(monkeypatch, windows_shortcut):
|
||||||
"""Windows: taskkill the process, then relaunch via the Start-menu shortcut."""
|
"""Windows: taskkill the process, then relaunch via the Start-menu shortcut."""
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
@@ -1008,7 +1048,7 @@ def test_restart_claude_desktop_windows_commands(monkeypatch):
|
|||||||
assert calls[1][-1].endswith("Claude.lnk")
|
assert calls[1][-1].endswith("Claude.lnk")
|
||||||
|
|
||||||
|
|
||||||
def test_restart_claude_desktop_windows_relaunch_failure_reported(monkeypatch):
|
def test_restart_claude_desktop_windows_relaunch_failure_reported(monkeypatch, windows_shortcut):
|
||||||
def fake_run(cmd, **kwargs):
|
def fake_run(cmd, **kwargs):
|
||||||
if cmd[0] == "cmd":
|
if cmd[0] == "cmd":
|
||||||
return _FakeCompletedProcess(returncode=1, stderr="not found")
|
return _FakeCompletedProcess(returncode=1, stderr="not found")
|
||||||
@@ -1021,43 +1061,37 @@ def test_restart_claude_desktop_windows_relaunch_failure_reported(monkeypatch):
|
|||||||
assert "not found" in result.detail
|
assert "not found" in result.detail
|
||||||
|
|
||||||
|
|
||||||
def test_restart_claude_desktop_linux_commands(monkeypatch):
|
def test_restart_claude_desktop_windows_missing_shortcut_refuses_before_killing(
|
||||||
"""Linux: pkill claude, then relaunch via a detached Popen (no waiting)."""
|
monkeypatch, tmp_path
|
||||||
run_calls = []
|
):
|
||||||
popen_calls = []
|
killed = []
|
||||||
|
monkeypatch.setattr(c, "_run_quiet", lambda cmd: killed.append(cmd))
|
||||||
|
monkeypatch.setattr(c.sys, "platform", "win32")
|
||||||
|
monkeypatch.setenv("APPDATA", str(tmp_path)) # no Claude.lnk under here (MSIX case)
|
||||||
|
res = c.restart_claude_desktop()
|
||||||
|
assert res.success is False
|
||||||
|
assert "shortcut" in res.detail.lower()
|
||||||
|
assert killed == [] # Claude must NOT be killed when it can't be relaunched
|
||||||
|
|
||||||
def fake_run(cmd, **kwargs):
|
|
||||||
run_calls.append(cmd)
|
|
||||||
return _FakeCompletedProcess(returncode=0)
|
|
||||||
|
|
||||||
class _FakePopen:
|
|
||||||
def __init__(self, cmd, **kwargs):
|
|
||||||
popen_calls.append((cmd, kwargs))
|
|
||||||
|
|
||||||
|
def test_restart_supported_only_on_desktop_platforms(monkeypatch):
|
||||||
|
monkeypatch.setattr(c.sys, "platform", "darwin")
|
||||||
|
assert c.restart_supported()
|
||||||
|
monkeypatch.setattr(c.sys, "platform", "win32")
|
||||||
|
assert c.restart_supported()
|
||||||
monkeypatch.setattr(c.sys, "platform", "linux")
|
monkeypatch.setattr(c.sys, "platform", "linux")
|
||||||
monkeypatch.setattr(c.subprocess, "run", fake_run)
|
assert not c.restart_supported()
|
||||||
monkeypatch.setattr(c.subprocess, "Popen", _FakePopen)
|
|
||||||
result = c.restart_claude_desktop()
|
|
||||||
assert result.success
|
|
||||||
assert run_calls == [["pkill", "claude"]]
|
|
||||||
cmd, kwargs = popen_calls[0]
|
|
||||||
assert cmd == ["claude"]
|
|
||||||
assert kwargs.get("start_new_session") is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_restart_claude_desktop_linux_popen_failure_reported(monkeypatch):
|
def test_restart_claude_desktop_linux_refuses_without_touching_processes(monkeypatch):
|
||||||
def fake_run(cmd, **kwargs):
|
"""There is no Claude Desktop on Linux; 'pkill claude' would substring-match
|
||||||
return _FakeCompletedProcess(returncode=0)
|
running Claude Code CLI sessions. The restart must refuse outright."""
|
||||||
|
killed = []
|
||||||
def fake_popen(cmd, **kwargs):
|
monkeypatch.setattr(c, "_run_quiet", lambda cmd: killed.append(cmd))
|
||||||
raise OSError("no such file or directory")
|
|
||||||
|
|
||||||
monkeypatch.setattr(c.sys, "platform", "linux")
|
monkeypatch.setattr(c.sys, "platform", "linux")
|
||||||
monkeypatch.setattr(c.subprocess, "run", fake_run)
|
res = c.restart_claude_desktop()
|
||||||
monkeypatch.setattr(c.subprocess, "Popen", fake_popen)
|
assert res.success is False
|
||||||
result = c.restart_claude_desktop()
|
assert killed == [] # nothing killed, nothing spawned
|
||||||
assert not result.success
|
|
||||||
assert "Claude Desktop" in result.detail
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
Reference in New Issue
Block a user