Compare commits
28 Commits
f4d4301c26
..
v1.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f535fb77f | |||
| 8cf19d43c4 | |||
| 0ef4586698 | |||
| ed7c40cac9 | |||
| 1384ed9703 | |||
| 41891ddad4 | |||
| 408f517c5d | |||
| 9036729cd8 | |||
| 29a08e9532 | |||
| 87303809b8 | |||
| 42456f25d2 | |||
| 2d274b9e03 | |||
| 5c476bb13f | |||
| 62c8a2ea65 | |||
| b485357cd5 | |||
| 6a91f830dc | |||
| 6dacc26057 | |||
| 1087fc84d1 | |||
| 5df364fb2e | |||
| c7b2c90518 | |||
| c493aa0c84 | |||
| bb355dac31 | |||
| d95db2b026 | |||
| 42963f98b4 | |||
| f5c9780948 | |||
| 06326e5e9d | |||
| 6d91c709a7 | |||
| 3b5379a2b8 |
@@ -29,21 +29,37 @@ jobs:
|
|||||||
run: ruff format --check .
|
run: ruff format --check .
|
||||||
|
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ${{ matrix.os }}
|
||||||
name: Tests (py${{ matrix.python }})
|
name: Tests (py${{ matrix.python }} / ${{ matrix.os }})
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
python: ["3.10", "3.12"]
|
os: [ubuntu-latest]
|
||||||
|
python: ["3.10", "3.12", "3.13"]
|
||||||
|
include:
|
||||||
|
# Windows tests on 3.12 only — the version the release binaries ship
|
||||||
|
# with. The self-hosted Windows runner blocks setup-python's install
|
||||||
|
# script (PowerShell execution policy), so it uses the host's `py`
|
||||||
|
# launcher + venv, same as release.yml.
|
||||||
|
- os: windows-latest
|
||||||
|
python: "3.12"
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Python ${{ matrix.python }}
|
- name: Set up Python ${{ matrix.python }} (Linux)
|
||||||
|
if: runner.os == 'Linux'
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python }}
|
python-version: ${{ matrix.python }}
|
||||||
|
|
||||||
|
- name: Set up Python venv (Windows)
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
py -${{ matrix.python }} -m venv .venv
|
||||||
|
Add-Content -Path $env:GITHUB_PATH -Value "$env:GITHUB_WORKSPACE\.venv\Scripts"
|
||||||
|
|
||||||
# bcc_core has no GUI imports, so the test suite needs no PySide6 —
|
# bcc_core has no GUI imports, so the test suite needs no PySide6 —
|
||||||
# keeps CI fast and avoids Qt system-library headaches on the runner.
|
# keeps CI fast and avoids Qt system-library headaches on the runner.
|
||||||
- name: Install test dependencies
|
- name: Install test dependencies
|
||||||
|
|||||||
@@ -13,11 +13,13 @@ from __future__ import annotations
|
|||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import ClassVar
|
||||||
|
|
||||||
from PySide6.QtCore import QRect, QSettings, QSize, Qt, QThread, QTimer, QUrl, Signal
|
from PySide6.QtCore import QRect, QSettings, QSize, Qt, QThread, QTimer, QUrl, Signal
|
||||||
from PySide6.QtGui import (
|
from PySide6.QtGui import (
|
||||||
QAction,
|
QAction,
|
||||||
QColor,
|
QColor,
|
||||||
|
QCursor,
|
||||||
QDesktopServices,
|
QDesktopServices,
|
||||||
QGuiApplication,
|
QGuiApplication,
|
||||||
QIcon,
|
QIcon,
|
||||||
@@ -50,12 +52,17 @@ from PySide6.QtWidgets import (
|
|||||||
QStyledItemDelegate,
|
QStyledItemDelegate,
|
||||||
QTableWidget,
|
QTableWidget,
|
||||||
QTableWidgetItem,
|
QTableWidgetItem,
|
||||||
|
QToolTip,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
import bcc_core as core
|
import bcc_core as core
|
||||||
|
|
||||||
|
# A full ~/.claude.json with history can be huge; parsing happens on the UI
|
||||||
|
# thread during drag-and-drop import, so skip anything larger than this.
|
||||||
|
MAX_DROP_IMPORT_BYTES = 5 * 1024 * 1024 # 5 MB
|
||||||
|
|
||||||
# --- One-line rebrand: change this to recolor the whole app --------------- #
|
# --- One-line rebrand: change this to recolor the whole app --------------- #
|
||||||
ACCENT = "#f97316" # warm orange
|
ACCENT = "#f97316" # warm orange
|
||||||
ACCENT_DIM = "#c2570b"
|
ACCENT_DIM = "#c2570b"
|
||||||
@@ -120,6 +127,7 @@ QScrollBar:vertical {{ background: transparent; width: 10px; margin: 2px; }}
|
|||||||
QScrollBar::handle:vertical {{ background: {BORDER}; border-radius: 5px; min-height: 24px; }}
|
QScrollBar::handle:vertical {{ background: {BORDER}; border-radius: 5px; min-height: 24px; }}
|
||||||
QScrollBar::add-line, QScrollBar::sub-line {{ height: 0; }}
|
QScrollBar::add-line, QScrollBar::sub-line {{ height: 0; }}
|
||||||
QLabel#statusbar {{ color: {MUTED}; padding: 4px 2px; }}
|
QLabel#statusbar {{ color: {MUTED}; padding: 4px 2px; }}
|
||||||
|
QLabel#warnBanner {{ color: #1a1205; background: {WARN}; border-radius: 8px; padding: 8px 10px; font-weight: 600; }}
|
||||||
QLabel#section {{ color: {MUTED}; font-weight: 600; font-size: 12px; padding: 2px 2px; }}
|
QLabel#section {{ color: {MUTED}; font-weight: 600; font-size: 12px; padding: 2px 2px; }}
|
||||||
QLabel#sectionDisabled {{ color: {MUTED}; font-weight: 600; font-size: 12px; padding: 2px 2px; }}
|
QLabel#sectionDisabled {{ color: {MUTED}; font-weight: 600; font-size: 12px; padding: 2px 2px; }}
|
||||||
QLabel#placeholder {{ color: {MUTED}; padding: 12px; background: {PANEL_2}; border: 1px dashed {BORDER}; border-radius: 8px; }}
|
QLabel#placeholder {{ color: {MUTED}; padding: 12px; background: {PANEL_2}; border: 1px dashed {BORDER}; border-radius: 8px; }}
|
||||||
@@ -172,10 +180,30 @@ class UpdateCheckWorker(QThread):
|
|||||||
running binary. Fails quiet — emits None on any network problem — so
|
running binary. Fails quiet — emits None on any network problem — so
|
||||||
it's safe to fire unattended from a silent startup check as well as from
|
it's safe to fire unattended from a silent startup check as well as from
|
||||||
the About dialog's "Check for updates" button.
|
the About dialog's "Check for updates" button.
|
||||||
|
|
||||||
|
Lifetime: the class keeps every instance alive in `_live` until its
|
||||||
|
thread has finished. Without this, a caller whose own reference dies
|
||||||
|
early (the About dialog is a temporary — closing it mid-check used to
|
||||||
|
GC the dialog and the running QThread with it) crashes the process with
|
||||||
|
"QThread: Destroyed while thread is still running". Callers may drop
|
||||||
|
their reference at any time; signal connections to a destroyed receiver
|
||||||
|
are disconnected by Qt, so a late result is simply discarded.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
done = Signal(object) # dict | None
|
done = Signal(object) # dict | None
|
||||||
|
|
||||||
|
_live: ClassVar[set[UpdateCheckWorker]] = set()
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
UpdateCheckWorker._live.add(self)
|
||||||
|
self.finished.connect(self._release_keepalive)
|
||||||
|
|
||||||
|
def _release_keepalive(self):
|
||||||
|
# Delivered on the main thread after run() has returned; only now is
|
||||||
|
# it safe for the last reference to drop.
|
||||||
|
UpdateCheckWorker._live.discard(self)
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
self.done.emit(core.fetch_latest_release())
|
self.done.emit(core.fetch_latest_release())
|
||||||
|
|
||||||
@@ -245,10 +273,31 @@ class KeyValueTable(QWidget):
|
|||||||
self.reveal_btn.setText("Hide secrets" if on else "Show secrets")
|
self.reveal_btn.setText("Hide secrets" if on else "Show secrets")
|
||||||
self.table.viewport().update()
|
self.table.viewport().update()
|
||||||
|
|
||||||
def _changed(self, *_):
|
def _changed(self, item=None, *_):
|
||||||
|
if item is not None and item.column() == 0:
|
||||||
|
new_key = item.text().strip()
|
||||||
|
row = item.row()
|
||||||
|
if new_key and self._is_duplicate_key(new_key, row):
|
||||||
|
prev_key = item.data(Qt.ItemDataRole.UserRole)
|
||||||
|
prev_key = prev_key if prev_key is not None else ""
|
||||||
|
self.table.blockSignals(True)
|
||||||
|
item.setText(prev_key)
|
||||||
|
self.table.blockSignals(False)
|
||||||
|
QToolTip.showText(QCursor.pos(), f"Duplicate key '{new_key}' — reverted.")
|
||||||
|
return
|
||||||
|
item.setData(Qt.ItemDataRole.UserRole, new_key)
|
||||||
if self._on_change:
|
if self._on_change:
|
||||||
self._on_change()
|
self._on_change()
|
||||||
|
|
||||||
|
def _is_duplicate_key(self, key: str, ignore_row: int) -> bool:
|
||||||
|
for r in range(self.table.rowCount()):
|
||||||
|
if r == ignore_row:
|
||||||
|
continue
|
||||||
|
other = self.table.item(r, 0)
|
||||||
|
if other and other.text().strip() == key:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def _add_row(self):
|
def _add_row(self):
|
||||||
dlg = QDialog(self.window())
|
dlg = QDialog(self.window())
|
||||||
dlg.setWindowTitle(f"Add {self._key_label}")
|
dlg.setWindowTitle(f"Add {self._key_label}")
|
||||||
@@ -275,6 +324,13 @@ class KeyValueTable(QWidget):
|
|||||||
grid.addWidget(val_edit, 1, 1)
|
grid.addWidget(val_edit, 1, 1)
|
||||||
v.addLayout(grid)
|
v.addLayout(grid)
|
||||||
|
|
||||||
|
# Shown when the typed key already exists in the table.
|
||||||
|
dup_warn = QLabel("")
|
||||||
|
dup_warn.setStyleSheet(f"color: {WARN};")
|
||||||
|
dup_warn.setWordWrap(True)
|
||||||
|
dup_warn.hide()
|
||||||
|
v.addWidget(dup_warn)
|
||||||
|
|
||||||
# Type an API_KEY/TOKEN-style name and the value field masks itself.
|
# Type an API_KEY/TOKEN-style name and the value field masks itself.
|
||||||
def _sync_echo(text):
|
def _sync_echo(text):
|
||||||
secret = core.is_secret_key(text)
|
secret = core.is_secret_key(text)
|
||||||
@@ -290,7 +346,15 @@ class KeyValueTable(QWidget):
|
|||||||
ok_btn = btns.button(QDialogButtonBox.StandardButton.Ok)
|
ok_btn = btns.button(QDialogButtonBox.StandardButton.Ok)
|
||||||
ok_btn.setObjectName("primary")
|
ok_btn.setObjectName("primary")
|
||||||
ok_btn.setEnabled(False)
|
ok_btn.setEnabled(False)
|
||||||
key_edit.textChanged.connect(lambda t: ok_btn.setEnabled(bool(t.strip())))
|
|
||||||
|
def _validate(text):
|
||||||
|
k = text.strip()
|
||||||
|
dup = bool(k) and self._is_duplicate_key(k, ignore_row=-1)
|
||||||
|
ok_btn.setEnabled(bool(k) and not dup)
|
||||||
|
dup_warn.setText(f"'{k}' already exists" if dup else "")
|
||||||
|
dup_warn.setVisible(dup)
|
||||||
|
|
||||||
|
key_edit.textChanged.connect(_validate)
|
||||||
btns.accepted.connect(dlg.accept)
|
btns.accepted.connect(dlg.accept)
|
||||||
btns.rejected.connect(dlg.reject)
|
btns.rejected.connect(dlg.reject)
|
||||||
v.addWidget(btns)
|
v.addWidget(btns)
|
||||||
@@ -304,13 +368,15 @@ class KeyValueTable(QWidget):
|
|||||||
return
|
return
|
||||||
k = key_edit.text().strip()
|
k = key_edit.text().strip()
|
||||||
val = val_edit.text()
|
val = val_edit.text()
|
||||||
if not k:
|
if not k or self._is_duplicate_key(k, ignore_row=-1):
|
||||||
return
|
return
|
||||||
if self._before_change:
|
if self._before_change:
|
||||||
self._before_change()
|
self._before_change()
|
||||||
r = self.table.rowCount()
|
r = self.table.rowCount()
|
||||||
self.table.insertRow(r)
|
self.table.insertRow(r)
|
||||||
self.table.setItem(r, 0, QTableWidgetItem(k))
|
key_item = QTableWidgetItem(k)
|
||||||
|
key_item.setData(Qt.ItemDataRole.UserRole, k)
|
||||||
|
self.table.setItem(r, 0, key_item)
|
||||||
self.table.setItem(r, 1, QTableWidgetItem(val))
|
self.table.setItem(r, 1, QTableWidgetItem(val))
|
||||||
self._changed()
|
self._changed()
|
||||||
|
|
||||||
@@ -329,7 +395,9 @@ class KeyValueTable(QWidget):
|
|||||||
for k, v in (d or {}).items():
|
for k, v in (d or {}).items():
|
||||||
r = self.table.rowCount()
|
r = self.table.rowCount()
|
||||||
self.table.insertRow(r)
|
self.table.insertRow(r)
|
||||||
self.table.setItem(r, 0, QTableWidgetItem(str(k)))
|
key_item = QTableWidgetItem(str(k))
|
||||||
|
key_item.setData(Qt.ItemDataRole.UserRole, str(k))
|
||||||
|
self.table.setItem(r, 0, key_item)
|
||||||
self.table.setItem(r, 1, QTableWidgetItem(str(v)))
|
self.table.setItem(r, 1, QTableWidgetItem(str(v)))
|
||||||
self.table.blockSignals(False)
|
self.table.blockSignals(False)
|
||||||
|
|
||||||
@@ -1253,6 +1321,31 @@ class LogViewerDialog(QDialog):
|
|||||||
super().closeEvent(event)
|
super().closeEvent(event)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Bundled assets (icons) — resolves both a normal source run and a frozen
|
||||||
|
# PyInstaller build (onefile extracts assets under sys._MEIPASS).
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _asset_dir() -> Path:
|
||||||
|
base = getattr(sys, "_MEIPASS", None)
|
||||||
|
return Path(base) if base else Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
def _app_icon() -> QIcon:
|
||||||
|
"""Multi-resolution app/window icon from the bundled PNGs (falls back to
|
||||||
|
the .ico). Returns a null QIcon if no asset is found."""
|
||||||
|
icon = QIcon()
|
||||||
|
base = _asset_dir() / "icons" / "twin-gears" / "rounded"
|
||||||
|
for size in (16, 32, 48, 64, 128, 256, 512):
|
||||||
|
f = base / f"icon-{size}.png"
|
||||||
|
if f.is_file():
|
||||||
|
icon.addFile(str(f))
|
||||||
|
if icon.isNull():
|
||||||
|
ico = _asset_dir() / "icons" / "app.ico"
|
||||||
|
if ico.is_file():
|
||||||
|
icon.addFile(str(ico))
|
||||||
|
return icon
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# About dialog
|
# About dialog
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -1274,9 +1367,7 @@ class AboutDialog(QDialog):
|
|||||||
self._worker: UpdateCheckWorker | None = None
|
self._worker: UpdateCheckWorker | None = None
|
||||||
self._release_url: str | None = None
|
self._release_url: str | None = None
|
||||||
|
|
||||||
icon_path = (
|
icon_path = _asset_dir() / "icons" / "twin-gears" / "rounded" / "icon-128.png"
|
||||||
Path(__file__).resolve().parent / "icons" / "twin-gears" / "rounded" / "icon-128.png"
|
|
||||||
)
|
|
||||||
if icon_path.is_file():
|
if icon_path.is_file():
|
||||||
self.setWindowIcon(QIcon(str(icon_path)))
|
self.setWindowIcon(QIcon(str(icon_path)))
|
||||||
|
|
||||||
@@ -1400,6 +1491,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
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -1437,6 +1539,15 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
root.addLayout(self._build_topbar())
|
root.addLayout(self._build_topbar())
|
||||||
|
|
||||||
|
# Persistent warning banner (MSIX-virtualized config, etc.). Lives in
|
||||||
|
# its own widget because the status bar is rewritten on every action,
|
||||||
|
# which used to wipe the warning before the user could read it.
|
||||||
|
self.warn_banner = QLabel("")
|
||||||
|
self.warn_banner.setObjectName("warnBanner")
|
||||||
|
self.warn_banner.setWordWrap(True)
|
||||||
|
self.warn_banner.hide()
|
||||||
|
root.addWidget(self.warn_banner)
|
||||||
|
|
||||||
# User-draggable divider between the server list and the editor.
|
# User-draggable divider between the server list and the editor.
|
||||||
split = QSplitter(Qt.Orientation.Horizontal)
|
split = QSplitter(Qt.Orientation.Horizontal)
|
||||||
split.setChildrenCollapsible(False)
|
split.setChildrenCollapsible(False)
|
||||||
@@ -1488,14 +1599,16 @@ class MainWindow(QMainWindow):
|
|||||||
last = float(st.value("update/lastCheck", 0.0, type=float) or 0.0)
|
last = float(st.value("update/lastCheck", 0.0, type=float) or 0.0)
|
||||||
if (time.time() - last) < 86400: # at most once/day
|
if (time.time() - last) < 86400: # at most once/day
|
||||||
return
|
return
|
||||||
st.setValue("update/lastCheck", time.time())
|
|
||||||
self._startup_update_worker = UpdateCheckWorker()
|
self._startup_update_worker = UpdateCheckWorker()
|
||||||
self._startup_update_worker.done.connect(self._on_startup_update_checked)
|
self._startup_update_worker.done.connect(self._on_startup_update_checked)
|
||||||
self._startup_update_worker.start()
|
self._startup_update_worker.start()
|
||||||
|
|
||||||
def _on_startup_update_checked(self, release: dict | None):
|
def _on_startup_update_checked(self, release: dict | None):
|
||||||
self._startup_update_worker = None
|
self._startup_update_worker = None
|
||||||
if release and core.is_newer_version(core.__version__, release["version"]):
|
if release is None:
|
||||||
|
return # offline/failed check: don't advance lastCheck, allow retry
|
||||||
|
QSettings("BCC", "BetterClaudeConfig").setValue("update/lastCheck", time.time())
|
||||||
|
if core.is_newer_version(core.__version__, release["version"]):
|
||||||
self.status.setText(
|
self.status.setText(
|
||||||
f"Update available: {release['version']} · Help ▸ About to view it."
|
f"Update available: {release['version']} · Help ▸ About to view it."
|
||||||
)
|
)
|
||||||
@@ -1709,8 +1822,9 @@ class MainWindow(QMainWindow):
|
|||||||
def _maybe_warn_msix(self):
|
def _maybe_warn_msix(self):
|
||||||
"""
|
"""
|
||||||
Windows-only, no-op everywhere else: if Claude Desktop looks like an
|
Windows-only, no-op everywhere else: if Claude Desktop looks like an
|
||||||
MSIX/Store install with a virtualized config, append a warning to the
|
MSIX/Store install with a virtualized config, show a persistent banner
|
||||||
status bar so edits to the plain %APPDATA% path aren't silently lost.
|
so edits to the plain %APPDATA% path aren't silently lost. (The status
|
||||||
|
bar is the wrong home for this: it's rewritten on every action.)
|
||||||
Defensive on purpose -- this must never block startup or profile load.
|
Defensive on purpose -- this must never block startup or profile load.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
@@ -1718,8 +1832,11 @@ class MainWindow(QMainWindow):
|
|||||||
except Exception:
|
except Exception:
|
||||||
return
|
return
|
||||||
if warning:
|
if warning:
|
||||||
self.status.setText(f"{self.status.text()} ⚠ {warning}")
|
self.warn_banner.setText(f"⚠ {warning}")
|
||||||
self.status.setToolTip(warning)
|
self.warn_banner.setToolTip(warning)
|
||||||
|
self.warn_banner.show()
|
||||||
|
else:
|
||||||
|
self.warn_banner.hide()
|
||||||
|
|
||||||
def add_custom_config(self):
|
def add_custom_config(self):
|
||||||
start = str(core.app_support_base())
|
start = str(core.app_support_base())
|
||||||
@@ -2022,12 +2139,17 @@ class MainWindow(QMainWindow):
|
|||||||
if not (0 <= idx < len(self.servers)):
|
if not (0 <= idx < len(self.servers)):
|
||||||
return
|
return
|
||||||
entry = self.servers[idx]
|
entry = self.servers[idx]
|
||||||
|
old_name = entry.name
|
||||||
entry.name = self.editor.current_name()
|
entry.name = self.editor.current_name()
|
||||||
entry.data = self.editor.dump_data()
|
entry.data = self.editor.dump_data()
|
||||||
# The server stays in its section (enable state unchanged), so update
|
# The server stays in its section (enable state unchanged), so update
|
||||||
# its existing row in place rather than re-rendering.
|
# its existing row in place rather than re-rendering.
|
||||||
# An edit invalidates any cached "Test all" result -- the server that
|
# An edit invalidates any cached "Test all" result -- the server that
|
||||||
# was spawn-tested no longer matches what's on disk once saved.
|
# was spawn-tested no longer matches what's on disk once saved. Pop
|
||||||
|
# both names: the old one (so a rename doesn't leave a stale entry
|
||||||
|
# for whoever takes that name next) and the new one (so we don't
|
||||||
|
# inherit a stale result cached under the name being renamed to).
|
||||||
|
self._health.pop(old_name, None)
|
||||||
self._health.pop(entry.name, None)
|
self._health.pop(entry.name, None)
|
||||||
loc = self._row_of_index.get(idx)
|
loc = self._row_of_index.get(idx)
|
||||||
if loc:
|
if loc:
|
||||||
@@ -2261,18 +2383,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}")
|
||||||
@@ -2333,29 +2467,44 @@ class MainWindow(QMainWindow):
|
|||||||
e.acceptProposedAction()
|
e.acceptProposedAction()
|
||||||
|
|
||||||
def dropEvent(self, e):
|
def dropEvent(self, e):
|
||||||
|
total_added, total_replaced, files_imported = 0, 0, 0
|
||||||
|
undo_pushed = False
|
||||||
for u in e.mimeData().urls():
|
for u in e.mimeData().urls():
|
||||||
path = u.toLocalFile()
|
path = u.toLocalFile()
|
||||||
if not path.endswith(".json"):
|
if not path.endswith(".json"):
|
||||||
continue
|
continue
|
||||||
text = Path(path).read_text(encoding="utf-8")
|
p = Path(path)
|
||||||
|
if p.stat().st_size > MAX_DROP_IMPORT_BYTES:
|
||||||
|
QMessageBox.warning(
|
||||||
|
self,
|
||||||
|
"File too large",
|
||||||
|
f"{p.name}:\nFile exceeds the 5 MB import limit and was skipped.",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
text = p.read_text(encoding="utf-8")
|
||||||
try:
|
try:
|
||||||
servers = core.parse_pasted_json(text)
|
servers = core.parse_pasted_json(text)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
QMessageBox.warning(self, "Couldn't import", f"{Path(path).name}:\n{ex}")
|
QMessageBox.warning(self, "Couldn't import", f"{p.name}:\n{ex}")
|
||||||
continue
|
continue
|
||||||
self._push_undo()
|
if not undo_pushed:
|
||||||
|
self._push_undo()
|
||||||
|
undo_pushed = True
|
||||||
added, replaced = 0, 0
|
added, replaced = 0, 0
|
||||||
for name, data in servers.items():
|
for name, data in servers.items():
|
||||||
a, r = self._import_server(name, data)
|
a, r = self._import_server(name, data)
|
||||||
added += int(a)
|
added += int(a)
|
||||||
replaced += int(r)
|
replaced += int(r)
|
||||||
|
total_added += added
|
||||||
|
total_replaced += replaced
|
||||||
|
files_imported += 1
|
||||||
|
if files_imported:
|
||||||
self._refresh_tables(select_index=len(self.servers) - 1)
|
self._refresh_tables(select_index=len(self.servers) - 1)
|
||||||
self._mark_dirty()
|
self._mark_dirty()
|
||||||
self.status.setText(
|
self.status.setText(
|
||||||
f"Imported {added} added, {replaced} replaced from {Path(path).name}. "
|
f"Imported {total_added} added, {total_replaced} replaced from "
|
||||||
"Review and Save."
|
f"{files_imported} file(s). Review and Save."
|
||||||
)
|
)
|
||||||
break
|
|
||||||
|
|
||||||
def closeEvent(self, e):
|
def closeEvent(self, e):
|
||||||
if self.dirty and not self._confirm_discard():
|
if self.dirty and not self._confirm_discard():
|
||||||
@@ -2366,9 +2515,23 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
if sys.platform == "win32":
|
||||||
|
# Without an explicit AppUserModelID, Windows taskbar groups the app
|
||||||
|
# under the default host/Python icon instead of our own window icon.
|
||||||
|
try:
|
||||||
|
import ctypes
|
||||||
|
|
||||||
|
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(
|
||||||
|
"io.avezzano.better-claude-config"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
app.setApplicationName("Better Claude Config")
|
app.setApplicationName("Better Claude Config")
|
||||||
app.setApplicationDisplayName("Better Claude Config")
|
app.setApplicationDisplayName("Better Claude Config")
|
||||||
|
icon = _app_icon()
|
||||||
|
if not icon.isNull():
|
||||||
|
app.setWindowIcon(icon)
|
||||||
app.setStyleSheet(STYLESHEET)
|
app.setStyleSheet(STYLESHEET)
|
||||||
win = MainWindow()
|
win = MainWindow()
|
||||||
win.show()
|
win.show()
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ a = Analysis(
|
|||||||
["bcc.py"],
|
["bcc.py"],
|
||||||
pathex=[],
|
pathex=[],
|
||||||
binaries=[],
|
binaries=[],
|
||||||
datas=[],
|
datas=[("icons", "icons")],
|
||||||
hiddenimports=[],
|
hiddenimports=[],
|
||||||
hookspath=[],
|
hookspath=[],
|
||||||
hooksconfig={},
|
hooksconfig={},
|
||||||
@@ -78,8 +78,8 @@ if sys.platform == "darwin":
|
|||||||
info_plist={
|
info_plist={
|
||||||
"CFBundleName": "Better Claude Config",
|
"CFBundleName": "Better Claude Config",
|
||||||
"CFBundleDisplayName": "Better Claude Config",
|
"CFBundleDisplayName": "Better Claude Config",
|
||||||
"CFBundleShortVersionString": "1.0.0",
|
"CFBundleShortVersionString": "1.2.1",
|
||||||
"CFBundleVersion": "1.0.0",
|
"CFBundleVersion": "1.2.1",
|
||||||
"NSHighResolutionCapable": True,
|
"NSHighResolutionCapable": True,
|
||||||
"NSRequiresAquaSystemAppearance": False, # supports dark mode
|
"NSRequiresAquaSystemAppearance": False, # supports dark mode
|
||||||
"LSMinimumSystemVersion": "11.0",
|
"LSMinimumSystemVersion": "11.0",
|
||||||
|
|||||||
+94
-23
@@ -59,7 +59,7 @@ KNOWN_FIELDS = {"command", "args", "env", "url", "type", "headers"}
|
|||||||
# binary. All network I/O here is fail-quiet (returns None on any problem)
|
# binary. All network I/O here is fail-quiet (returns None on any problem)
|
||||||
# so it's safe to run unattended, off the UI thread, at startup.
|
# so it's safe to run unattended, off the UI thread, at startup.
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
__version__ = "1.2.0"
|
__version__ = "1.2.1"
|
||||||
|
|
||||||
REPO_URL = "https://git.avezzano.io/the_og/better-claude-config"
|
REPO_URL = "https://git.avezzano.io/the_og/better-claude-config"
|
||||||
ISSUES_URL = f"{REPO_URL}/issues"
|
ISSUES_URL = f"{REPO_URL}/issues"
|
||||||
@@ -1581,6 +1581,23 @@ def server_log_path(name: str) -> Path | None:
|
|||||||
_STDERR_CAP = 4096 # bytes
|
_STDERR_CAP = 4096 # bytes
|
||||||
|
|
||||||
|
|
||||||
|
def _kill_process_tree_windows(pid: int) -> None:
|
||||||
|
"""
|
||||||
|
Kill `pid` and its whole descendant tree via `taskkill /T /F` (issue #13).
|
||||||
|
|
||||||
|
Popen.kill() only terminates the direct child; runner-style commands
|
||||||
|
(npx → node → server, cmd → real process) leave the actual server alive,
|
||||||
|
leaking a process on every Windows spawn test. taskkill walks the tree.
|
||||||
|
"""
|
||||||
|
flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) # no console flash from the GUI exe
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
subprocess.run(
|
||||||
|
["taskkill", "/PID", str(pid), "/T", "/F"],
|
||||||
|
capture_output=True,
|
||||||
|
creationflags=flags,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def spawn_test(data: dict, timeout: float = 3.0) -> dict:
|
def spawn_test(data: dict, timeout: float = 3.0) -> dict:
|
||||||
"""
|
"""
|
||||||
Attempt to start a stdio server and observe it for `timeout` seconds.
|
Attempt to start a stdio server and observe it for `timeout` seconds.
|
||||||
@@ -1591,12 +1608,29 @@ def spawn_test(data: dict, timeout: float = 3.0) -> dict:
|
|||||||
"crashed" — exited with a non-zero code before timeout
|
"crashed" — exited with a non-zero code before timeout
|
||||||
"not_found" — command could not be resolved to an executable
|
"not_found" — command could not be resolved to an executable
|
||||||
"not_applicable" — remote server or no command; nothing to spawn
|
"not_applicable" — remote server or no command; nothing to spawn
|
||||||
|
"error" — unexpected internal failure while spawning/observing
|
||||||
returncode: int | None
|
returncode: int | None
|
||||||
stderr: str (first ~4 KB)
|
stderr: str (first ~4 KB)
|
||||||
detail: str
|
detail: str
|
||||||
|
|
||||||
|
Never raises: the GUI threads (Test launch / Test all) re-enable their
|
||||||
|
buttons only when a result arrives, so an escaping exception would leave
|
||||||
|
the UI stuck. Anything unexpected comes back as outcome "error".
|
||||||
|
|
||||||
Run this off the UI thread — it blocks for up to `timeout` seconds.
|
Run this off the UI thread — it blocks for up to `timeout` seconds.
|
||||||
"""
|
"""
|
||||||
|
try:
|
||||||
|
return _spawn_test_impl(data, timeout)
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"outcome": "error",
|
||||||
|
"returncode": None,
|
||||||
|
"stderr": "",
|
||||||
|
"detail": f"unexpected error: {e!r}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _spawn_test_impl(data: dict, timeout: float) -> dict:
|
||||||
if "url" in data and "command" not in data:
|
if "url" in data and "command" not in data:
|
||||||
return {
|
return {
|
||||||
"outcome": "not_applicable",
|
"outcome": "not_applicable",
|
||||||
@@ -1605,7 +1639,9 @@ def spawn_test(data: dict, timeout: float = 3.0) -> dict:
|
|||||||
"detail": "remote server",
|
"detail": "remote server",
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd = (data.get("command") or "").strip()
|
# str() first: pasted JSON can legally carry a non-string here and the
|
||||||
|
# value never round-trips through the editor before a Test all run.
|
||||||
|
cmd = str(data.get("command") or "").strip()
|
||||||
if not cmd:
|
if not cmd:
|
||||||
return {
|
return {
|
||||||
"outcome": "not_applicable",
|
"outcome": "not_applicable",
|
||||||
@@ -1627,7 +1663,8 @@ def spawn_test(data: dict, timeout: float = 3.0) -> dict:
|
|||||||
args_list = [resolved_cmd] + [str(a) for a in (data.get("args") or [])]
|
args_list = [resolved_cmd] + [str(a) for a in (data.get("args") or [])]
|
||||||
|
|
||||||
merged_env = {**os.environ, "PATH": augmented_path()}
|
merged_env = {**os.environ, "PATH": augmented_path()}
|
||||||
merged_env.update(data.get("env") or {})
|
# Popen rejects non-string env values; pasted JSON may carry numbers.
|
||||||
|
merged_env.update({str(k): str(v) for k, v in (data.get("env") or {}).items()})
|
||||||
|
|
||||||
stderr_chunks: list[bytes] = []
|
stderr_chunks: list[bytes] = []
|
||||||
|
|
||||||
@@ -1655,6 +1692,10 @@ def spawn_test(data: dict, timeout: float = 3.0) -> dict:
|
|||||||
)
|
)
|
||||||
if os.name != "nt":
|
if os.name != "nt":
|
||||||
popen_kwargs["start_new_session"] = True # own process group → clean kill
|
popen_kwargs["start_new_session"] = True # own process group → clean kill
|
||||||
|
else:
|
||||||
|
# The packaged app is windowed (console=False); without this every
|
||||||
|
# spawn test of a console server flashes a console window.
|
||||||
|
popen_kwargs["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
proc = subprocess.Popen(args_list, **popen_kwargs)
|
proc = subprocess.Popen(args_list, **popen_kwargs)
|
||||||
@@ -1676,7 +1717,7 @@ def spawn_test(data: dict, timeout: float = 3.0) -> dict:
|
|||||||
if os.name != "nt":
|
if os.name != "nt":
|
||||||
os.killpg(os.getpgid(proc.pid), _signal.SIGKILL)
|
os.killpg(os.getpgid(proc.pid), _signal.SIGKILL)
|
||||||
else:
|
else:
|
||||||
proc.kill() # best-effort on Windows
|
_kill_process_tree_windows(proc.pid)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
with contextlib.suppress(subprocess.TimeoutExpired):
|
with contextlib.suppress(subprocess.TimeoutExpired):
|
||||||
@@ -1837,8 +1878,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 +1929,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 +1957,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.")
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "better-claude-config"
|
name = "better-claude-config"
|
||||||
version = "1.2.0"
|
version = "1.2.1"
|
||||||
description = "Cross-platform GUI for editing the mcpServers block of Claude Desktop and Claude Code configs"
|
description = "Cross-platform GUI for editing the mcpServers block of Claude Desktop and Claude Code configs"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
|
|||||||
+187
-44
@@ -3,9 +3,11 @@ proper test functions with tmp_path/monkeypatch fixtures)."""
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -227,8 +229,11 @@ def test_diagnostics_redacts_token_args():
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# 6. Dependency / PATH checking
|
# 6. Dependency / PATH checking
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
def test_dep_check_finds_python3():
|
def test_dep_check_finds_python():
|
||||||
assert c.check_dependency({"command": "python3"})["status"] == "ok"
|
# "python3" does not exist on a stock Windows install; "warn" (found only
|
||||||
|
# on an augmented PATH) still proves resolution works on a real machine.
|
||||||
|
cmd = "python" if os.name == "nt" else "python3"
|
||||||
|
assert c.check_dependency({"command": cmd})["status"] in ("ok", "warn")
|
||||||
|
|
||||||
|
|
||||||
def test_dep_check_flags_missing():
|
def test_dep_check_flags_missing():
|
||||||
@@ -326,6 +331,91 @@ def test_spawn_test_large_stderr_does_not_deadlock():
|
|||||||
assert len(r["stderr"]) <= c._STDERR_CAP
|
assert len(r["stderr"]) <= c._STDERR_CAP
|
||||||
|
|
||||||
|
|
||||||
|
def test_windows_tree_kill_uses_taskkill(monkeypatch):
|
||||||
|
"""The Windows kill path must walk the process tree (taskkill /T /F);
|
||||||
|
Popen.kill() alone orphans grandchildren (issue #13)."""
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_run(cmd, **kwargs):
|
||||||
|
calls.append(cmd)
|
||||||
|
return _FakeRC()
|
||||||
|
|
||||||
|
class _FakeRC:
|
||||||
|
returncode = 0
|
||||||
|
|
||||||
|
monkeypatch.setattr(c.subprocess, "run", fake_run)
|
||||||
|
c._kill_process_tree_windows(1234)
|
||||||
|
assert calls == [["taskkill", "/PID", "1234", "/T", "/F"]]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(os.name != "nt", reason="Windows-only: real process-tree kill")
|
||||||
|
def test_spawn_test_windows_kills_child_process_tree(tmp_path):
|
||||||
|
"""A spawn-tested parent that has its own child (npx -> node style) must
|
||||||
|
not leave the child running after the timeout kill (issue #13)."""
|
||||||
|
import subprocess as sp
|
||||||
|
import time as _t
|
||||||
|
|
||||||
|
pid_file = tmp_path / "child.pid"
|
||||||
|
parent_script = (
|
||||||
|
"import subprocess, sys, time\n"
|
||||||
|
"p = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)'])\n"
|
||||||
|
f"open({str(pid_file)!r}, 'w').write(str(p.pid))\n"
|
||||||
|
"time.sleep(60)\n"
|
||||||
|
)
|
||||||
|
r = c.spawn_test({"command": sys.executable, "args": ["-c", parent_script]}, timeout=2.0)
|
||||||
|
assert r["outcome"] == "ok" # parent was still running at timeout, then tree-killed
|
||||||
|
assert pid_file.is_file(), "parent never spawned its child"
|
||||||
|
child_pid = int(pid_file.read_text())
|
||||||
|
|
||||||
|
deadline = _t.time() + 5.0
|
||||||
|
alive = True
|
||||||
|
while _t.time() < deadline:
|
||||||
|
out = sp.run(
|
||||||
|
["tasklist", "/FI", f"PID eq {child_pid}"], capture_output=True, text=True
|
||||||
|
).stdout
|
||||||
|
alive = str(child_pid) in out
|
||||||
|
if not alive:
|
||||||
|
break
|
||||||
|
_t.sleep(0.25)
|
||||||
|
assert not alive, f"child pid {child_pid} survived the spawn-test kill"
|
||||||
|
|
||||||
|
|
||||||
|
def test_spawn_test_non_string_env_value():
|
||||||
|
# Pasted JSON can carry numeric env values ("env": {"PORT": 8080}) that never
|
||||||
|
# round-trip through the editor. Popen rejects non-str env; spawn_test must
|
||||||
|
# coerce instead of letting TypeError escape (which would leave the GUI's
|
||||||
|
# Test launch / Test all buttons stuck disabled).
|
||||||
|
r = c.spawn_test(
|
||||||
|
{
|
||||||
|
"command": sys.executable,
|
||||||
|
"args": ["-c", "import os; raise SystemExit(0 if os.environ['PORT'] == '8080' else 1)"],
|
||||||
|
"env": {"PORT": 8080},
|
||||||
|
},
|
||||||
|
timeout=2.0,
|
||||||
|
)
|
||||||
|
assert r["outcome"] == "exited"
|
||||||
|
assert r["returncode"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_spawn_test_non_string_command():
|
||||||
|
# A non-string command must classify, not raise (str(123) resolves to nothing).
|
||||||
|
r = c.spawn_test({"command": 123}, timeout=0.3)
|
||||||
|
assert r["outcome"] == "not_found"
|
||||||
|
|
||||||
|
|
||||||
|
def test_spawn_test_internal_error_yields_result(monkeypatch):
|
||||||
|
# Any unexpected exception inside the spawn path must come back as a result
|
||||||
|
# dict (outcome "error"), never escape — the UI only re-enables its buttons
|
||||||
|
# when a result arrives.
|
||||||
|
def boom(*a, **k):
|
||||||
|
raise RuntimeError("simulated internal failure")
|
||||||
|
|
||||||
|
monkeypatch.setattr(c.shutil, "which", boom)
|
||||||
|
r = c.spawn_test({"command": "python3"}, timeout=0.3)
|
||||||
|
assert r["outcome"] == "error"
|
||||||
|
assert "simulated internal failure" in r["detail"]
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# 8. Backup restore
|
# 8. Backup restore
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -934,11 +1024,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 +1038,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 +1069,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 +1083,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 +1138,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 +1151,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
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -1065,8 +1189,12 @@ def test_restart_claude_desktop_linux_popen_failure_reported(monkeypatch):
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
def test_dunder_version_matches_pyproject():
|
def test_dunder_version_matches_pyproject():
|
||||||
# Guards against the version drifting out of sync between the two places
|
# Guards against the version drifting out of sync between the two places
|
||||||
# a human might bump it.
|
# a human might bump it. Reads pyproject.toml rather than hard-coding a
|
||||||
assert c.__version__ == "1.2.0"
|
# version here (which would make this a third place to bump).
|
||||||
|
text = (Path(__file__).parent.parent / "pyproject.toml").read_text(encoding="utf-8")
|
||||||
|
m = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE)
|
||||||
|
assert m, "no [project] version found in pyproject.toml"
|
||||||
|
assert c.__version__ == m.group(1)
|
||||||
|
|
||||||
|
|
||||||
def test_parse_version_basic():
|
def test_parse_version_basic():
|
||||||
@@ -1326,3 +1454,18 @@ def test_health_from_spawn_result_failed_without_stderr_has_no_dash():
|
|||||||
}
|
}
|
||||||
_, summary = c.health_from_spawn_result(result)
|
_, summary = c.health_from_spawn_result(result)
|
||||||
assert "—" not in summary
|
assert "—" not in summary
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# App icon assets present (issue #20 — icons must exist to be bundled/loaded)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_app_icon_assets_present():
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
root = Path(c.__file__).resolve().parent
|
||||||
|
rounded = root / "icons" / "twin-gears" / "rounded"
|
||||||
|
# The window-icon builder in bcc.py loads these sizes; keep them present.
|
||||||
|
for size in (16, 32, 128, 256):
|
||||||
|
assert (rounded / f"icon-{size}.png").is_file(), f"missing icon-{size}.png"
|
||||||
|
assert (root / "icons" / "app.ico").is_file()
|
||||||
|
assert (root / "icons" / "app.icns").is_file()
|
||||||
|
|||||||
Reference in New Issue
Block a user