28 Commits

Author SHA1 Message Date
the_og 9f535fb77f Merge pull request 'release: v1.2.1 — first shipped build with icon fix, audit fixes #32–#40, Windows tree-kill (#13)' (#51) from release/v1.2.1 into main
CI / Lint (ruff) (push) Successful in 9s
Build & Release / Build (Linux) (push) Successful in 1m0s
Build & Release / Build (Windows) (push) Successful in 58s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 9s
CI / Tests (py3.12 / windows-latest) (push) Successful in 20s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 8s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 8s
Build & Release / Build (macOS) (push) Successful in 1m43s
Build & Release / Publish Release (push) Successful in 9s
2026-07-12 13:33:26 -04:00
Cowork Supervisor 8cf19d43c4 test: version-sync test reads pyproject.toml instead of hard-coding
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 8s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 19s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 8s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 8s
CI / Lint (ruff) (pull_request) Successful in 7s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:32:23 -04:00
Cowork Supervisor 0ef4586698 release: bump version to 1.2.1
First release actually shipping the v1.2.0 feature set (the v1.2.0
tag's release run was cancelled and produced no assets) plus the
audit fixes #32-#40 and the Windows process-tree kill (#13).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:31:36 -04:00
the_og ed7c40cac9 Merge pull request 'fix: kill the whole process tree on Windows spawn-test timeout (#13)' (#50) from fix/13-windows-tree-kill into main
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 9s
CI / Tests (py3.12 / windows-latest) (push) Successful in 21s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 9s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 9s
2026-07-12 13:31:15 -04:00
Cowork Supervisor 1384ed9703 fix: kill the whole process tree on Windows spawn-test timeout (#13)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 8s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 20s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 9s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 8s
Popen.kill() only terminated the direct child, so runner-style commands
(npx -> node -> server) leaked the real server process on every Windows
spawn test. taskkill /PID <pid> /T /F walks the descendant tree. Also
sets CREATE_NO_WINDOW on the spawned test process so the windowed exe
doesn't flash a console per test.

Closes #13

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:30:02 -04:00
the_og 41891ddad4 Merge pull request 'fix: block duplicate env/header keys at entry instead of silently dropping one (#38)' (#49) from fix/38-duplicate-kv-keys into main
CI / Tests (py3.12 / windows-latest) (push) Successful in 18s
CI / Lint (ruff) (push) Successful in 13s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 12s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 13s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 12s
2026-07-12 13:06:54 -04:00
the_og 408f517c5d Merge pull request 'fix: pop old AND new name from health cache on rename (#36)' (#48) from fix/36-health-cache-rename into main
CI / Tests (py3.12 / windows-latest) (push) Successful in 17s
CI / Lint (ruff) (push) Successful in 11s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 14s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 11s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 13s
2026-07-12 13:06:48 -04:00
the_og 9036729cd8 Merge pull request 'fix: MSIX warning gets a persistent banner instead of the status bar (#35)' (#47) from fix/35-msix-banner into main
CI / Tests (py3.12 / windows-latest) (push) Successful in 18s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 14s
CI / Lint (ruff) (push) Successful in 9s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 12s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 14s
2026-07-12 13:06:41 -04:00
the_og 29a08e9532 Merge pull request 'ci: test on windows + python 3.13 (#40)' (#46) from ci/40-test-matrix into main
CI / Tests (py3.12 / windows-latest) (push) Successful in 18s
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 10s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 12s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 10s
2026-07-12 13:06:34 -04:00
Cowork Supervisor 87303809b8 test: dep-check probe command must exist on Windows too
CI / Lint (ruff) (pull_request) Successful in 6s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 17s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 8s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 8s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 8s
python3 is not a command on a stock Windows install; caught by the new
windows-latest CI job. Probe 'python' there and accept warn (found on
augmented PATH) as proof of resolution.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:05:04 -04:00
Cowork Supervisor 42456f25d2 fix: block duplicate env/header keys at entry instead of silently dropping one (#38)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 9s
CI / Tests (py3.12) (pull_request) Successful in 9s
Closes #38

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:04:49 -04:00
Cowork Supervisor 2d274b9e03 fix: MSIX warning gets a persistent banner instead of the status bar (#35)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 10s
CI / Tests (py3.12) (pull_request) Successful in 9s
The status bar is rewritten on every action, so the appended MSIX
warning vanished on first interaction. A dedicated warn banner under
the top bar stays visible.

Closes #35

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:03:57 -04:00
Cowork Supervisor 5c476bb13f fix: pop old AND new name from health cache on rename (#36)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 9s
CI / Tests (py3.12) (pull_request) Successful in 10s
Closes #36

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:03:29 -04:00
Cowork Supervisor 62c8a2ea65 ci: use host py launcher for Windows tests (runner blocks setup-python)
CI / Tests (py3.12 / windows-latest) (pull_request) Failing after 20s
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 9s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 8s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 8s
The self-hosted Windows runner's PowerShell execution policy rejects
setup-python's install script, so Windows mirrors release.yml: py -3.12
+ venv (the version the release binaries ship with). Linux keeps the
full 3.10/3.12/3.13 setup-python matrix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:02:09 -04:00
the_og b485357cd5 Merge pull request 'fix: import every dropped .json (aggregate counts) + 5 MB size guard (#39)' (#45) from fix/39-drop-import-all into main
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10) (push) Successful in 9s
CI / Tests (py3.12) (push) Successful in 9s
2026-07-12 13:00:43 -04:00
the_og 6a91f830dc Merge pull request 'fix: only advance update/lastCheck after a completed check (#37)' (#44) from fix/37-update-lastcheck into main
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10) (push) Successful in 9s
CI / Tests (py3.12) (push) Successful in 10s
2026-07-12 13:00:36 -04:00
the_og 6dacc26057 Merge pull request 'fix: harden restart_claude_desktop — Linux refusal, macOS quit-wait, Windows MSIX guard (#33)' (#43) from fix/33-restart-hardening into main
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10) (push) Successful in 9s
CI / Tests (py3.12) (push) Successful in 9s
2026-07-12 13:00:30 -04:00
the_og 1087fc84d1 Merge pull request 'fix: keep UpdateCheckWorker alive until its thread finishes (#32)' (#42) from fix/32-about-worker-lifetime into main
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10) (push) Successful in 8s
CI / Tests (py3.12) (push) Successful in 8s
2026-07-12 13:00:22 -04:00
the_og 5df364fb2e Merge pull request 'fix: spawn_test never raises — str-coerce env/command, wrap unexpected errors (#34)' (#41) from fix/34-spawn-test-env into main
CI / Lint (ruff) (push) Successful in 8s
CI / Tests (py3.10) (push) Successful in 8s
CI / Tests (py3.12) (push) Successful in 8s
2026-07-12 13:00:14 -04:00
Cowork Supervisor c7b2c90518 fix: spawn_test never raises — coerce env/command to str, wrap unexpected errors (#34)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 8s
CI / Tests (py3.12) (pull_request) Successful in 8s
Closes #34

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 12:59:19 -04:00
Cowork Supervisor c493aa0c84 fix: harden restart_claude_desktop across platforms (#33)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 9s
CI / Tests (py3.12) (pull_request) Successful in 10s
- Linux (and any non-desktop platform): refuse instead of 'pkill claude',
  which substring-matched running Claude Code CLI sessions and relaunched
  the CLI, not a desktop app. New restart_supported() gates the button.
- macOS: wait (<=5s) for the old instance to exit before 'open -a Claude'
  so the relaunch can't re-activate the dying process. Runs off the UI
  thread via a RestartWorker.
- Windows: verify the Start-menu shortcut exists BEFORE taskkill, so an
  MSIX/Store install is never killed without a relaunch path.

Closes #33

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 12:58:15 -04:00
Cowork Supervisor bb355dac31 fix: keep UpdateCheckWorker alive until its thread finishes (#32)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 8s
CI / Tests (py3.12) (pull_request) Successful in 8s
Closing the About dialog mid-check GC'd the dialog and the running
QThread with it -> 'QThread: Destroyed while thread is still running'.
A class-level keepalive set now holds each worker until finished;
stale results to a destroyed receiver are dropped by Qt.

Closes #32

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 12:53:12 -04:00
Cowork Supervisor d95db2b026 fix: import every dropped .json (aggregate counts) + 5 MB size guard (#39)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 8s
CI / Tests (py3.12) (pull_request) Successful in 9s
Closes #39

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 12:47:30 -04:00
Cowork Supervisor 42963f98b4 ci: test on windows + python 3.13 (#40)
CI / Tests (py3.10 / windows-latest) (pull_request) Failing after 24s
CI / Tests (py3.12 / windows-latest) (pull_request) Failing after 14s
CI / Tests (py3.13 / windows-latest) (pull_request) Failing after 10s
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 8s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 9s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 27s
Closes #40

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 12:47:14 -04:00
Cowork Supervisor f5c9780948 fix: only advance update/lastCheck after a completed check (#37)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 9s
CI / Tests (py3.12) (pull_request) Successful in 8s
Closes #37

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 12:46:59 -04:00
the_og 06326e5e9d fix: app icon in packaged builds (#31, closes #20)
CI / Lint (ruff) (push) Successful in 9s
CI / Tests (py3.10) (push) Successful in 8s
CI / Tests (py3.12) (push) Successful in 8s
2026-07-08 12:00:52 -04:00
Cowork Supervisor 6d91c709a7 fix: bundle icons + set app window icon and Windows AppUserModelID (#20)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 9s
CI / Tests (py3.12) (pull_request) Successful in 10s
2026-07-08 11:59:57 -04:00
the_og 3b5379a2b8 feat: server search/filter + test-all health column (#30, closes #27 #28)
CI / Lint (ruff) (push) Successful in 6s
CI / Tests (py3.10) (push) Successful in 8s
CI / Tests (py3.12) (push) Successful in 8s
2026-07-08 11:56:37 -04:00
6 changed files with 495 additions and 102 deletions
+20 -4
View File
@@ -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
+188 -25
View File
@@ -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_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.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
if not undo_pushed:
self._push_undo() 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()
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()