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
+74 -40
View File
@@ -970,11 +970,13 @@ class _FakeCompletedProcess:
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 = []
def fake_run(cmd, **kwargs):
calls.append(cmd)
if cmd[0] == "pgrep":
return _FakeCompletedProcess(returncode=1) # already exited
return _FakeCompletedProcess(returncode=0)
monkeypatch.setattr(c.sys, "platform", "darwin")
@@ -982,14 +984,14 @@ def test_restart_claude_desktop_macos_commands(monkeypatch):
result = c.restart_claude_desktop()
assert result.success
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):
"""pkill exiting non-zero (nothing to kill) must NOT be treated as failure."""
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=0)
@@ -1013,12 +1015,12 @@ def test_restart_claude_desktop_macos_relaunch_failure_reported(monkeypatch):
def test_restart_claude_desktop_macos_pkill_binary_missing_does_not_raise(monkeypatch):
"""pkill raising OSError (binary missing) must be swallowed, not propagated --
relaunch is still attempted."""
"""pkill/pgrep raising OSError (binary missing) must be swallowed, not
propagated -- relaunch is still attempted."""
def fake_run(cmd, **kwargs):
if cmd[0] == "pkill":
raise OSError("pkill not found")
if cmd[0] in ("pkill", "pgrep"):
raise OSError("binary not found")
return _FakeCompletedProcess(returncode=0)
monkeypatch.setattr(c.sys, "platform", "darwin")
@@ -1027,7 +1029,45 @@ def test_restart_claude_desktop_macos_pkill_binary_missing_does_not_raise(monkey
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."""
calls = []
@@ -1044,7 +1084,7 @@ def test_restart_claude_desktop_windows_commands(monkeypatch):
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):
if cmd[0] == "cmd":
return _FakeCompletedProcess(returncode=1, stderr="not found")
@@ -1057,43 +1097,37 @@ def test_restart_claude_desktop_windows_relaunch_failure_reported(monkeypatch):
assert "not found" in result.detail
def test_restart_claude_desktop_linux_commands(monkeypatch):
"""Linux: pkill claude, then relaunch via a detached Popen (no waiting)."""
run_calls = []
popen_calls = []
def test_restart_claude_desktop_windows_missing_shortcut_refuses_before_killing(
monkeypatch, tmp_path
):
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.subprocess, "run", fake_run)
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
assert not c.restart_supported()
def test_restart_claude_desktop_linux_popen_failure_reported(monkeypatch):
def fake_run(cmd, **kwargs):
return _FakeCompletedProcess(returncode=0)
def fake_popen(cmd, **kwargs):
raise OSError("no such file or directory")
def test_restart_claude_desktop_linux_refuses_without_touching_processes(monkeypatch):
"""There is no Claude Desktop on Linux; 'pkill claude' would substring-match
running Claude Code CLI sessions. The restart must refuse outright."""
killed = []
monkeypatch.setattr(c, "_run_quiet", lambda cmd: killed.append(cmd))
monkeypatch.setattr(c.sys, "platform", "linux")
monkeypatch.setattr(c.subprocess, "run", fake_run)
monkeypatch.setattr(c.subprocess, "Popen", fake_popen)
result = c.restart_claude_desktop()
assert not result.success
assert "Claude Desktop" in result.detail
res = c.restart_claude_desktop()
assert res.success is False
assert killed == [] # nothing killed, nothing spawned
# --------------------------------------------------------------------------- #