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())
|
root.addLayout(self._build_actionbar())
|
||||||
|
|
||||||
|
status_row = QHBoxLayout()
|
||||||
|
status_row.setContentsMargins(0, 0, 0, 0)
|
||||||
self.status = QLabel("Ready.")
|
self.status = QLabel("Ready.")
|
||||||
self.status.setObjectName("statusbar")
|
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._restore_layout()
|
||||||
self.reload_profiles()
|
self.reload_profiles()
|
||||||
@@ -1514,6 +1524,7 @@ class MainWindow(QMainWindow):
|
|||||||
self.current_profile = profile
|
self.current_profile = profile
|
||||||
self.servers = core.extract_servers(self.full_config)
|
self.servers = core.extract_servers(self.full_config)
|
||||||
self.dirty = False
|
self.dirty = False
|
||||||
|
self.restart_btn.hide()
|
||||||
self._undo_stack.clear()
|
self._undo_stack.clear()
|
||||||
self.undo_btn.setEnabled(False)
|
self.undo_btn.setEnabled(False)
|
||||||
self._refresh_tables(select_index=0 if self.servers else -1)
|
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"Merged & saved {self.current_profile.path}{bnote}"
|
||||||
f" · Restart {self.current_profile.label} to apply."
|
f" · Restart {self.current_profile.label} to apply."
|
||||||
)
|
)
|
||||||
|
self._offer_restart_button()
|
||||||
return
|
return
|
||||||
# else OVERWRITE: fall through to normal write
|
# else OVERWRITE: fall through to normal write
|
||||||
|
|
||||||
@@ -1907,6 +1919,29 @@ class MainWindow(QMainWindow):
|
|||||||
self.status.setText(
|
self.status.setText(
|
||||||
f"Saved {self.current_profile.path}{bnote} · Restart {self.current_profile.label} to apply."
|
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):
|
def _restore_from_backup(self):
|
||||||
if not self.current_profile:
|
if not self.current_profile:
|
||||||
@@ -1935,6 +1970,7 @@ class MainWindow(QMainWindow):
|
|||||||
# --- dirty / status -------------------------------------------------- #
|
# --- dirty / status -------------------------------------------------- #
|
||||||
def _mark_dirty(self):
|
def _mark_dirty(self):
|
||||||
self.dirty = True
|
self.dirty = True
|
||||||
|
self.restart_btn.hide()
|
||||||
self._validate()
|
self._validate()
|
||||||
self._update_status(saved=False)
|
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())
|
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
|
# Load / extract / apply
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -1522,3 +1533,91 @@ def pin_command_path(data: dict, path: str | None = None) -> tuple[dict, str | N
|
|||||||
out["args"] = args
|
out["args"] = args
|
||||||
return out, f"'{c}' → {resolved}"
|
return out, f"'{c}' → {resolved}"
|
||||||
return data, None
|
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(sys, "platform", "linux")
|
||||||
monkeypatch.setattr(c.Path, "home", lambda: tmp_path)
|
monkeypatch.setattr(c.Path, "home", lambda: tmp_path)
|
||||||
assert c.server_log_path("brave-search") is None
|
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