feat: detect MSIX-virtualized Claude config path + warn (#7) #29
@@ -1679,6 +1679,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())
|
||||
|
||||
+102
@@ -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\\<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]:
|
||||
"""
|
||||
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()))
|
||||
|
||||
@@ -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)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
Reference in New Issue
Block a user