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