release: v1.2.0 — About+update checker, log viewer, restart button, dup-name fix, stale-file hardening #26
+101
-1
@@ -29,6 +29,7 @@ import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
CONFIG_FILENAME = "claude_desktop_config.json"
|
||||
@@ -135,6 +136,17 @@ def profile_from_path(path: str | os.PathLike) -> Profile:
|
||||
return Profile(label=label, path=p, config_exists=p.is_file())
|
||||
|
||||
|
||||
def profile_targets_claude_desktop(profile: Profile) -> bool:
|
||||
"""
|
||||
True when `profile` points at a Claude Desktop config
|
||||
(claude_desktop_config.json), as opposed to Claude Code (~/.claude.json
|
||||
or the legacy ~/.claude/settings.json). Used to gate Desktop-only actions
|
||||
like "Restart Claude Desktop" so they never show up for a Claude Code
|
||||
profile -- restarting the CLI makes no sense.
|
||||
"""
|
||||
return Path(profile.path).name == CONFIG_FILENAME
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Load / extract / apply
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -474,7 +486,7 @@ def _normalize_unicode(text: str, notes: list[str]) -> str:
|
||||
out = text
|
||||
for junk in _JUNK_CHARS:
|
||||
out = out.replace(junk, "")
|
||||
out = out.replace(" ", " ") # non-breaking space
|
||||
out = out.replace("\xa0", " ") # non-breaking space
|
||||
for smart, ascii_q in _QUOTE_MAP.items():
|
||||
out = out.replace(smart, ascii_q)
|
||||
if out != text:
|
||||
@@ -1459,3 +1471,91 @@ def pin_command_path(data: dict, path: str | None = None) -> tuple[dict, str | N
|
||||
out["args"] = args
|
||||
return out, f"'{c}' → {resolved}"
|
||||
return data, None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Restart Claude Desktop (issue #9)
|
||||
#
|
||||
# Scoped strictly to Claude DESKTOP, the GUI app -- never Claude Code (the
|
||||
# CLI), which has no long-running process to bounce. Callers should gate this
|
||||
# behind profile_targets_claude_desktop() before offering it in the UI.
|
||||
# --------------------------------------------------------------------------- #
|
||||
class RestartResult(NamedTuple):
|
||||
"""Outcome of a restart_claude_desktop() attempt."""
|
||||
|
||||
success: bool
|
||||
detail: str
|
||||
|
||||
|
||||
def _run_quiet(cmd: list[str]) -> None:
|
||||
"""Best-effort fire-and-forget command. Never raises: a nonzero exit (e.g.
|
||||
pkill finding nothing to kill) is expected and not an error."""
|
||||
with contextlib.suppress(OSError):
|
||||
subprocess.run(cmd, capture_output=True)
|
||||
|
||||
|
||||
def _restart_claude_desktop_macos() -> RestartResult:
|
||||
_run_quiet(["pkill", "-x", "Claude"])
|
||||
try:
|
||||
result = subprocess.run(["open", "-a", "Claude"], capture_output=True, text=True)
|
||||
except OSError as e:
|
||||
return RestartResult(False, f"Couldn't launch Claude Desktop: {e}")
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or result.stdout or "").strip() or "'open -a Claude' failed"
|
||||
return RestartResult(False, detail)
|
||||
return RestartResult(True, "Claude Desktop restarted.")
|
||||
|
||||
|
||||
def _claude_windows_start_menu_shortcut() -> Path:
|
||||
appdata = os.environ.get("APPDATA", str(Path.home()))
|
||||
return Path(appdata) / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Claude.lnk"
|
||||
|
||||
|
||||
def _restart_claude_desktop_windows() -> RestartResult:
|
||||
_run_quiet(["taskkill", "/IM", "Claude.exe", "/F"])
|
||||
shortcut = _claude_windows_start_menu_shortcut()
|
||||
try:
|
||||
# `cmd /c start "" <target>` launches detached, the same as double-clicking
|
||||
# the Start-menu shortcut, and returns immediately.
|
||||
result = subprocess.run(
|
||||
["cmd", "/c", "start", "", str(shortcut)], capture_output=True, text=True
|
||||
)
|
||||
except OSError as e:
|
||||
return RestartResult(False, f"Couldn't launch Claude Desktop: {e}")
|
||||
if result.returncode != 0:
|
||||
detail = (
|
||||
result.stderr or result.stdout or ""
|
||||
).strip() or "failed to relaunch Claude Desktop"
|
||||
return RestartResult(False, detail)
|
||||
return RestartResult(True, "Claude Desktop restarted.")
|
||||
|
||||
|
||||
def _restart_claude_desktop_linux() -> RestartResult:
|
||||
_run_quiet(["pkill", "claude"])
|
||||
try:
|
||||
# The Linux launcher is the app itself (no "open"-style helper), so it
|
||||
# has to be started detached rather than waited on.
|
||||
subprocess.Popen(
|
||||
["claude"],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
except OSError as e:
|
||||
return RestartResult(False, f"Couldn't launch Claude Desktop: {e}")
|
||||
return RestartResult(True, "Claude Desktop restarted.")
|
||||
|
||||
|
||||
def restart_claude_desktop() -> RestartResult:
|
||||
"""
|
||||
Kill and relaunch the Claude Desktop app so a freshly saved config takes
|
||||
effect. The app not currently running is NOT a failure -- pkill/taskkill
|
||||
exiting non-zero just means "nothing to kill", and we go straight to
|
||||
relaunching. Only a failed relaunch is reported as success=False.
|
||||
"""
|
||||
if sys.platform == "darwin":
|
||||
return _restart_claude_desktop_macos()
|
||||
if sys.platform.startswith("win"):
|
||||
return _restart_claude_desktop_windows()
|
||||
return _restart_claude_desktop_linux()
|
||||
|
||||
Reference in New Issue
Block a user