diff --git a/bcc.py b/bcc.py index 1779909..89fe6d9 100644 --- a/bcc.py +++ b/bcc.py @@ -1444,6 +1444,17 @@ class AboutDialog(QDialog): 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 # --------------------------------------------------------------------------- # @@ -2305,18 +2316,30 @@ class MainWindow(QMainWindow): def _offer_restart_button(self): """Show the 'Restart Claude Desktop' button after a successful save, but only when the just-saved profile is Claude Desktop -- restarting - 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): + makes no sense for Claude Code, which has no GUI process to bounce -- + 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() else: self.restart_btn.hide() def _restart_claude_desktop(self): self.restart_btn.setEnabled(False) - try: - result = core.restart_claude_desktop() - finally: - self.restart_btn.setEnabled(True) + self.restart_btn.setText("Restarting…") + # Held on self (MainWindow outlives the worker); replaced only after + # done re-enables the button, so a running thread is never dropped. + 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() if result.success: self.status.setText(f"{self.status.text()} · {result.detail}") diff --git a/bcc_core.py b/bcc_core.py index 8f89be6..fc2f535 100644 --- a/bcc_core.py +++ b/bcc_core.py @@ -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 "" ` 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.") diff --git a/tests/test_core.py b/tests/test_core.py index dfca4e7..6b6476b 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -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 # --------------------------------------------------------------------------- #