Merge remote-tracking branch 'origin/main' into feat/server-list-ux
This commit is contained in:
@@ -1704,6 +1704,22 @@ class MainWindow(QMainWindow):
|
|||||||
self.load_profile(self.profiles[0])
|
self.load_profile(self.profiles[0])
|
||||||
else:
|
else:
|
||||||
self.status.setText('No Claude installs found. Use "Add config…" to point at one.')
|
self.status.setText('No Claude installs found. Use "Add config…" to point at one.')
|
||||||
|
self._maybe_warn_msix()
|
||||||
|
|
||||||
|
def _maybe_warn_msix(self):
|
||||||
|
"""
|
||||||
|
Windows-only, no-op everywhere else: if Claude Desktop looks like an
|
||||||
|
MSIX/Store install with a virtualized config, append a warning to the
|
||||||
|
status bar so edits to the plain %APPDATA% path aren't silently lost.
|
||||||
|
Defensive on purpose -- this must never block startup or profile load.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
warning = core.msix_warning_text()
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
if warning:
|
||||||
|
self.status.setText(f"{self.status.text()} ⚠ {warning}")
|
||||||
|
self.status.setToolTip(warning)
|
||||||
|
|
||||||
def add_custom_config(self):
|
def add_custom_config(self):
|
||||||
start = str(core.app_support_base())
|
start = str(core.app_support_base())
|
||||||
|
|||||||
+102
@@ -191,6 +191,97 @@ def app_support_base() -> Path:
|
|||||||
return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
||||||
|
|
||||||
|
|
||||||
|
def msix_config_paths(localappdata: str | os.PathLike | None = None) -> list[Path]:
|
||||||
|
"""
|
||||||
|
Find MSIX/Store-packaged Claude Desktop configs.
|
||||||
|
|
||||||
|
When Claude Desktop is installed from the Microsoft Store (MSIX), Windows
|
||||||
|
virtualizes its filesystem writes to a per-package folder under
|
||||||
|
`%LOCALAPPDATA%\\Packages\\<PackageFamilyName>\\LocalCache\\Roaming\\Claude\\`
|
||||||
|
instead of the normal `%APPDATA%\\Claude\\`. A user (or BCC) editing the
|
||||||
|
plain %APPDATA% path can end up changing a file the running app never
|
||||||
|
reads -- see anthropics/claude-code issues #26073, #29100, #38830.
|
||||||
|
|
||||||
|
Globs `<localappdata>/Packages/*Claude*/LocalCache/Roaming/Claude/
|
||||||
|
claude_desktop_config.json` and returns every match that actually exists,
|
||||||
|
sorted for determinism. `localappdata` defaults to the %LOCALAPPDATA% env
|
||||||
|
var (falling back to the usual Windows path) but is accepted as a
|
||||||
|
parameter so this is unit-testable with tmp_path on any platform.
|
||||||
|
|
||||||
|
This function itself is platform-independent (it just globs whatever
|
||||||
|
directory it's given); callers that care about the *current* machine
|
||||||
|
should gate on sys.platform -- see `detect_msix_claude`.
|
||||||
|
"""
|
||||||
|
base = (
|
||||||
|
Path(localappdata)
|
||||||
|
if localappdata is not None
|
||||||
|
else Path(os.environ.get("LOCALAPPDATA", str(Path.home() / "AppData" / "Local")))
|
||||||
|
)
|
||||||
|
packages = base / "Packages"
|
||||||
|
if not packages.is_dir():
|
||||||
|
return []
|
||||||
|
out: list[Path] = []
|
||||||
|
for pkg_dir in sorted(packages.glob("*Claude*")):
|
||||||
|
cfg = pkg_dir / "LocalCache" / "Roaming" / "Claude" / CONFIG_FILENAME
|
||||||
|
if cfg.is_file():
|
||||||
|
out.append(cfg)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def detect_msix_claude(
|
||||||
|
appdata: str | os.PathLike | None = None,
|
||||||
|
localappdata: str | os.PathLike | None = None,
|
||||||
|
) -> Path | None:
|
||||||
|
"""
|
||||||
|
Best-effort detection of an MSIX-virtualized Claude Desktop install.
|
||||||
|
|
||||||
|
Returns the first virtualized `claude_desktop_config.json` found (see
|
||||||
|
`msix_config_paths`), or None when not running on Windows, no matching
|
||||||
|
package folder exists, or a package folder exists but has no config file
|
||||||
|
written yet. The sys.platform gate makes this a safe no-op to call
|
||||||
|
unconditionally from discovery/diagnostics code on macOS/Linux.
|
||||||
|
|
||||||
|
`appdata`/`localappdata` are threaded through (rather than read straight
|
||||||
|
from os.environ) purely so the whole detection path is unit-testable via
|
||||||
|
tmp_path + monkeypatch without mutating real env vars.
|
||||||
|
"""
|
||||||
|
if not sys.platform.startswith("win"):
|
||||||
|
return None
|
||||||
|
hits = msix_config_paths(localappdata)
|
||||||
|
return hits[0] if hits else None
|
||||||
|
|
||||||
|
|
||||||
|
def msix_warning_text(
|
||||||
|
appdata: str | os.PathLike | None = None,
|
||||||
|
localappdata: str | os.PathLike | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
"""
|
||||||
|
A one-line, paste-safe warning for the diagnostics/status surface when
|
||||||
|
Claude Desktop looks like an MSIX/Store install whose real config lives
|
||||||
|
somewhere other than the plain %APPDATA%\\Claude\\ path. Returns None
|
||||||
|
when nothing was detected (including on non-Windows platforms) or when
|
||||||
|
the virtualized path and the plain path happen to coincide -- i.e. there
|
||||||
|
is nothing surprising to warn about. Contains only filesystem paths, no
|
||||||
|
env values or secrets.
|
||||||
|
"""
|
||||||
|
real = detect_msix_claude(appdata, localappdata)
|
||||||
|
if real is None:
|
||||||
|
return None
|
||||||
|
plain_base = (
|
||||||
|
Path(appdata)
|
||||||
|
if appdata is not None
|
||||||
|
else Path(os.environ.get("APPDATA", str(Path.home() / "AppData" / "Roaming")))
|
||||||
|
)
|
||||||
|
plain_cfg = plain_base / "Claude" / CONFIG_FILENAME
|
||||||
|
if plain_cfg == real:
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
"Claude Desktop looks like it's installed from the Microsoft Store (MSIX). "
|
||||||
|
f"Windows virtualizes its config, so edits to {plain_cfg} may be silently "
|
||||||
|
f"ignored by the running app. The real config is at: {real}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def discover_profiles() -> list[Profile]:
|
def discover_profiles() -> list[Profile]:
|
||||||
"""
|
"""
|
||||||
Find every `Claude*` data directory in the platform's app-support base
|
Find every `Claude*` data directory in the platform's app-support base
|
||||||
@@ -198,6 +289,11 @@ def discover_profiles() -> list[Profile]:
|
|||||||
|
|
||||||
Claude Desktop: scans the platform app-support folder for any `Claude*`
|
Claude Desktop: scans the platform app-support folder for any `Claude*`
|
||||||
directory (catches `Claude`, `Claude-Work`, etc.).
|
directory (catches `Claude`, `Claude-Work`, etc.).
|
||||||
|
Windows/MSIX: if Claude Desktop was installed from the Microsoft Store,
|
||||||
|
its real config lives in a virtualized per-package folder rather than the
|
||||||
|
plain %APPDATA%\\Claude\\ path above (see `detect_msix_claude`); when
|
||||||
|
that's detected, it's surfaced here as its own profile so the user can
|
||||||
|
edit the file the app actually reads.
|
||||||
Claude Code: user-scope MCP servers live in ~/.claude.json (that's what
|
Claude Code: user-scope MCP servers live in ~/.claude.json (that's what
|
||||||
`claude mcp add` writes; project scope is a per-repo .mcp.json, which can
|
`claude mcp add` writes; project scope is a per-repo .mcp.json, which can
|
||||||
be opened via 'Add config…'). NOT ~/.claude/settings.json — that file is
|
be opened via 'Add config…'). NOT ~/.claude/settings.json — that file is
|
||||||
@@ -213,6 +309,12 @@ def discover_profiles() -> list[Profile]:
|
|||||||
cfg = d / CONFIG_FILENAME
|
cfg = d / CONFIG_FILENAME
|
||||||
out.append(Profile(label=d.name, path=cfg, config_exists=cfg.is_file()))
|
out.append(Profile(label=d.name, path=cfg, config_exists=cfg.is_file()))
|
||||||
|
|
||||||
|
msix_cfg = detect_msix_claude()
|
||||||
|
if msix_cfg is not None and str(msix_cfg) not in {str(p.path) for p in out}:
|
||||||
|
out.append(
|
||||||
|
Profile(label="Claude (Microsoft Store / MSIX)", path=msix_cfg, config_exists=True)
|
||||||
|
)
|
||||||
|
|
||||||
home = Path.home()
|
home = Path.home()
|
||||||
cc_cfg = home / ".claude.json"
|
cc_cfg = home / ".claude.json"
|
||||||
out.append(Profile(label="Claude Code", path=cc_cfg, config_exists=cc_cfg.is_file()))
|
out.append(Profile(label="Claude Code", path=cc_cfg, config_exists=cc_cfg.is_file()))
|
||||||
|
|||||||
@@ -758,6 +758,152 @@ def test_server_log_path_unsupported_platform_returns_none(tmp_path, monkeypatch
|
|||||||
assert c.server_log_path("brave-search") is None
|
assert c.server_log_path("brave-search") is None
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# MSIX-virtualized Claude Desktop config detection (issue #7)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _make_msix_pkg(localappdata, pkg_name="AnthropicClaude_abc123xyz", with_config=True):
|
||||||
|
cfg_dir = localappdata / "Packages" / pkg_name / "LocalCache" / "Roaming" / "Claude"
|
||||||
|
cfg_dir.mkdir(parents=True)
|
||||||
|
cfg_path = cfg_dir / c.CONFIG_FILENAME
|
||||||
|
if with_config:
|
||||||
|
cfg_path.write_text('{"mcpServers": {}}')
|
||||||
|
return cfg_path
|
||||||
|
|
||||||
|
|
||||||
|
def test_msix_config_paths_finds_package_config(tmp_path):
|
||||||
|
local = tmp_path / "Local"
|
||||||
|
expected = _make_msix_pkg(local)
|
||||||
|
assert c.msix_config_paths(local) == [expected]
|
||||||
|
|
||||||
|
|
||||||
|
def test_msix_config_paths_ignores_non_claude_packages(tmp_path):
|
||||||
|
local = tmp_path / "Local"
|
||||||
|
(local / "Packages" / "SomeOtherApp_xyz" / "LocalCache" / "Roaming").mkdir(parents=True)
|
||||||
|
assert c.msix_config_paths(local) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_msix_config_paths_empty_when_package_dir_has_no_config_yet(tmp_path):
|
||||||
|
local = tmp_path / "Local"
|
||||||
|
_make_msix_pkg(local, with_config=False)
|
||||||
|
assert c.msix_config_paths(local) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_msix_config_paths_empty_when_no_packages_dir(tmp_path):
|
||||||
|
assert c.msix_config_paths(tmp_path / "Local") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_msix_config_paths_uses_localappdata_env_by_default(tmp_path, monkeypatch):
|
||||||
|
local = tmp_path / "Local"
|
||||||
|
expected = _make_msix_pkg(local)
|
||||||
|
monkeypatch.setenv("LOCALAPPDATA", str(local))
|
||||||
|
assert c.msix_config_paths() == [expected]
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_msix_claude_returns_none_on_non_windows(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(c.sys, "platform", "darwin")
|
||||||
|
local = tmp_path / "Local"
|
||||||
|
_make_msix_pkg(local)
|
||||||
|
assert c.detect_msix_claude(localappdata=local) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_msix_claude_finds_virtualized_config_on_windows(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(c.sys, "platform", "win32")
|
||||||
|
local = tmp_path / "Local"
|
||||||
|
expected = _make_msix_pkg(local)
|
||||||
|
assert c.detect_msix_claude(localappdata=local) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_msix_claude_none_when_nothing_present_on_windows(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(c.sys, "platform", "win32")
|
||||||
|
local = tmp_path / "Local"
|
||||||
|
assert c.detect_msix_claude(localappdata=local) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_msix_warning_text_none_on_non_windows(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(c.sys, "platform", "darwin")
|
||||||
|
local = tmp_path / "Local"
|
||||||
|
_make_msix_pkg(local)
|
||||||
|
assert c.msix_warning_text(localappdata=local) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_msix_warning_text_none_when_nothing_detected(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(c.sys, "platform", "win32")
|
||||||
|
local = tmp_path / "Local"
|
||||||
|
assert c.msix_warning_text(localappdata=local) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_msix_warning_text_warns_when_virtualized_path_differs(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(c.sys, "platform", "win32")
|
||||||
|
roaming = tmp_path / "Roaming"
|
||||||
|
local = tmp_path / "Local"
|
||||||
|
real = _make_msix_pkg(local)
|
||||||
|
warning = c.msix_warning_text(appdata=roaming, localappdata=local)
|
||||||
|
assert warning is not None
|
||||||
|
assert "Microsoft Store" in warning
|
||||||
|
assert str(real) in warning
|
||||||
|
assert str(roaming / "Claude" / c.CONFIG_FILENAME) in warning
|
||||||
|
|
||||||
|
|
||||||
|
def test_msix_warning_text_none_when_plain_and_virtualized_paths_match(tmp_path, monkeypatch):
|
||||||
|
# Degenerate case: if the "plain" APPDATA base is pointed at the exact
|
||||||
|
# same tree the MSIX scan found (e.g. a symlink setup), there's nothing
|
||||||
|
# surprising to warn about.
|
||||||
|
monkeypatch.setattr(c.sys, "platform", "win32")
|
||||||
|
local = tmp_path / "Local"
|
||||||
|
pkg_claude_dir = local / "Packages" / "AnthropicClaude_abc123xyz" / "LocalCache" / "Roaming"
|
||||||
|
pkg_claude_dir.mkdir(parents=True)
|
||||||
|
(pkg_claude_dir / "Claude").mkdir()
|
||||||
|
(pkg_claude_dir / "Claude" / c.CONFIG_FILENAME).write_text("{}")
|
||||||
|
warning = c.msix_warning_text(appdata=pkg_claude_dir, localappdata=local)
|
||||||
|
assert warning is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_discover_profiles_adds_msix_profile_on_windows(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(c.sys, "platform", "win32")
|
||||||
|
base = tmp_path / "AppSupport"
|
||||||
|
base.mkdir()
|
||||||
|
monkeypatch.setattr(c, "app_support_base", lambda: base)
|
||||||
|
monkeypatch.setattr(c.Path, "home", lambda: tmp_path / "home")
|
||||||
|
(tmp_path / "home" / ".claude").mkdir(parents=True)
|
||||||
|
local = tmp_path / "Local"
|
||||||
|
real = _make_msix_pkg(local)
|
||||||
|
monkeypatch.setenv("LOCALAPPDATA", str(local))
|
||||||
|
|
||||||
|
profs = c.discover_profiles()
|
||||||
|
msix = [p for p in profs if "MSIX" in p.label]
|
||||||
|
assert len(msix) == 1
|
||||||
|
assert msix[0].path == real
|
||||||
|
assert msix[0].config_exists
|
||||||
|
|
||||||
|
|
||||||
|
def test_discover_profiles_no_msix_profile_when_nothing_detected(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(c.sys, "platform", "win32")
|
||||||
|
base = tmp_path / "AppSupport"
|
||||||
|
base.mkdir()
|
||||||
|
monkeypatch.setattr(c, "app_support_base", lambda: base)
|
||||||
|
monkeypatch.setattr(c.Path, "home", lambda: tmp_path / "home")
|
||||||
|
(tmp_path / "home" / ".claude").mkdir(parents=True)
|
||||||
|
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path / "Local"))
|
||||||
|
|
||||||
|
profs = c.discover_profiles()
|
||||||
|
assert not any("MSIX" in p.label for p in profs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_discover_profiles_no_msix_profile_on_non_windows(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(c.sys, "platform", "darwin")
|
||||||
|
base = tmp_path / "AppSupport"
|
||||||
|
base.mkdir()
|
||||||
|
monkeypatch.setattr(c, "app_support_base", lambda: base)
|
||||||
|
monkeypatch.setattr(c.Path, "home", lambda: tmp_path / "home")
|
||||||
|
(tmp_path / "home" / ".claude").mkdir(parents=True)
|
||||||
|
local = tmp_path / "Local"
|
||||||
|
_make_msix_pkg(local)
|
||||||
|
monkeypatch.setenv("LOCALAPPDATA", str(local))
|
||||||
|
|
||||||
|
profs = c.discover_profiles()
|
||||||
|
assert not any("MSIX" in p.label for p in profs)
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# restart_claude_desktop / profile_targets_claude_desktop (issue #9)
|
# restart_claude_desktop / profile_targets_claude_desktop (issue #9)
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
Reference in New Issue
Block a user