diff --git a/README.md b/README.md
index bdb4ba6..5755f63 100644
--- a/README.md
+++ b/README.md
@@ -50,7 +50,9 @@ combined selection looks tight for your RAM) and Stable Audio 3 (generated pads
and tracks) land in the app-owned `~/Library/Application Support/LSDJ`
(`MAGENTA_HOME` / `SA3_MLX_HOME` override the locations). `just migrate-models`
relocates an existing install—including one under the previous app name—into
-that folder without re-downloading.
+that folder without re-downloading. The Rust host owns the cross-platform
+config/data/cache/assets/staging mapping and passes it explicitly to Python; see
+[the platform path contract](docs/platform-paths.md).
## Run
diff --git a/backend/lsdj/loras.py b/backend/lsdj/loras.py
index 951c00d..05afd0e 100644
--- a/backend/lsdj/loras.py
+++ b/backend/lsdj/loras.py
@@ -1,9 +1,9 @@
"""Stable Audio 3 LoRA adapter registry — the read side (issue #66, ADR-0028).
-Adapters live on disk under the app-owned data dir, one directory per
-adapter, organised by the DiT family they ride:
+Adapters live under the host-resolved asset root, one directory per adapter,
+organised by the DiT family they ride:
- ~/Library/Application Support/LSDJ/sa3-loras///
+ $SA3_LORAS_HOME///
``base`` is ``small`` (the 1024-wide sm-sfx / sm-music DiTs) or ``medium``
(the 1536-wide track DiT). An adapter directory holds its ``.safetensors``
@@ -18,6 +18,8 @@
import pathlib
import re
+from . import runtime_paths
+
# The two DiT families an adapter can ride, and which generation kind uses
# which. sm-sfx and sm-music share one architecture, so a "small" adapter
# applies to both kinds; the medium DiT is the track engine (sa3.KINDS).
@@ -47,15 +49,13 @@ class UnknownAdapter(Exception):
def loras_dir(
env: dict | None = None, home: pathlib.Path | None = None
) -> pathlib.Path:
- """The registry root. $SA3_LORAS_HOME wins (tests, dev overrides);
- otherwise the app-owned data dir, beside the SA3 checkout. Mirrors the
- Rust `loras::loras_dir`."""
+ """The registry root explicitly supplied by the Rust host."""
env = os.environ if env is None else env
- home = pathlib.Path.home() if home is None else home
- override = env.get("SA3_LORAS_HOME", "")
- if override:
- return pathlib.Path(override).expanduser()
- return home / "Library" / "Application Support" / "LSDJ" / "sa3-loras"
+ del home # retained for API compatibility; platform paths come from Rust.
+ root = runtime_paths.loras_home(env)
+ if root is None:
+ raise RuntimeError("LSDJ asset roots were not supplied by the desktop host")
+ return root
def _adapter_file(adapter_dir: pathlib.Path) -> pathlib.Path | None:
diff --git a/backend/lsdj/runtime_paths.py b/backend/lsdj/runtime_paths.py
new file mode 100644
index 0000000..0648c45
--- /dev/null
+++ b/backend/lsdj/runtime_paths.py
@@ -0,0 +1,63 @@
+"""Filesystem roots supplied by the native Rust host.
+
+The desktop host is the only component that knows platform conventions. Python
+services consume these explicit values and never reconstruct Windows, XDG, or
+macOS locations from a user home directory. Compatibility variables remain for
+the current upstream runtimes, but they are populated by the same Rust contract.
+"""
+
+import os
+import pathlib
+import sys
+from collections.abc import Mapping
+
+
+def _path(env: Mapping[str, str], name: str) -> pathlib.Path | None:
+ value = env.get(name, "")
+ return pathlib.Path(value) if value else None
+
+
+def config_home(env: Mapping[str, str] | None = None) -> pathlib.Path | None:
+ return _path(os.environ if env is None else env, "LSDJ_CONFIG_HOME")
+
+
+def data_home(env: Mapping[str, str] | None = None) -> pathlib.Path | None:
+ return _path(os.environ if env is None else env, "LSDJ_DATA_HOME")
+
+
+def cache_home(env: Mapping[str, str] | None = None) -> pathlib.Path | None:
+ return _path(os.environ if env is None else env, "LSDJ_CACHE_HOME")
+
+
+def assets_home(env: Mapping[str, str] | None = None) -> pathlib.Path | None:
+ return _path(os.environ if env is None else env, "LSDJ_ASSETS_HOME")
+
+
+def staging_home(env: Mapping[str, str] | None = None) -> pathlib.Path | None:
+ return _path(os.environ if env is None else env, "LSDJ_STAGING_HOME")
+
+
+def sa3_home(env: Mapping[str, str] | None = None) -> pathlib.Path | None:
+ env = os.environ if env is None else env
+ override = _path(env, "SA3_MLX_HOME")
+ if override is not None:
+ return override
+ assets = assets_home(env)
+ return None if assets is None else assets / "stable-audio-3"
+
+
+def loras_home(env: Mapping[str, str] | None = None) -> pathlib.Path | None:
+ env = os.environ if env is None else env
+ override = _path(env, "SA3_LORAS_HOME")
+ if override is not None:
+ return override
+ assets = assets_home(env)
+ return None if assets is None else assets / "sa3-loras"
+
+
+def venv_python(venv: pathlib.Path, *, platform: str | None = None) -> pathlib.Path:
+ """Return a venv interpreter as a path/argv item, never a shell string."""
+ platform = sys.platform if platform is None else platform
+ if platform == "win32":
+ return venv / "Scripts" / "python.exe"
+ return venv / "bin" / "python"
diff --git a/backend/lsdj/sa3.py b/backend/lsdj/sa3.py
index 873c2c1..c2d2e30 100644
--- a/backend/lsdj/sa3.py
+++ b/backend/lsdj/sa3.py
@@ -14,6 +14,8 @@
import tempfile
from collections.abc import Sequence
+from . import runtime_paths
+
# CLI vocabulary of scripts/sa3_mlx.py at the pinned commit (sa3-pin.json).
# Pads use the small DiTs with the SAME-S decoder; tracks (M19, ADR-0013)
# the medium DiT, which pairs with SAME-L.
@@ -89,18 +91,14 @@ class GenerationFailed(Exception):
WARMED_STAMP = ".lsdj-warmed"
-def _checkout_candidates(env: dict, home: pathlib.Path) -> list[pathlib.Path]:
- """Checkout roots to probe, in order. $SA3_MLX_HOME wins (pointing at the
- checkout root); otherwise the app-owned data dir, where the in-app installer
- puts the checkout. Mirrors the Rust `models::sa3_candidates`."""
- candidates = []
- override = env.get("SA3_MLX_HOME", "")
- if override:
- candidates.append(pathlib.Path(override).expanduser())
- candidates.append(
- home / "Library" / "Application Support" / "LSDJ" / "stable-audio-3"
- )
- return candidates
+def _checkout_candidates(env: dict) -> list[pathlib.Path]:
+ """The checkout root explicitly supplied by the Rust host.
+
+ No platform fallback lives here: independently rebuilding a macOS/XDG/
+ Windows location is precisely how the two sides drifted before issue #107.
+ """
+ checkout = runtime_paths.sa3_home(env)
+ return [] if checkout is None else [checkout]
def resolve_mlx_dir(
@@ -108,10 +106,10 @@ def resolve_mlx_dir(
) -> pathlib.Path | None:
"""First checkout whose optimized/mlx has a venv and the CLI script."""
env = os.environ if env is None else env
- home = pathlib.Path.home() if home is None else home
- for checkout in _checkout_candidates(env, home):
+ del home # retained for API compatibility; platform paths come from Rust.
+ for checkout in _checkout_candidates(env):
mlx_dir = checkout / "optimized" / "mlx"
- python = mlx_dir / ".venv" / "bin" / "python"
+ python = runtime_paths.venv_python(mlx_dir / ".venv")
script = mlx_dir / "scripts" / "sa3_mlx.py"
if python.is_file() and script.is_file():
return mlx_dir
@@ -133,16 +131,16 @@ def readiness(env: dict | None = None, home: pathlib.Path | None = None) -> dict
Rust `model_status` mirrors this exact logic and these exact identifiers.
"""
env = os.environ if env is None else env
- home = pathlib.Path.home() if home is None else home
+ del home # retained for API compatibility; platform paths come from Rust.
first_with_mlx: tuple[pathlib.Path, pathlib.Path] | None = None
- for checkout in _checkout_candidates(env, home):
+ for checkout in _checkout_candidates(env):
mlx_dir = checkout / "optimized" / "mlx"
if not mlx_dir.is_dir():
continue
if first_with_mlx is None:
first_with_mlx = (checkout, mlx_dir)
- python = mlx_dir / ".venv" / "bin" / "python"
+ python = runtime_paths.venv_python(mlx_dir / ".venv")
script = mlx_dir / "scripts" / "sa3_mlx.py"
if not (python.is_file() and script.is_file()):
continue
@@ -191,7 +189,7 @@ async def generate(
with tempfile.TemporaryDirectory(prefix="sa3-") as tmp:
out_path = pathlib.Path(tmp) / "out.wav"
argv = [
- str(mlx_dir / ".venv" / "bin" / "python"),
+ str(runtime_paths.venv_python(mlx_dir / ".venv")),
str(mlx_dir / "scripts" / "sa3_mlx.py"),
"--prompt",
prompt,
diff --git a/backend/tests/test_loras.py b/backend/tests/test_loras.py
index 5debdcf..899dee7 100644
--- a/backend/tests/test_loras.py
+++ b/backend/tests/test_loras.py
@@ -28,12 +28,16 @@ def test_env_override_wins(self, tmp_path):
== tmp_path / "elsewhere"
)
- def test_defaults_to_the_app_support_home(self, tmp_path):
+ def test_uses_the_host_supplied_assets_home(self, tmp_path):
assert (
- loras.loras_dir(env={}, home=tmp_path)
- == tmp_path / "Library" / "Application Support" / "LSDJ" / "sa3-loras"
+ loras.loras_dir(env={"LSDJ_ASSETS_HOME": str(tmp_path / "assets")})
+ == tmp_path / "assets" / "sa3-loras"
)
+ def test_refuses_to_guess_a_platform_home(self):
+ with pytest.raises(RuntimeError, match="desktop host"):
+ loras.loras_dir(env={})
+
class TestResolve:
def test_resolves_an_installed_adapter(self, tmp_path):
diff --git a/backend/tests/test_runtime_paths.py b/backend/tests/test_runtime_paths.py
new file mode 100644
index 0000000..bff70f8
--- /dev/null
+++ b/backend/tests/test_runtime_paths.py
@@ -0,0 +1,50 @@
+"""The Rust→Python storage and executable-layout contract."""
+
+import pathlib
+
+from lsdj import runtime_paths
+
+
+def test_all_roots_preserve_spaces_and_non_ascii():
+ env = {
+ "LSDJ_CONFIG_HOME": "/profiles/DJ Name/音楽/config",
+ "LSDJ_DATA_HOME": "/profiles/DJ Name/音楽/data",
+ "LSDJ_CACHE_HOME": "/profiles/DJ Name/音楽/cache",
+ "LSDJ_ASSETS_HOME": "/profiles/DJ Name/音楽/assets",
+ "LSDJ_STAGING_HOME": "/profiles/DJ Name/音楽/staging",
+ }
+ assert runtime_paths.config_home(env) == pathlib.Path(env["LSDJ_CONFIG_HOME"])
+ assert runtime_paths.data_home(env) == pathlib.Path(env["LSDJ_DATA_HOME"])
+ assert runtime_paths.cache_home(env) == pathlib.Path(env["LSDJ_CACHE_HOME"])
+ assert runtime_paths.assets_home(env) == pathlib.Path(env["LSDJ_ASSETS_HOME"])
+ assert runtime_paths.staging_home(env) == pathlib.Path(env["LSDJ_STAGING_HOME"])
+ assert (
+ runtime_paths.sa3_home(env)
+ == pathlib.Path(env["LSDJ_ASSETS_HOME"]) / "stable-audio-3"
+ )
+ assert (
+ runtime_paths.loras_home(env)
+ == pathlib.Path(env["LSDJ_ASSETS_HOME"]) / "sa3-loras"
+ )
+
+
+def test_compatibility_overrides_win_without_home_guessing():
+ env = {
+ "LSDJ_ASSETS_HOME": "/host/assets",
+ "SA3_MLX_HOME": "/custom/SA 3",
+ "SA3_LORAS_HOME": "/custom/适配器",
+ }
+ assert runtime_paths.sa3_home(env) == pathlib.Path("/custom/SA 3")
+ assert runtime_paths.loras_home(env) == pathlib.Path("/custom/适配器")
+ assert runtime_paths.sa3_home({}) is None
+ assert runtime_paths.loras_home({}) is None
+
+
+def test_venv_interpreter_layout_is_platform_specific_and_structured():
+ venv = pathlib.Path("/profiles/DJ Name/模型/.venv")
+ assert (
+ runtime_paths.venv_python(venv, platform="win32")
+ == venv / "Scripts" / "python.exe"
+ )
+ assert runtime_paths.venv_python(venv, platform="linux") == venv / "bin" / "python"
+ assert runtime_paths.venv_python(venv, platform="darwin") == venv / "bin" / "python"
diff --git a/backend/tests/test_sa3.py b/backend/tests/test_sa3.py
index 8ddfa2c..4c6cc44 100644
--- a/backend/tests/test_sa3.py
+++ b/backend/tests/test_sa3.py
@@ -61,25 +61,20 @@ def test_env_override_wins(self, tmp_path):
)
assert resolved == mlx_dir
- def test_resolves_the_app_support_home(self, tmp_path):
- # In-app installs (and `just setup-sa3`) put the checkout in the app-owned
- # data dir — the only non-override candidate.
- mlx_dir = make_checkout(
- tmp_path / "Library" / "Application Support" / "LSDJ" / "stable-audio-3",
- SUCCESS_STUB,
- )
- assert sa3.resolve_mlx_dir(env={}, home=tmp_path) == mlx_dir
+ def test_resolves_the_host_supplied_assets_home(self, tmp_path):
+ assets = tmp_path / "DJ Name" / "模型 assets"
+ mlx_dir = make_checkout(assets / "stable-audio-3", SUCCESS_STUB)
+ assert sa3.resolve_mlx_dir(env={"LSDJ_ASSETS_HOME": str(assets)}) == mlx_dir
def test_checkout_without_venv_is_skipped(self, tmp_path):
- checkout = (
- tmp_path / "Library" / "Application Support" / "LSDJ" / "stable-audio-3"
- )
+ assets = tmp_path / "assets"
+ checkout = assets / "stable-audio-3"
(checkout / "optimized" / "mlx" / "scripts").mkdir(parents=True)
(checkout / "optimized" / "mlx" / "scripts" / "sa3_mlx.py").write_text("#")
- assert sa3.resolve_mlx_dir(env={}, home=tmp_path) is None
+ assert sa3.resolve_mlx_dir(env={"LSDJ_ASSETS_HOME": str(assets)}) is None
def test_nothing_resolves_to_none(self, tmp_path):
- assert sa3.resolve_mlx_dir(env={}, home=tmp_path) is None
+ assert sa3.resolve_mlx_dir(env={}) is None
@pytest.fixture
@@ -211,7 +206,7 @@ def test_timeout_scales_with_the_requested_length(self):
def test_no_checkout_raises_unavailable(self, monkeypatch, tmp_path):
monkeypatch.delenv("SA3_MLX_HOME", raising=False)
- monkeypatch.setattr(sa3.pathlib.Path, "home", staticmethod(lambda: tmp_path))
+ monkeypatch.setenv("LSDJ_ASSETS_HOME", str(tmp_path / "assets"))
with pytest.raises(sa3.GenerationUnavailable):
asyncio.run(sa3.generate("anything", 3.0, "sfx"))
diff --git a/docs/platform-paths.md b/docs/platform-paths.md
new file mode 100644
index 0000000..fa8e4d5
--- /dev/null
+++ b/docs/platform-paths.md
@@ -0,0 +1,48 @@
+# Platform filesystem contract
+
+The Rust desktop host resolves LSDJ's filesystem roots once during Tauri setup,
+before it starts the deck sidecars, generation server, watchers, or installers.
+Python services receive the resolved paths through environment variables and do
+not derive platform locations from a home directory.
+
+| Ownership | macOS | Windows | Linux |
+| --- | --- | --- | --- |
+| Configuration | `~/Library/Application Support/works.protocol.lsdj` | `%LOCALAPPDATA%\LSDJ\config` | `$XDG_CONFIG_HOME/lsdj` |
+| Durable user data | `~/Documents/LSDJ` | `%LOCALAPPDATA%\LSDJ\data` | `$XDG_DATA_HOME/lsdj` |
+| Disposable cache | `~/Library/Caches/works.protocol.lsdj` | `%LOCALAPPDATA%\LSDJ\cache` | `$XDG_CACHE_HOME/lsdj` |
+| Downloaded assets | `~/Library/Application Support/LSDJ` | `%LOCALAPPDATA%\LSDJ\assets` | `$XDG_DATA_HOME/lsdj/assets` |
+| Install staging | `~/Library/Application Support/LSDJ/.staging` | `%LOCALAPPDATA%\LSDJ\staging` | `$XDG_DATA_HOME/lsdj/staging` |
+
+On Linux, absent or invalid XDG variables use the standard fallbacks
+`~/.config`, `~/.local/share`, and `~/.cache`. On Windows, every root is
+non-roaming and the short `LSDJ` directory deliberately avoids consuming path
+budget when long-path support is disabled. Staging and downloaded assets always
+share a filesystem so a validated install can be promoted atomically.
+
+The host exports `LSDJ_CONFIG_HOME`, `LSDJ_DATA_HOME`, `LSDJ_CACHE_HOME`,
+`LSDJ_ASSETS_HOME`, and `LSDJ_STAGING_HOME`. It also supplies the current
+compatibility variables `MAGENTA_HOME`, `SA3_MLX_HOME`, and `SA3_LORAS_HOME`;
+explicit developer/user values for those three are captured into the contract
+at startup. Paths are passed as native process-environment values and executable
+arguments, not interpolated into shell command strings.
+
+## macOS compatibility and migration
+
+The contract preserves the current visible locations: generated songs and
+samples remain in Documents, model assets remain in Application Support, and
+settings/MCP credentials remain under the bundle identifier. Startup retains
+the existing one-time migrations from `LSDJai` and from
+`~/Documents/Magenta/magenta-rt-v2`.
+
+Each migration is an atomic same-filesystem rename attempted only when the
+destination does not exist. A restart sees the destination and does nothing. If
+preparing or renaming fails, the process contract points the relevant backend at
+the old directory for that run, so a migration failure cannot hide an installed
+model or adapter.
+
+## Virtual environments
+
+Virtual-environment interpreters are resolved centrally as `bin/python` on
+macOS/Linux and `Scripts/python.exe` on Windows. The interpreter and each
+argument remain separate process arguments, including when a profile path
+contains spaces or non-ASCII characters.
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index dbbf6e4..f8d83da 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -48,6 +48,7 @@ mod loras;
mod mcp;
mod midi;
mod models;
+mod platform_paths;
mod samples;
mod settings;
mod sidecar;
@@ -528,11 +529,10 @@ pub fn run() {
.plugin(tauri_plugin_opener::init())
.setup(|app| {
configure_bundled_backend(app)?;
- // Relocate the Magenta model weights out of ~/Documents (which users
- // may sync to iCloud) into the app-owned data dir, migrating a prior
- // install. MUST run before any backend process spawns so they — and
- // magenta_rt.paths, read at import — inherit MAGENTA_HOME (issue #43).
- models::ensure_magenta_home();
+ // Resolve every filesystem root once and pass the contract to Python
+ // through inherited environment variables. This also performs the
+ // restart-safe macOS model migration. MUST precede every service.
+ platform_paths::configure(app)?;
// Start the audio host (engine + render thread + device), then spawn
// the per-deck inference sidecars fed by the deck handles. Everything
// is held in managed state for the app's lifetime.
@@ -585,36 +585,34 @@ pub fn run() {
// The sa3/Magenta generation server (gap 2): the gen-only FastAPI on a
// loopback port the webview fetches; started with the app.
let generation_server = generation::GenerationServer::start();
- // The generated-songs library: a fixed folder under the user's Documents
- // (override never reaches it from the webview) plus a JSON registry the
- // take list restores from. Auto-save / list / load / delete all go
- // through it. Fall back to a relative path only if Documents can't be
- // resolved (effectively never on macOS) so the app still runs.
- let songs_dir = app
- .path()
- .document_dir()
- .map(|d| {
- models::migrate_legacy_dir(
- &d.join("LSDJ").join("generated_songs"),
- &d.join("LSDJai").join("generated_songs"),
+ // The generated-songs library: the durable-data root from the platform
+ // contract plus a JSON registry the take list restores from. On macOS
+ // this remains Documents/LSDJ. Auto-save / list / load / delete all go
+ // through it.
+ let paths = platform_paths::get();
+ let songs_dir = paths.legacy_data().map_or_else(
+ || paths.data().join("generated_songs"),
+ |legacy| {
+ platform_paths::migrate_legacy_dir(
+ &paths.data().join("generated_songs"),
+ &legacy.join("generated_songs"),
)
- })
- .unwrap_or_else(|_| std::path::PathBuf::from("LSDJ/generated_songs"));
+ },
+ );
app.manage(songs::SongLibrary::new(songs_dir.clone()));
// The generated-samples library: the short-loop counterpart of the songs
// folder (ADR-0022), the home for deck freezes / generated pads / composed
// SFX-Music that used to die at session end. Same fixed-folder + registry
// discipline.
- let samples_dir = app
- .path()
- .document_dir()
- .map(|d| {
- models::migrate_legacy_dir(
- &d.join("LSDJ").join("generated_samples"),
- &d.join("LSDJai").join("generated_samples"),
+ let samples_dir = paths.legacy_data().map_or_else(
+ || paths.data().join("generated_samples"),
+ |legacy| {
+ platform_paths::migrate_legacy_dir(
+ &paths.data().join("generated_samples"),
+ &legacy.join("generated_samples"),
)
- })
- .unwrap_or_else(|_| std::path::PathBuf::from("LSDJ/generated_samples"));
+ },
+ );
app.manage(samples::SampleLibrary::new(samples_dir.clone()));
// Watch both library folders so the Media Explorer tabs live-reload on a
// change (a deck auto-saving a sample, a hand-drop/-delete); Rust owns the
diff --git a/src-tauri/src/loras.rs b/src-tauri/src/loras.rs
index 2b445c9..6c43aaa 100644
--- a/src-tauri/src/loras.rs
+++ b/src-tauri/src/loras.rs
@@ -6,7 +6,7 @@
//! models (no central index file):
//!
//! ```text
-//! ~/Library/Application Support/LSDJ/sa3-loras///
+//! /sa3-loras///
//! adapter_model.safetensors (the adapter — any single *.safetensors)
//! adapter_config.json (PEFT convention only)
//! lora.json (import manifest: source / type / rank)
@@ -50,15 +50,10 @@ const MAX_HEADER_BYTES: u64 = 64 * 1024 * 1024;
const MANIFEST: &str = "lora.json";
-/// The registry root. `$SA3_LORAS_HOME` wins (dev/test override); otherwise the
-/// app-owned data dir, beside the SA3 checkout. Mirrors `loras.loras_dir`.
+/// The registry root resolved by the Rust host. An explicit dev/user override is
+/// captured into this contract during startup; callers never guess an OS path.
pub fn loras_dir() -> PathBuf {
- if let Some(override_home) = std::env::var_os("SA3_LORAS_HOME") {
- if !override_home.is_empty() {
- return PathBuf::from(override_home);
- }
- }
- crate::models::app_support_base().join("sa3-loras")
+ crate::platform_paths::get().loras_home().to_path_buf()
}
// --- Registry discovery ----------------------------------------------------
diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs
index d3ff6d6..90230dd 100644
--- a/src-tauri/src/mcp.rs
+++ b/src-tauri/src/mcp.rs
@@ -1071,12 +1071,11 @@ impl ServerHandler for McpHandler {
pub struct McpServer {
app: AppHandle,
token: Arc>,
- /// Where the token is persisted (under the app data dir); `None` if the dir can't
- /// be resolved (then the token is in-memory only).
- token_path: Option,
+ /// Where the token is persisted under the resolved configuration root.
+ token_path: PathBuf,
/// Where the chosen port is persisted, so it's stable across launches and the
- /// config snippet doesn't churn; `None` if the dir can't be resolved.
- port_path: Option,
+ /// config snippet doesn't churn.
+ port_path: PathBuf,
running: Mutex,
}
@@ -1095,19 +1094,16 @@ impl McpServer {
/// carry the bearer token (also persisted).
pub fn start(app: AppHandle) -> McpServer {
let token_path = token_file(&app);
- let token_string = match &token_path {
- Some(path) => load_or_generate_token(path),
- None => generate_token(),
- };
+ let token_string = load_or_generate_token(&token_path);
let token = Arc::new(RwLock::new(token_string));
let port_path = port_file(&app);
- let desired = port_path.as_deref().and_then(load_port);
+ let desired = load_port(&port_path);
let running = spawn_server(&app, &token, desired);
// Remember the actually-bound port so an ephemeral assignment is reused.
- if let (Some(port), Some(path)) = (running.port, &port_path) {
- save_port(path, port);
+ if let Some(port) = running.port {
+ save_port(&port_path, port);
}
McpServer {
@@ -1133,9 +1129,7 @@ impl McpServer {
/// at once (a leaked token is invalidated without restarting). Returns the new token.
pub fn rotate(&self) -> Option {
let next = generate_token();
- if let Some(path) = &self.token_path {
- save_token(path, &next);
- }
+ save_token(&self.token_path, &next);
*write_lock(&self.token) = next.clone();
Some(next)
}
@@ -1164,9 +1158,7 @@ impl McpServer {
)
};
previous.cancel.cancel();
- if let Some(path) = &self.port_path {
- save_port(path, port);
- }
+ save_port(&self.port_path, port);
Ok(port)
}
@@ -1312,20 +1304,14 @@ fn write_lock(lock: &RwLock) -> std::sync::RwLockWriteGuard<'_, String>
lock.write().unwrap_or_else(|p| p.into_inner())
}
-/// The token file under the app data dir (`None` if it can't be resolved).
-fn token_file(app: &AppHandle) -> Option {
- app.path()
- .app_data_dir()
- .ok()
- .map(|dir| dir.join("mcp-token"))
+/// The token file under the host-resolved configuration root.
+fn token_file(_app: &AppHandle) -> PathBuf {
+ crate::platform_paths::get().config().join("mcp-token")
}
-/// The port file under the app data dir (`None` if it can't be resolved).
-fn port_file(app: &AppHandle) -> Option {
- app.path()
- .app_data_dir()
- .ok()
- .map(|dir| dir.join("mcp-port"))
+/// The port file under the host-resolved configuration root.
+fn port_file(_app: &AppHandle) -> PathBuf {
+ crate::platform_paths::get().config().join("mcp-port")
}
/// Read the persisted port — a plain decimal `u16` ≥ 1024 (privileged ports are
diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs
index 1d39ad2..cdde89f 100644
--- a/src-tauri/src/models.rs
+++ b/src-tauri/src/models.rs
@@ -45,22 +45,14 @@ const WARMED_STAMP: &str = ".lsdj-warmed";
// installer doesn't know the commit). Lives beside `.lsdj-warmed` in optimized/mlx.
const SOURCE_STAMP: &str = ".lsdj-source.json";
-// --- Path resolution (mirrors backend/lsdj/paths.py + sa3.py) --------------
+// --- Host-resolved paths (mirrors the explicit Python environment) --------
-fn home_dir() -> PathBuf {
- std::env::var_os("HOME")
- .map(PathBuf::from)
- .unwrap_or_else(|| PathBuf::from("/"))
-}
-
-/// The `magenta-rt-v2` data root: `(MAGENTA_HOME or ~/Documents/Magenta)/
-/// magenta-rt-v2`. The `magenta-rt-v2` segment is ALWAYS appended, even when
-/// `MAGENTA_HOME` is set — matching `paths.magenta_home()`.
+/// The `magenta-rt-v2` data root. `MAGENTA_HOME`'s compatibility semantics
+/// append this segment; the host owns the resolved base.
fn magenta_home() -> PathBuf {
- let base = std::env::var_os("MAGENTA_HOME")
- .map(PathBuf::from)
- .unwrap_or_else(|| home_dir().join("Documents").join("Magenta"));
- base.join("magenta-rt-v2")
+ crate::platform_paths::get()
+ .magenta_base()
+ .join("magenta-rt-v2")
}
/// The Magenta models dir (`paths.models_dir()`).
@@ -68,107 +60,9 @@ pub fn magenta_models_dir() -> PathBuf {
magenta_home().join("models")
}
-/// The app-owned data root for model weights — `~/Library/Application Support/
-/// LSDJ`. Kept out of `~/Documents` (which users may sync to iCloud, where
-/// multi-GB weights don't belong and Finder ops on offloaded files fail, -8013).
-pub(crate) fn app_support_base() -> PathBuf {
- home_dir()
- .join("Library")
- .join("Application Support")
- .join("LSDJ")
-}
-
-/// The app-owned home for the Stable Audio 3 checkout — where in-app installs go
-/// and the first place the resolver looks (kept out of `~/Documents`, like the
-/// Magenta weights).
+/// The host-owned home for the Stable Audio 3 checkout.
fn sa3_app_home() -> PathBuf {
- app_support_base().join("stable-audio-3")
-}
-
-/// Decide the one-time migration: `Some((from, to))` when a prior install lives
-/// at `old_rtv2` and `new_rtv2` doesn't exist yet, else `None`. Pure (no I/O
-/// beyond the existence checks) so the policy is unit-testable.
-fn migration_move(new_rtv2: &Path, old_rtv2: &Path) -> Option<(PathBuf, PathBuf)> {
- (!new_rtv2.exists() && old_rtv2.is_dir())
- .then(|| (old_rtv2.to_path_buf(), new_rtv2.to_path_buf()))
-}
-
-/// Move one directory from the previous brand root when its LSDJ destination
-/// does not exist yet. If the move fails, keep using the legacy directory so a
-/// branding change can never make installed models or adapters disappear.
-pub(crate) fn migrate_legacy_dir(new_dir: &Path, old_dir: &Path) -> PathBuf {
- let Some((from, to)) = migration_move(new_dir, old_dir) else {
- return new_dir.to_path_buf();
- };
- if let Some(parent) = to.parent() {
- if let Err(error) = std::fs::create_dir_all(parent) {
- eprintln!(
- "lsdj-app: could not prepare branded data directory {}: {error}",
- parent.display()
- );
- return from;
- }
- }
- match std::fs::rename(&from, &to) {
- Ok(()) => {
- eprintln!("lsdj-app: migrated branded data → {}", to.display());
- to
- }
- Err(error) => {
- eprintln!(
- "lsdj-app: could not migrate {} to {}: {error}",
- from.display(),
- to.display()
- );
- from
- }
- }
-}
-
-/// Point `MAGENTA_HOME` at the app-owned data dir and migrate a prior
-/// `~/Documents/Magenta` install into it (a same-volume rename — instant, no
-/// multi-GB copy). A pre-set `MAGENTA_HOME` (a dev/user override) wins. Must run
-/// once at startup BEFORE any backend process is spawned, so the children — and
-/// `magenta_rt.paths`, which reads the env at import — inherit the new location.
-pub fn ensure_magenta_home() {
- let base = app_support_base();
- let legacy_base = home_dir()
- .join("Library")
- .join("Application Support")
- .join("LSDJai");
- let branded_magenta = migrate_legacy_dir(
- &base.join("magenta-rt-v2"),
- &legacy_base.join("magenta-rt-v2"),
- );
- let branded_sa3 = migrate_legacy_dir(
- &base.join("stable-audio-3"),
- &legacy_base.join("stable-audio-3"),
- );
- let branded_loras = migrate_legacy_dir(&base.join("sa3-loras"), &legacy_base.join("sa3-loras"));
- if branded_sa3.starts_with(&legacy_base) && std::env::var_os("SA3_MLX_HOME").is_none() {
- std::env::set_var("SA3_MLX_HOME", branded_sa3);
- }
- if branded_loras.starts_with(&legacy_base) && std::env::var_os("SA3_LORAS_HOME").is_none() {
- std::env::set_var("SA3_LORAS_HOME", branded_loras);
- }
- if std::env::var_os("MAGENTA_HOME").is_some() {
- return; // respect an explicit override (dev, or a custom location)
- }
- if branded_magenta.starts_with(&legacy_base) {
- std::env::set_var("MAGENTA_HOME", legacy_base);
- return;
- }
- let old_base = home_dir().join("Documents").join("Magenta");
- if let Some((from, to)) = migration_move(&base.join("magenta-rt-v2"), &old_base.join("magenta-rt-v2")) {
- let _ = std::fs::create_dir_all(&base);
- if std::fs::rename(&from, &to).is_err() {
- // Cross-volume / perms: keep the existing install rather than strand it.
- std::env::set_var("MAGENTA_HOME", &old_base);
- return;
- }
- eprintln!("lsdj-app: migrated model weights → {}", to.display());
- }
- std::env::set_var("MAGENTA_HOME", &base);
+ crate::platform_paths::get().sa3_home().to_path_buf()
}
/// Whether the shared resources a model load needs are present — without these
@@ -180,14 +74,7 @@ fn resources_present() -> bool {
/// SA3 checkout roots to probe, in order (mirrors `sa3._checkout_candidates`).
fn sa3_candidates() -> Vec {
- let mut candidates = Vec::new();
- if let Some(override_home) = std::env::var_os("SA3_MLX_HOME") {
- if !override_home.is_empty() {
- candidates.push(PathBuf::from(override_home));
- }
- }
- candidates.push(sa3_app_home()); // the app-owned home — where in-app installs go
- candidates
+ vec![sa3_app_home()]
}
/// The SA3 install state + the resolved checkout root (mirrors `sa3.readiness`):
@@ -203,7 +90,7 @@ fn sa3_status() -> (&'static str, Option) {
if first_with_mlx.is_none() {
first_with_mlx = Some(checkout.clone());
}
- let python = mlx.join(".venv").join("bin").join("python");
+ let python = crate::platform_paths::venv_python(&mlx.join(".venv"));
let script = mlx.join("scripts").join("sa3_mlx.py");
if !(python.is_file() && script.is_file()) {
continue;
@@ -1062,45 +949,6 @@ mod tests {
assert!(!sa3_update_available(Some(&slash), &pin, true));
}
- #[test]
- fn migration_moves_a_prior_install_only_when_the_new_dir_is_absent() {
- let tmp = std::env::temp_dir().join(format!("lsdj-migrate-test-{}", std::process::id()));
- let _ = std::fs::remove_dir_all(&tmp);
- let new_rtv2 = tmp.join("new").join("magenta-rt-v2");
- let old_rtv2 = tmp.join("old").join("magenta-rt-v2");
-
- // No prior install → nothing to move.
- assert_eq!(migration_move(&new_rtv2, &old_rtv2), None);
-
- // Prior install present, new dir absent → move it.
- std::fs::create_dir_all(&old_rtv2).unwrap();
- assert_eq!(
- migration_move(&new_rtv2, &old_rtv2),
- Some((old_rtv2.clone(), new_rtv2.clone())),
- );
-
- // New dir already exists (already migrated) → leave the old one be.
- std::fs::create_dir_all(&new_rtv2).unwrap();
- assert_eq!(migration_move(&new_rtv2, &old_rtv2), None);
- let _ = std::fs::remove_dir_all(&tmp);
- }
-
- #[test]
- fn legacy_brand_directory_is_moved_without_losing_its_contents() {
- let tmp =
- std::env::temp_dir().join(format!("lsdj-brand-migrate-test-{}", std::process::id()));
- let _ = std::fs::remove_dir_all(&tmp);
- let old_dir = tmp.join("LSDJai").join("stable-audio-3");
- let new_dir = tmp.join("LSDJ").join("stable-audio-3");
- std::fs::create_dir_all(&old_dir).unwrap();
- std::fs::write(old_dir.join("model.bin"), b"model").unwrap();
-
- assert_eq!(migrate_legacy_dir(&new_dir, &old_dir), new_dir);
- assert_eq!(std::fs::read(new_dir.join("model.bin")).unwrap(), b"model");
- assert!(!old_dir.exists());
- let _ = std::fs::remove_dir_all(&tmp);
- }
-
// --- End-to-end install: actually run the pipeline against a stub backend.
// These spawn real processes and exercise the full spawn → stream-parse →
// progress → on-disk-result path (no weights, no GUI, no network). Env is set
diff --git a/src-tauri/src/platform_paths.rs b/src-tauri/src/platform_paths.rs
new file mode 100644
index 0000000..6395344
--- /dev/null
+++ b/src-tauri/src/platform_paths.rs
@@ -0,0 +1,515 @@
+//! Host-owned cross-platform filesystem contract (issue #107).
+//!
+//! Rust resolves every application root once, before any Python process starts.
+//! Children inherit the explicit `LSDJ_*_HOME` variables installed by
+//! [`configure`]; Python must consume those values rather than reconstructing an
+//! operating-system path from `$HOME`.
+//!
+//! The roots have deliberately different ownership:
+//! - `config`: small durable settings and credentials.
+//! - `data`: user-created songs, samples, and registries.
+//! - `cache`: disposable, reproducible files.
+//! - `assets`: downloaded model weights and runtimes.
+//! - `staging`: incomplete downloads/installs. It is on the same filesystem as
+//! `assets`, so a validated future installer can promote atomically.
+
+use std::ffi::OsString;
+use std::io;
+use std::path::{Path, PathBuf};
+use std::sync::OnceLock;
+
+use tauri::Manager;
+
+const APP_ID: &str = "works.protocol.lsdj";
+const APP_NAME: &str = "LSDJ";
+const APP_SLUG: &str = "lsdj";
+
+/// The operating-system families whose path policy differs. Kept independent
+/// of `cfg!` so every mapping is unit-tested on every CI host.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[allow(dead_code)] // non-host variants are exercised by portable unit tests
+enum Platform {
+ MacOs,
+ Windows,
+ Linux,
+}
+
+/// Platform-native unscoped directories. Production gets these from Tauri's
+/// directory resolver (known-folder APIs on Windows, XDG on Linux); tests feed
+/// synthetic roots so spaces and non-ASCII profiles are covered everywhere.
+#[derive(Clone, Debug)]
+struct NativeDirs {
+ home: PathBuf,
+ config: PathBuf,
+ data: PathBuf,
+ local_data: PathBuf,
+ cache: PathBuf,
+ documents: PathBuf,
+}
+
+/// The canonical application roots plus the actual backend asset locations.
+/// The latter can temporarily point at a legacy macOS directory if an old
+/// install cannot be renamed (for example because of permissions), ensuring a
+/// migration failure never makes existing models disappear.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct AppPaths {
+ config: PathBuf,
+ data: PathBuf,
+ cache: PathBuf,
+ assets: PathBuf,
+ staging: PathBuf,
+ magenta_base: PathBuf,
+ sa3_home: PathBuf,
+ loras_home: PathBuf,
+ legacy_data: Option,
+}
+
+impl AppPaths {
+ pub fn config(&self) -> &Path {
+ &self.config
+ }
+
+ pub fn data(&self) -> &Path {
+ &self.data
+ }
+
+ pub fn cache(&self) -> &Path {
+ &self.cache
+ }
+
+ pub fn assets(&self) -> &Path {
+ &self.assets
+ }
+
+ pub fn staging(&self) -> &Path {
+ &self.staging
+ }
+
+ pub fn magenta_base(&self) -> &Path {
+ &self.magenta_base
+ }
+
+ pub fn sa3_home(&self) -> &Path {
+ &self.sa3_home
+ }
+
+ pub fn loras_home(&self) -> &Path {
+ &self.loras_home
+ }
+
+ /// The old macOS Documents brand root, used only to migrate generated
+ /// libraries independently when a destination already contains other data.
+ pub fn legacy_data(&self) -> Option<&Path> {
+ self.legacy_data.as_deref()
+ }
+
+ fn backend_env(&self) -> [(OsString, OsString); 8] {
+ [
+ pair("LSDJ_CONFIG_HOME", &self.config),
+ pair("LSDJ_DATA_HOME", &self.data),
+ pair("LSDJ_CACHE_HOME", &self.cache),
+ pair("LSDJ_ASSETS_HOME", &self.assets),
+ pair("LSDJ_STAGING_HOME", &self.staging),
+ // Compatibility variables consumed by upstream magenta-rt-v2 and
+ // the current SA3 entry points. Rust remains their source of truth.
+ pair("MAGENTA_HOME", &self.magenta_base),
+ pair("SA3_MLX_HOME", &self.sa3_home),
+ pair("SA3_LORAS_HOME", &self.loras_home),
+ ]
+ }
+}
+
+fn pair(name: &str, value: &Path) -> (OsString, OsString) {
+ (OsString::from(name), value.as_os_str().to_owned())
+}
+
+fn platform() -> Platform {
+ #[cfg(target_os = "macos")]
+ return Platform::MacOs;
+ #[cfg(target_os = "windows")]
+ return Platform::Windows;
+ #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
+ return Platform::Linux;
+}
+
+fn resolve(platform: Platform, native: NativeDirs) -> AppPaths {
+ let (config, data, cache, assets, staging, legacy_data) = match platform {
+ Platform::MacOs => {
+ // Preserve all current user-visible locations: generated media stays
+ // in Documents/LSDJ, model assets stay in Application Support/LSDJ,
+ // and shell settings remain under Tauri's bundle identifier.
+ let assets = native.data.join(APP_NAME);
+ (
+ native.config.join(APP_ID),
+ native.documents.join(APP_NAME),
+ native.cache.join(APP_ID),
+ assets.clone(),
+ assets.join(".staging"),
+ Some(native.documents.join("LSDJai")),
+ )
+ }
+ Platform::Windows => {
+ // Everything is non-roaming and deliberately shallow. `LSDJ`
+ // replaces the reverse-DNS identifier to preserve path budget for
+ // Python environments and model filenames when long paths are off.
+ let base = native.local_data.join(APP_NAME);
+ (
+ base.join("config"),
+ base.join("data"),
+ base.join("cache"),
+ base.join("assets"),
+ base.join("staging"),
+ None,
+ )
+ }
+ Platform::Linux => {
+ // Tauri's native bases follow XDG_CONFIG_HOME, XDG_DATA_HOME, and
+ // XDG_CACHE_HOME, falling back respectively to ~/.config,
+ // ~/.local/share, and ~/.cache when variables are absent/invalid.
+ let data = native.data.join(APP_SLUG);
+ (
+ native.config.join(APP_SLUG),
+ data.clone(),
+ native.cache.join(APP_SLUG),
+ data.join("assets"),
+ data.join("staging"),
+ None,
+ )
+ }
+ };
+ AppPaths {
+ magenta_base: assets.clone(),
+ sa3_home: assets.join("stable-audio-3"),
+ loras_home: assets.join("sa3-loras"),
+ config,
+ data,
+ cache,
+ assets,
+ staging,
+ legacy_data,
+ }
+}
+
+fn native_dirs(
+ app: &tauri::App,
+ platform: Platform,
+) -> Result> {
+ let paths = app.path();
+ match platform {
+ Platform::MacOs => {
+ let data = paths.data_dir()?;
+ Ok(NativeDirs {
+ home: paths.home_dir().unwrap_or_else(|_| data.clone()),
+ config: paths.config_dir()?,
+ local_data: data.clone(),
+ cache: paths.cache_dir()?,
+ documents: paths.document_dir().unwrap_or_else(|_| data.clone()),
+ data,
+ })
+ }
+ Platform::Windows => {
+ let local_data = paths.local_data_dir()?;
+ Ok(NativeDirs {
+ home: local_data.clone(),
+ config: local_data.clone(),
+ data: local_data.clone(),
+ cache: local_data.clone(),
+ documents: local_data.clone(),
+ local_data,
+ })
+ }
+ Platform::Linux => {
+ let data = paths.data_dir()?;
+ Ok(NativeDirs {
+ home: data.clone(),
+ config: paths.config_dir()?,
+ local_data: data.clone(),
+ cache: paths.cache_dir()?,
+ documents: data.clone(),
+ data,
+ })
+ }
+ }
+}
+
+static CONFIGURED: OnceLock = OnceLock::new();
+
+/// Resolve, prepare, and publish the path contract. This must be called in
+/// Tauri setup before starting any sidecar, server, watcher, or installer.
+pub fn configure(app: &tauri::App) -> Result<&'static AppPaths, Box> {
+ if let Some(paths) = CONFIGURED.get() {
+ return Ok(paths);
+ }
+ let platform = platform();
+ let native = native_dirs(app, platform)?;
+ let mut paths = resolve(platform, native.clone());
+ prepare_roots(&paths)?;
+ if platform == Platform::MacOs {
+ migrate_macos(&mut paths, &native);
+ }
+
+ // Explicit dev/user overrides remain supported, but the resolved value is
+ // captured into the host contract and then passed to every child.
+ if let Some(value) = nonempty_env("MAGENTA_HOME") {
+ paths.magenta_base = PathBuf::from(value);
+ }
+ if let Some(value) = nonempty_env("SA3_MLX_HOME") {
+ paths.sa3_home = PathBuf::from(value);
+ }
+ if let Some(value) = nonempty_env("SA3_LORAS_HOME") {
+ paths.loras_home = PathBuf::from(value);
+ }
+
+ for (name, value) in paths.backend_env() {
+ std::env::set_var(name, value);
+ }
+ CONFIGURED
+ .set(paths)
+ .map_err(|_| io::Error::new(io::ErrorKind::AlreadyExists, "paths already configured"))?;
+ Ok(CONFIGURED.get().expect("paths were just configured"))
+}
+
+fn nonempty_env(name: &str) -> Option {
+ std::env::var_os(name).filter(|value| !value.is_empty())
+}
+
+fn prepare_roots(paths: &AppPaths) -> io::Result<()> {
+ for dir in [
+ paths.config(),
+ paths.data(),
+ paths.cache(),
+ paths.assets(),
+ paths.staging(),
+ ] {
+ std::fs::create_dir_all(dir)?;
+ }
+ Ok(())
+}
+
+/// Return the configured contract. Calling this before [`configure`] is a
+/// programming error: doing so would reintroduce independent path guessing.
+pub fn get() -> &'static AppPaths {
+ CONFIGURED
+ .get()
+ .expect("platform paths must be configured before services start")
+}
+
+/// Resolve the interpreter in a virtual environment without assuming Unix's
+/// `bin/python` layout on Windows.
+pub fn venv_python(venv: &Path) -> PathBuf {
+ venv_python_for(platform(), venv)
+}
+
+fn venv_python_for(platform: Platform, venv: &Path) -> PathBuf {
+ match platform {
+ Platform::Windows => venv.join("Scripts").join("python.exe"),
+ Platform::MacOs | Platform::Linux => venv.join("bin").join("python"),
+ }
+}
+
+/// Move one legacy directory only when its destination is absent. `rename` is
+/// atomic on the same filesystem; after success, a restart observes the
+/// destination and does nothing. On failure the caller receives the old path,
+/// keeping existing user data visible.
+pub fn migrate_legacy_dir(new_dir: &Path, old_dir: &Path) -> PathBuf {
+ if new_dir.exists() || !old_dir.is_dir() {
+ return new_dir.to_path_buf();
+ }
+ if let Some(parent) = new_dir.parent() {
+ if let Err(error) = std::fs::create_dir_all(parent) {
+ eprintln!(
+ "lsdj-app: could not prepare data directory {}: {error}",
+ parent.display()
+ );
+ return old_dir.to_path_buf();
+ }
+ }
+ match std::fs::rename(old_dir, new_dir) {
+ Ok(()) => {
+ eprintln!("lsdj-app: migrated data → {}", new_dir.display());
+ new_dir.to_path_buf()
+ }
+ Err(error) => {
+ eprintln!(
+ "lsdj-app: could not migrate {} to {}: {error}",
+ old_dir.display(),
+ new_dir.display()
+ );
+ old_dir.to_path_buf()
+ }
+ }
+}
+
+fn migrate_macos(paths: &mut AppPaths, native: &NativeDirs) {
+ let old_brand = native.data.join("LSDJai");
+ let new_magenta = paths.assets.join("magenta-rt-v2");
+ let branded_magenta = migrate_legacy_dir(&new_magenta, &old_brand.join("magenta-rt-v2"));
+ let branded_sa3 = migrate_legacy_dir(
+ &paths.assets.join("stable-audio-3"),
+ &old_brand.join("stable-audio-3"),
+ );
+ let branded_loras = migrate_legacy_dir(
+ &paths.assets.join("sa3-loras"),
+ &old_brand.join("sa3-loras"),
+ );
+
+ paths.sa3_home = branded_sa3;
+ paths.loras_home = branded_loras;
+ if branded_magenta.starts_with(&old_brand) {
+ paths.magenta_base = old_brand;
+ return;
+ }
+
+ // Pre-model-manager installs lived under Documents/Magenta. Preserve the
+ // old location if a same-volume rename cannot complete.
+ let old_magenta_base = native.home.join("Documents").join("Magenta");
+ let migrated = migrate_legacy_dir(&new_magenta, &old_magenta_base.join("magenta-rt-v2"));
+ paths.magenta_base = migrated
+ .parent()
+ .map(Path::to_path_buf)
+ .unwrap_or_else(|| paths.assets.clone());
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn native(profile: &str) -> NativeDirs {
+ let home = PathBuf::from(profile);
+ NativeDirs {
+ config: home.join("config base"),
+ data: home.join("data base"),
+ local_data: home.join("local data"),
+ cache: home.join("cache base"),
+ documents: home.join("Documents"),
+ home,
+ }
+ }
+
+ #[test]
+ fn macos_preserves_existing_user_visible_locations() {
+ let roots = resolve(Platform::MacOs, native("/Users/DJ Name"));
+ assert_eq!(roots.data, Path::new("/Users/DJ Name/Documents/LSDJ"));
+ assert_eq!(
+ roots.assets,
+ Path::new("/Users/DJ Name/data base/LSDJ")
+ );
+ assert_eq!(
+ roots.config,
+ Path::new("/Users/DJ Name/config base/works.protocol.lsdj")
+ );
+ }
+
+ #[test]
+ fn windows_roots_are_non_roaming_shallow_and_unicode_safe() {
+ let roots = resolve(Platform::Windows, native(r"C:\Users\Zoë 王"));
+ let base = Path::new(r"C:\Users\Zoë 王").join("local data").join("LSDJ");
+ assert_eq!(roots.config, base.join("config"));
+ assert_eq!(roots.data, base.join("data"));
+ assert_eq!(roots.cache, base.join("cache"));
+ assert_eq!(roots.assets, base.join("assets"));
+ assert_eq!(roots.staging, base.join("staging"));
+ assert!(!roots.config.to_string_lossy().contains(APP_ID));
+ }
+
+ #[test]
+ fn linux_roots_follow_xdg_native_bases_and_keep_staging_with_assets() {
+ let roots = resolve(Platform::Linux, native("/home/DJ 名"));
+ assert_eq!(roots.config, Path::new("/home/DJ 名/config base/lsdj"));
+ assert_eq!(roots.data, Path::new("/home/DJ 名/data base/lsdj"));
+ assert_eq!(roots.cache, Path::new("/home/DJ 名/cache base/lsdj"));
+ assert_eq!(roots.assets, roots.data.join("assets"));
+ assert_eq!(roots.staging, roots.data.join("staging"));
+ }
+
+ #[test]
+ fn linux_standard_xdg_fallbacks_are_scoped_to_lsdj() {
+ let home = PathBuf::from("/home/DJ Name");
+ let roots = resolve(
+ Platform::Linux,
+ NativeDirs {
+ config: home.join(".config"),
+ data: home.join(".local/share"),
+ local_data: home.join(".local/share"),
+ cache: home.join(".cache"),
+ documents: home.clone(),
+ home: home.clone(),
+ },
+ );
+ assert_eq!(roots.config, home.join(".config/lsdj"));
+ assert_eq!(roots.data, home.join(".local/share/lsdj"));
+ assert_eq!(roots.cache, home.join(".cache/lsdj"));
+ }
+
+ #[test]
+ fn venv_python_handles_both_layouts_without_parsing_a_command_string() {
+ let root = Path::new("/profiles/DJ Name/模型/.venv");
+ assert_eq!(
+ venv_python_for(Platform::Windows, root),
+ root.join("Scripts").join("python.exe")
+ );
+ assert_eq!(
+ venv_python_for(Platform::Linux, root),
+ root.join("bin").join("python")
+ );
+ }
+
+ #[test]
+ fn backend_environment_preserves_spaces_and_non_ascii() {
+ let roots = resolve(Platform::Linux, native("/home/DJ Name/音楽"));
+ let values: std::collections::HashMap<_, _> = roots.backend_env().into_iter().collect();
+ assert_eq!(
+ values
+ .get(std::ffi::OsStr::new("LSDJ_ASSETS_HOME"))
+ .map(OsString::as_os_str),
+ Some(roots.assets.as_os_str()),
+ );
+ assert_eq!(
+ values
+ .get(std::ffi::OsStr::new("SA3_MLX_HOME"))
+ .map(OsString::as_os_str),
+ Some(roots.sa3_home.as_os_str()),
+ );
+ }
+
+ #[test]
+ fn migration_is_atomic_and_restart_safe() {
+ let root = std::env::temp_dir().join(format!(
+ "lsdj-path-migrate-{}-{}",
+ std::process::id(),
+ "音楽 folder"
+ ));
+ let old = root.join("old brand").join("models");
+ let new = root.join("new brand").join("models");
+ let _ = std::fs::remove_dir_all(&root);
+ std::fs::create_dir_all(&old).unwrap();
+ std::fs::write(old.join("weight.bin"), b"model").unwrap();
+
+ assert_eq!(migrate_legacy_dir(&new, &old), new);
+ assert_eq!(std::fs::read(new.join("weight.bin")).unwrap(), b"model");
+ // A restart is a no-op and leaves the promoted destination intact.
+ assert_eq!(migrate_legacy_dir(&new, &old), new);
+ assert!(!old.exists());
+ let _ = std::fs::remove_dir_all(&root);
+ }
+
+ #[test]
+ fn failed_migration_keeps_the_legacy_directory_active() {
+ let root = std::env::temp_dir().join(format!(
+ "lsdj-path-migrate-failure-{}",
+ std::process::id()
+ ));
+ let old = root.join("legacy").join("models");
+ let blocked_parent = root.join("blocked");
+ let new = blocked_parent.join("models");
+ let _ = std::fs::remove_dir_all(&root);
+ std::fs::create_dir_all(&old).unwrap();
+ std::fs::write(old.join("weight.bin"), b"model").unwrap();
+ std::fs::write(&blocked_parent, b"not a directory").unwrap();
+
+ assert_eq!(migrate_legacy_dir(&new, &old), old);
+ assert_eq!(std::fs::read(old.join("weight.bin")).unwrap(), b"model");
+ assert!(!new.exists());
+ let _ = std::fs::remove_dir_all(&root);
+ }
+}
diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs
index 8c41c14..e1fd6eb 100644
--- a/src-tauri/src/settings.rs
+++ b/src-tauri/src/settings.rs
@@ -3,7 +3,7 @@
//! Settings the SHELL consumes — the output devices and the recordings
//! folder — used to live in webview localStorage, replayed into the engine
//! at boot by App.tsx. Persistence follows ownership: they now persist here
-//! (a JSON file under the app data dir, beside the MCP token), hydrate into
+//! (a JSON file under the host-resolved config dir, beside the MCP token), hydrate into
//! the engine and the interface store during `setup` — before the webview
//! exists — and mutate through the same commands every controller uses. The
//! webview's pickers become projections. Presentation-only preferences
@@ -15,7 +15,7 @@ use std::sync::{mpsc, Mutex};
use std::time::Duration;
use serde::{Deserialize, Serialize};
-use tauri::{AppHandle, Manager};
+use tauri::AppHandle;
use crate::store::{GenerationSnap, InterfaceState, InterfaceStore};
@@ -101,8 +101,8 @@ impl Default for DeckMixerSetting {
}
}
-fn settings_file(app: &AppHandle) -> Option {
- app.path().app_data_dir().ok().map(|dir| dir.join("settings.json"))
+fn settings_file() -> PathBuf {
+ crate::platform_paths::get().config().join("settings.json")
}
/// Load from a concrete path (the testable core): a missing or unreadable
@@ -141,8 +141,8 @@ pub fn save_to(path: &Path, settings: &ShellSettings) {
}
}
-pub fn load(app: &AppHandle) -> ShellSettings {
- settings_file(app).map(|p| load_from(&p)).unwrap_or_default()
+pub fn load(_app: &AppHandle) -> ShellSettings {
+ load_from(&settings_file())
}
/// One writer at a time: `update` runs from the main thread (the device /
@@ -163,17 +163,8 @@ pub fn update_at(path: &Path, mutate: impl FnOnce(&mut ShellSettings)) -> ShellS
}
/// Read-modify-write one field; the single mutation path the commands use.
-pub fn update(app: &AppHandle, mutate: impl FnOnce(&mut ShellSettings)) -> ShellSettings {
- match settings_file(app) {
- Some(path) => update_at(&path, mutate),
- // No data dir (boot-path edge): mutate the defaults so the caller
- // still gets the value it wrote, exactly as before — just unsaved.
- None => {
- let mut settings = ShellSettings::default();
- mutate(&mut settings);
- settings
- }
- }
+pub fn update(_app: &AppHandle, mutate: impl FnOnce(&mut ShellSettings)) -> ShellSettings {
+ update_at(&settings_file(), mutate)
}
/// The settings-write debounce: a fader ride or a pad drag settles before it