Merge branch 'feat/9-restart-claude-desktop' into integration/v1.2.0
# Conflicts: # bcc_core.py # tests/test_core.py
This commit is contained in:
@@ -1265,9 +1265,19 @@ class MainWindow(QMainWindow):
|
||||
|
||||
root.addLayout(self._build_actionbar())
|
||||
|
||||
status_row = QHBoxLayout()
|
||||
status_row.setContentsMargins(0, 0, 0, 0)
|
||||
self.status = QLabel("Ready.")
|
||||
self.status.setObjectName("statusbar")
|
||||
root.addWidget(self.status)
|
||||
status_row.addWidget(self.status, 1)
|
||||
self.restart_btn = QPushButton("Restart Claude Desktop")
|
||||
self.restart_btn.setToolTip(
|
||||
"Quit and relaunch Claude Desktop so the saved config takes effect"
|
||||
)
|
||||
self.restart_btn.clicked.connect(self._restart_claude_desktop)
|
||||
self.restart_btn.hide()
|
||||
status_row.addWidget(self.restart_btn)
|
||||
root.addLayout(status_row)
|
||||
|
||||
self._restore_layout()
|
||||
self.reload_profiles()
|
||||
@@ -1514,6 +1524,7 @@ class MainWindow(QMainWindow):
|
||||
self.current_profile = profile
|
||||
self.servers = core.extract_servers(self.full_config)
|
||||
self.dirty = False
|
||||
self.restart_btn.hide()
|
||||
self._undo_stack.clear()
|
||||
self.undo_btn.setEnabled(False)
|
||||
self._refresh_tables(select_index=0 if self.servers else -1)
|
||||
@@ -1890,6 +1901,7 @@ class MainWindow(QMainWindow):
|
||||
f"Merged & saved {self.current_profile.path}{bnote}"
|
||||
f" · Restart {self.current_profile.label} to apply."
|
||||
)
|
||||
self._offer_restart_button()
|
||||
return
|
||||
# else OVERWRITE: fall through to normal write
|
||||
|
||||
@@ -1907,6 +1919,29 @@ class MainWindow(QMainWindow):
|
||||
self.status.setText(
|
||||
f"Saved {self.current_profile.path}{bnote} · Restart {self.current_profile.label} to apply."
|
||||
)
|
||||
self._offer_restart_button()
|
||||
|
||||
# --- restart Claude Desktop (issue #9) -------------------------------- #
|
||||
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):
|
||||
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.hide()
|
||||
if result.success:
|
||||
self.status.setText(f"{self.status.text()} · {result.detail}")
|
||||
else:
|
||||
QMessageBox.warning(self, "Restart failed", result.detail)
|
||||
|
||||
def _restore_from_backup(self):
|
||||
if not self.current_profile:
|
||||
@@ -1935,6 +1970,7 @@ class MainWindow(QMainWindow):
|
||||
# --- dirty / status -------------------------------------------------- #
|
||||
def _mark_dirty(self):
|
||||
self.dirty = True
|
||||
self.restart_btn.hide()
|
||||
self._validate()
|
||||
self._update_status(saved=False)
|
||||
|
||||
|
||||
+99
@@ -136,6 +136,17 @@ def profile_from_path(path: str | os.PathLike) -> Profile:
|
||||
return Profile(label=label, path=p, config_exists=p.is_file())
|
||||
|
||||
|
||||
def profile_targets_claude_desktop(profile: Profile) -> bool:
|
||||
"""
|
||||
True when `profile` points at a Claude Desktop config
|
||||
(claude_desktop_config.json), as opposed to Claude Code (~/.claude.json
|
||||
or the legacy ~/.claude/settings.json). Used to gate Desktop-only actions
|
||||
like "Restart Claude Desktop" so they never show up for a Claude Code
|
||||
profile -- restarting the CLI makes no sense.
|
||||
"""
|
||||
return Path(profile.path).name == CONFIG_FILENAME
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Load / extract / apply
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -1522,3 +1533,91 @@ def pin_command_path(data: dict, path: str | None = None) -> tuple[dict, str | N
|
||||
out["args"] = args
|
||||
return out, f"'{c}' → {resolved}"
|
||||
return data, None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Restart Claude Desktop (issue #9)
|
||||
#
|
||||
# Scoped strictly to Claude DESKTOP, the GUI app -- never Claude Code (the
|
||||
# CLI), which has no long-running process to bounce. Callers should gate this
|
||||
# behind profile_targets_claude_desktop() before offering it in the UI.
|
||||
# --------------------------------------------------------------------------- #
|
||||
class RestartResult(NamedTuple):
|
||||
"""Outcome of a restart_claude_desktop() attempt."""
|
||||
|
||||
success: bool
|
||||
detail: str
|
||||
|
||||
|
||||
def _run_quiet(cmd: list[str]) -> None:
|
||||
"""Best-effort fire-and-forget command. Never raises: a nonzero exit (e.g.
|
||||
pkill finding nothing to kill) is expected and not an error."""
|
||||
with contextlib.suppress(OSError):
|
||||
subprocess.run(cmd, capture_output=True)
|
||||
|
||||
|
||||
def _restart_claude_desktop_macos() -> RestartResult:
|
||||
_run_quiet(["pkill", "-x", "Claude"])
|
||||
try:
|
||||
result = subprocess.run(["open", "-a", "Claude"], capture_output=True, text=True)
|
||||
except OSError as e:
|
||||
return RestartResult(False, f"Couldn't launch Claude Desktop: {e}")
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or result.stdout or "").strip() or "'open -a Claude' failed"
|
||||
return RestartResult(False, detail)
|
||||
return RestartResult(True, "Claude Desktop restarted.")
|
||||
|
||||
|
||||
def _claude_windows_start_menu_shortcut() -> Path:
|
||||
appdata = os.environ.get("APPDATA", str(Path.home()))
|
||||
return Path(appdata) / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Claude.lnk"
|
||||
|
||||
|
||||
def _restart_claude_desktop_windows() -> RestartResult:
|
||||
_run_quiet(["taskkill", "/IM", "Claude.exe", "/F"])
|
||||
shortcut = _claude_windows_start_menu_shortcut()
|
||||
try:
|
||||
# `cmd /c start "" <target>` launches detached, the same as double-clicking
|
||||
# the Start-menu shortcut, and returns immediately.
|
||||
result = subprocess.run(
|
||||
["cmd", "/c", "start", "", str(shortcut)], capture_output=True, text=True
|
||||
)
|
||||
except OSError as e:
|
||||
return RestartResult(False, f"Couldn't launch Claude Desktop: {e}")
|
||||
if result.returncode != 0:
|
||||
detail = (
|
||||
result.stderr or result.stdout or ""
|
||||
).strip() or "failed to relaunch Claude Desktop"
|
||||
return RestartResult(False, detail)
|
||||
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.
|
||||
"""
|
||||
if sys.platform == "darwin":
|
||||
return _restart_claude_desktop_macos()
|
||||
if sys.platform.startswith("win"):
|
||||
return _restart_claude_desktop_windows()
|
||||
return _restart_claude_desktop_linux()
|
||||
|
||||
@@ -754,3 +754,159 @@ def test_server_log_path_unsupported_platform_returns_none(tmp_path, monkeypatch
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
monkeypatch.setattr(c.Path, "home", lambda: tmp_path)
|
||||
assert c.server_log_path("brave-search") is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# restart_claude_desktop / profile_targets_claude_desktop (issue #9)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_profile_targets_claude_desktop_true_for_desktop_config():
|
||||
p = c.Profile(label="Claude", path="/x/Claude/claude_desktop_config.json", config_exists=True)
|
||||
assert c.profile_targets_claude_desktop(p)
|
||||
|
||||
|
||||
def test_profile_targets_claude_desktop_false_for_claude_code():
|
||||
p = c.Profile(label="Claude Code", path="/home/me/.claude.json", config_exists=True)
|
||||
assert not c.profile_targets_claude_desktop(p)
|
||||
|
||||
|
||||
def test_profile_targets_claude_desktop_false_for_legacy_settings():
|
||||
p = c.Profile(
|
||||
label="Claude Code (legacy settings.json)",
|
||||
path="/home/me/.claude/settings.json",
|
||||
config_exists=True,
|
||||
)
|
||||
assert not c.profile_targets_claude_desktop(p)
|
||||
|
||||
|
||||
class _FakeCompletedProcess:
|
||||
def __init__(self, returncode=0, stdout="", stderr=""):
|
||||
self.returncode = returncode
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
|
||||
|
||||
def test_restart_claude_desktop_macos_commands(monkeypatch):
|
||||
"""macOS: pkill -x "Claude" then open -a Claude, in that order."""
|
||||
calls = []
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
calls.append(cmd)
|
||||
return _FakeCompletedProcess(returncode=0)
|
||||
|
||||
monkeypatch.setattr(c.sys, "platform", "darwin")
|
||||
monkeypatch.setattr(c.subprocess, "run", fake_run)
|
||||
result = c.restart_claude_desktop()
|
||||
assert result.success
|
||||
assert calls[0] == ["pkill", "-x", "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":
|
||||
return _FakeCompletedProcess(returncode=1) # no matching process
|
||||
return _FakeCompletedProcess(returncode=0)
|
||||
|
||||
monkeypatch.setattr(c.sys, "platform", "darwin")
|
||||
monkeypatch.setattr(c.subprocess, "run", fake_run)
|
||||
result = c.restart_claude_desktop()
|
||||
assert result.success
|
||||
|
||||
|
||||
def test_restart_claude_desktop_macos_relaunch_failure_reported(monkeypatch):
|
||||
def fake_run(cmd, **kwargs):
|
||||
if cmd[0] == "open":
|
||||
return _FakeCompletedProcess(returncode=1, stderr="Unable to find application")
|
||||
return _FakeCompletedProcess(returncode=1)
|
||||
|
||||
monkeypatch.setattr(c.sys, "platform", "darwin")
|
||||
monkeypatch.setattr(c.subprocess, "run", fake_run)
|
||||
result = c.restart_claude_desktop()
|
||||
assert not result.success
|
||||
assert "Unable to find application" in result.detail
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
if cmd[0] == "pkill":
|
||||
raise OSError("pkill not found")
|
||||
return _FakeCompletedProcess(returncode=0)
|
||||
|
||||
monkeypatch.setattr(c.sys, "platform", "darwin")
|
||||
monkeypatch.setattr(c.subprocess, "run", fake_run)
|
||||
result = c.restart_claude_desktop()
|
||||
assert result.success
|
||||
|
||||
|
||||
def test_restart_claude_desktop_windows_commands(monkeypatch):
|
||||
"""Windows: taskkill the process, then relaunch via the Start-menu shortcut."""
|
||||
calls = []
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
calls.append(cmd)
|
||||
return _FakeCompletedProcess(returncode=0)
|
||||
|
||||
monkeypatch.setattr(c.sys, "platform", "win32")
|
||||
monkeypatch.setattr(c.subprocess, "run", fake_run)
|
||||
result = c.restart_claude_desktop()
|
||||
assert result.success
|
||||
assert calls[0] == ["taskkill", "/IM", "Claude.exe", "/F"]
|
||||
assert calls[1][:3] == ["cmd", "/c", "start"]
|
||||
assert calls[1][-1].endswith("Claude.lnk")
|
||||
|
||||
|
||||
def test_restart_claude_desktop_windows_relaunch_failure_reported(monkeypatch):
|
||||
def fake_run(cmd, **kwargs):
|
||||
if cmd[0] == "cmd":
|
||||
return _FakeCompletedProcess(returncode=1, stderr="not found")
|
||||
return _FakeCompletedProcess(returncode=0)
|
||||
|
||||
monkeypatch.setattr(c.sys, "platform", "win32")
|
||||
monkeypatch.setattr(c.subprocess, "run", fake_run)
|
||||
result = c.restart_claude_desktop()
|
||||
assert not result.success
|
||||
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 fake_run(cmd, **kwargs):
|
||||
run_calls.append(cmd)
|
||||
return _FakeCompletedProcess(returncode=0)
|
||||
|
||||
class _FakePopen:
|
||||
def __init__(self, cmd, **kwargs):
|
||||
popen_calls.append((cmd, kwargs))
|
||||
|
||||
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
|
||||
|
||||
|
||||
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")
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user