diff --git a/bcc.py b/bcc.py index ed7fc0f..6f33f93 100644 --- a/bcc.py +++ b/bcc.py @@ -1704,6 +1704,22 @@ class MainWindow(QMainWindow): self.load_profile(self.profiles[0]) else: 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): start = str(core.app_support_base()) diff --git a/bcc_core.py b/bcc_core.py index 317e784..7a8574f 100644 --- a/bcc_core.py +++ b/bcc_core.py @@ -191,6 +191,97 @@ def app_support_base() -> Path: 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\\\\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 `/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]: """ 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*` 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 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 @@ -213,6 +309,12 @@ def discover_profiles() -> list[Profile]: cfg = d / CONFIG_FILENAME 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() cc_cfg = home / ".claude.json" out.append(Profile(label="Claude Code", path=cc_cfg, config_exists=cc_cfg.is_file())) diff --git a/tests/test_core.py b/tests/test_core.py index 6c608be..578387b 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -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 +# --------------------------------------------------------------------------- # +# 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) # --------------------------------------------------------------------------- #