From 19454e7a6cd67cef05a3ebfecec050220d188012 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sun, 26 Apr 2026 17:03:28 +0000 Subject: [PATCH] studio: tauri studio_root helper + marker-file persistence + ~ expansion Address cycle-5 reviewer findings: - Add studio/src-tauri/src/studio_root.rs: shared resolver with UNSLOTH_STUDIO_HOME / STUDIO_HOME (priority order), tilde expansion (~, ~/..., ~\...), installer-written marker fallback, then ~/.unsloth/studio. 5 unit tests cover the expansion paths. - Tauri lookups now go through the shared resolver: - process.rs::find_unsloth_binary - desktop_auth.rs::desktop_secret_path - main.rs::setup_logging (tauri.log under custom root) - commands.rs::open_logs_dir (opens custom root dir) - install.rs work_dir uses parent of resolved root (avoids creating a stray ~/.unsloth on a custom-root install) - install.sh / install.ps1 (env-mode only): write ~/.unsloth/studio-home marker so the desktop app launched from Finder/Start Menu (no shell env inheritance) still resolves the custom root. - install.sh / install.ps1 non-interactive completion: when StudioRedirectMode=env, print the absolute custom-root shim path since the persistent rc/registry PATH update is intentionally skipped in env-override mode. - unsloth_cli/commands/studio.py: replace setdefault() with truthy-check so a blank UNSLOTH_STUDIO_HOME / UNSLOTH_LLAMA_CPP_PATH in the parent env doesn't suppress the inferred custom root. 40/40 cargo test --bins pass. --- install.ps1 | 30 ++++++- install.sh | 25 +++++- studio/src-tauri/src/commands.rs | 4 +- studio/src-tauri/src/desktop_auth.rs | 16 +--- studio/src-tauri/src/install.rs | 10 ++- studio/src-tauri/src/main.rs | 8 +- studio/src-tauri/src/process.rs | 22 ++--- studio/src-tauri/src/studio_root.rs | 116 +++++++++++++++++++++++++++ unsloth_cli/commands/studio.py | 8 +- 9 files changed, 196 insertions(+), 43 deletions(-) create mode 100644 studio/src-tauri/src/studio_root.rs diff --git a/install.ps1 b/install.ps1 index 1ff57ae5c5..7889fb38da 100644 --- a/install.ps1 +++ b/install.ps1 @@ -433,6 +433,21 @@ function Install-UnslothStudio { } $_sq = $StudioHome -replace "'", "''" $_llama = $_llamaPath -replace "'", "''" + + # Marker file so the Tauri desktop app (launched from + # Start Menu / Desktop, where the launching shell env vars + # aren't inherited) can still resolve the custom root. + try { + $_markerDir = Join-Path $env:USERPROFILE ".unsloth" + if (-not (Test-Path $_markerDir)) { + New-Item -ItemType Directory -Path $_markerDir -Force | Out-Null + } + Set-Content -LiteralPath (Join-Path $_markerDir "studio-home") ` + -Value $StudioHome -NoNewline -ErrorAction Stop + } catch { + # Non-fatal: env var still works for shells that inherit it. + } + "`$env:UNSLOTH_STUDIO_HOME = '$_sq'`n`$env:UNSLOTH_LLAMA_CPP_PATH = '$_llama'`n" } else { "" } @@ -1280,8 +1295,19 @@ shell.Run cmd, 0, False & $UnslothExe studio -H 0.0.0.0 -p 8888 } else { step "launch" "manual commands:" - substep "& `"$VenvDir\Scripts\Activate.ps1`"" - substep "unsloth studio -H 0.0.0.0 -p 8888" + if ($StudioRedirectMode -eq 'env') { + # Env-override mode skips persistent registry PATH update, so + # `unsloth` may not resolve in a fresh shell. Print the + # absolute shim path so callers can launch directly. + $_shim = Join-Path $StudioHome "bin\unsloth.exe" + substep "& `"$_shim`" studio -H 0.0.0.0 -p 8888" + substep "or activate env first:" + substep "& `"$VenvDir\Scripts\Activate.ps1`"" + substep "unsloth studio -H 0.0.0.0 -p 8888" + } else { + substep "& `"$VenvDir\Scripts\Activate.ps1`"" + substep "unsloth studio -H 0.0.0.0 -p 8888" + } Write-Host "" } } diff --git a/install.sh b/install.sh index 74d6663941..9a0a080ae4 100755 --- a/install.sh +++ b/install.sh @@ -625,6 +625,12 @@ LAUNCHER_EOF _css_quoted_llama=$(printf '%s' "$_css_llama_path" | sed "s/'/'\\\\''/g") printf '%s\n' "export UNSLOTH_STUDIO_HOME='$_css_quoted_home'" printf '%s\n' "export UNSLOTH_LLAMA_CPP_PATH='$_css_quoted_llama'" + + # Marker file so the Tauri desktop app (launched from + # Finder/Start Menu/Desktop, where the launching shell's env + # vars are not inherited) can still resolve the custom root. + mkdir -p "$HOME/.unsloth" 2>/dev/null || true + printf '%s\n' "$STUDIO_HOME" > "$HOME/.unsloth/studio-home" 2>/dev/null || true fi } > "$_css_data_dir/studio.conf" @@ -1822,9 +1828,20 @@ if [ -t 1 ]; then exit "$_LAUNCH_EXIT" else step "launch" "manual commands:" - substep "unsloth studio -H 0.0.0.0 -p 8888" - substep "or activate env first:" - substep "source ${VENV_DIR}/bin/activate" - substep "unsloth studio -H 0.0.0.0 -p 8888" + if [ "$_STUDIO_HOME_REDIRECT" = "env" ]; then + # In env-override mode we deliberately skip the persistent shell + # rc PATH append, so a fresh shell will not have `unsloth` on PATH + # unless the caller re-exports UNSLOTH_STUDIO_HOME. Print the + # absolute shim path and the activate-then-run alternative. + substep "${_LOCAL_BIN}/unsloth studio -H 0.0.0.0 -p 8888" + substep "or activate env first:" + substep "source ${VENV_DIR}/bin/activate" + substep "unsloth studio -H 0.0.0.0 -p 8888" + else + substep "unsloth studio -H 0.0.0.0 -p 8888" + substep "or activate env first:" + substep "source ${VENV_DIR}/bin/activate" + substep "unsloth studio -H 0.0.0.0 -p 8888" + fi echo "" fi diff --git a/studio/src-tauri/src/commands.rs b/studio/src-tauri/src/commands.rs index f2acb92e09..d139aebf49 100644 --- a/studio/src-tauri/src/commands.rs +++ b/studio/src-tauri/src/commands.rs @@ -184,8 +184,8 @@ pub fn get_server_logs(state: tauri::State<'_, BackendState>) -> Vec { /// Open the Unsloth Studio directory in the system file manager. #[tauri::command] pub fn open_logs_dir() -> Result<(), String> { - let home = dirs::home_dir().ok_or("Could not determine home directory")?; - let dir = home.join(".unsloth").join("studio"); + let dir = crate::studio_root::resolve_studio_root() + .ok_or("Could not determine Studio install root")?; if !dir.exists() { return Err(format!("Directory does not exist: {}", dir.display())); diff --git a/studio/src-tauri/src/desktop_auth.rs b/studio/src-tauri/src/desktop_auth.rs index 72a29f4f0e..18703123bc 100644 --- a/studio/src-tauri/src/desktop_auth.rs +++ b/studio/src-tauri/src/desktop_auth.rs @@ -69,19 +69,11 @@ fn home_dir() -> Result { dirs::home_dir().ok_or_else(|| "Could not determine home directory".to_string()) } -fn studio_root_from_env() -> Option { - for var in ["UNSLOTH_STUDIO_HOME", "STUDIO_HOME"] { - if let Some(value) = std::env::var_os(var) { - if !value.is_empty() { - return Some(PathBuf::from(value)); - } - } - } - None -} - fn desktop_secret_path() -> Result { - if let Some(studio) = studio_root_from_env() { + // Use the shared studio_root resolver so env vars (with ~ expansion) + // and the installer-written marker file are honored before falling + // back to ~/.unsloth/studio. + if let Some(studio) = crate::studio_root::resolve_studio_root() { return Ok(auth_secret_path_in_studio(&studio, ".desktop_secret")); } Ok(auth_secret_path(&home_dir()?, ".desktop_secret")) diff --git a/studio/src-tauri/src/install.rs b/studio/src-tauri/src/install.rs index 5760989353..51d3e78193 100644 --- a/studio/src-tauri/src/install.rs +++ b/studio/src-tauri/src/install.rs @@ -146,9 +146,13 @@ fn spawn_script( install.intentional_stop = false; install.needed_packages.clear(); - // Scripts create ~/.unsloth/studio/ themselves, but need a writable cwd. - let home = dirs::home_dir().ok_or("Could not determine home directory")?; - let work_dir = home.join(".unsloth"); + // Scripts create the Studio root themselves, but need a writable cwd. + // Resolve via the shared helper so a custom-root install does not + // create a stray ~/.unsloth on the user's machine. + let work_dir = crate::studio_root::resolve_studio_root() + .and_then(|studio| studio.parent().map(|p| p.to_path_buf())) + .or_else(|| dirs::home_dir().map(|h| h.join(".unsloth"))) + .ok_or("Could not determine Studio install root")?; if !work_dir.exists() { std::fs::create_dir_all(&work_dir) .map_err(|e| format!("Failed to create {}: {}", work_dir.display(), e))?; diff --git a/studio/src-tauri/src/main.rs b/studio/src-tauri/src/main.rs index f7d49cce60..d210f126ff 100644 --- a/studio/src-tauri/src/main.rs +++ b/studio/src-tauri/src/main.rs @@ -5,6 +5,7 @@ mod desktop_auth; mod install; mod preflight; mod process; +mod studio_root; mod update; mod windows_job; @@ -29,9 +30,10 @@ fn setup_logging() { simplelog::ColorChoice::Auto, )); - // Try to set up file logging to ~/.unsloth/studio/tauri.log - if let Some(home) = dirs::home_dir() { - let log_dir = home.join(".unsloth").join("studio"); + // File logging to /tauri.log. Honors UNSLOTH_STUDIO_HOME / + // STUDIO_HOME (with ~ expansion) and the installer-written marker file + // before falling back to ~/.unsloth/studio. + if let Some(log_dir) = studio_root::resolve_studio_root() { if fs::create_dir_all(&log_dir).is_ok() { let log_path = log_dir.join("tauri.log"); let rotated_path = log_dir.join("tauri.log.1"); diff --git a/studio/src-tauri/src/process.rs b/studio/src-tauri/src/process.rs index 6f014b5ac4..18b8b0debf 100644 --- a/studio/src-tauri/src/process.rs +++ b/studio/src-tauri/src/process.rs @@ -111,23 +111,15 @@ fn find_unsloth_binary_in_studio_dir(studio: &std::path::Path) -> Option Option { - // Honor UNSLOTH_STUDIO_HOME / STUDIO_HOME (in that priority order) so - // Tauri custom-root installs can locate the venv. Falls back to the - // legacy ~/.unsloth/studio when neither is set. - for var in ["UNSLOTH_STUDIO_HOME", "STUDIO_HOME"] { - if let Some(value) = std::env::var_os(var) { - if !value.is_empty() { - let studio = std::path::PathBuf::from(value); - if let Some(bin) = find_unsloth_binary_in_studio_dir(&studio) { - return Some(bin); - } - } + // Resolve via the shared studio_root helper so env vars (with ~ + // expansion) and the installer-written marker file are honored. Falls + // back to ~/.unsloth/studio when nothing else applies. + if let Some(studio) = crate::studio_root::resolve_studio_root() { + if let Some(bin) = find_unsloth_binary_in_studio_dir(&studio) { + return Some(bin); } } - - let home = dirs::home_dir()?; - let studio = home.join(".unsloth").join("studio"); - find_unsloth_binary_in_studio_dir(&studio) + None } #[cfg(test)] diff --git a/studio/src-tauri/src/studio_root.rs b/studio/src-tauri/src/studio_root.rs new file mode 100644 index 0000000000..ef9f0f10a9 --- /dev/null +++ b/studio/src-tauri/src/studio_root.rs @@ -0,0 +1,116 @@ +//! Shared resolver for the Studio install root inside the Tauri desktop app. +//! +//! Priority (highest first): +//! 1. `UNSLOTH_STUDIO_HOME` / `STUDIO_HOME` env vars (current process). +//! 2. `~/.unsloth/studio-home` marker file written by the installer in +//! env-override mode, so the desktop app launched from +//! Finder/Start Menu/Desktop (where the launching shell's env vars are +//! not inherited) still resolves the custom root. +//! 3. Legacy default `~/.unsloth/studio`. +//! +//! Mirrors the shell / PowerShell / Python resolvers in this PR so a +//! `UNSLOTH_STUDIO_HOME=~/studio` value is interpreted the same in every +//! component. + +use std::ffi::OsString; +use std::path::PathBuf; + +/// Expand a leading `~`, `~/...`, or `~\...` against `dirs::home_dir()`. +/// Empty values return `None`. Other absolute or relative paths pass +/// through unchanged. +pub fn expand_studio_home_value(value: OsString) -> Option { + if value.is_empty() { + return None; + } + if let Some(text) = value.to_str() { + if text == "~" { + return dirs::home_dir(); + } + if let Some(rest) = text + .strip_prefix("~/") + .or_else(|| text.strip_prefix("~\\")) + { + return dirs::home_dir().map(|home| home.join(rest)); + } + } + Some(PathBuf::from(value)) +} + +fn studio_root_from_env() -> Option { + for var in ["UNSLOTH_STUDIO_HOME", "STUDIO_HOME"] { + if let Some(value) = std::env::var_os(var) { + if let Some(path) = expand_studio_home_value(value) { + return Some(path); + } + } + } + None +} + +/// Marker file the installer writes (in env-override mode) so a fresh +/// desktop launch with no shell env vars can still find the custom root. +fn studio_root_from_marker() -> Option { + let home = dirs::home_dir()?; + let marker = home.join(".unsloth").join("studio-home"); + let raw = std::fs::read_to_string(&marker).ok()?; + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + expand_studio_home_value(OsString::from(trimmed)) +} + +/// Resolve the Studio install root using the priority chain. Returns +/// `None` only when the home directory cannot be determined and no env +/// override is set (extremely rare on supported platforms). +pub fn resolve_studio_root() -> Option { + if let Some(p) = studio_root_from_env() { + return Some(p); + } + if let Some(p) = studio_root_from_marker() { + return Some(p); + } + let home = dirs::home_dir()?; + Some(home.join(".unsloth").join("studio")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn expand_handles_literal_paths() { + let out = expand_studio_home_value(OsString::from("/srv/foo")).unwrap(); + assert_eq!(out, Path::new("/srv/foo")); + } + + #[test] + fn expand_returns_none_for_empty() { + assert!(expand_studio_home_value(OsString::new()).is_none()); + } + + #[test] + fn expand_handles_tilde_only() { + let out = expand_studio_home_value(OsString::from("~")); + if let Some(home) = dirs::home_dir() { + assert_eq!(out.unwrap(), home); + } + } + + #[test] + fn expand_handles_tilde_slash_prefix() { + let out = expand_studio_home_value(OsString::from("~/studio")).unwrap(); + if let Some(home) = dirs::home_dir() { + assert_eq!(out, home.join("studio")); + } + } + + #[test] + fn expand_handles_tilde_backslash_prefix() { + let out = expand_studio_home_value(OsString::from("~\\studio")).unwrap(); + if let Some(home) = dirs::home_dir() { + assert_eq!(out, home.join("studio")); + } + } +} diff --git a/unsloth_cli/commands/studio.py b/unsloth_cli/commands/studio.py index caf9050e19..593d5f4e1f 100644 --- a/unsloth_cli/commands/studio.py +++ b/unsloth_cli/commands/studio.py @@ -66,7 +66,10 @@ STUDIO_HOME, _STUDIO_HOME_IS_CUSTOM = _resolve_studio_home() # unsloth-zoo's import-time LLAMA_CPP_DEFAULT_DIR binding picks up the # correct build dir for GGUF export. if _STUDIO_HOME_IS_CUSTOM: - os.environ.setdefault("UNSLOTH_STUDIO_HOME", str(STUDIO_HOME)) + # Use truthy-check rather than setdefault so a blank env var (e.g. + # UNSLOTH_STUDIO_HOME=) doesn't suppress the inferred custom root. + if not os.environ.get("UNSLOTH_STUDIO_HOME"): + os.environ["UNSLOTH_STUDIO_HOME"] = str(STUDIO_HOME) # Mirror setup.sh / setup.ps1's legacy-equality check: when an env # override happens to equal the legacy default, llama.cpp still lives # at ~/.unsloth/llama.cpp (one shared build across legacy installs). @@ -75,7 +78,8 @@ if _STUDIO_HOME_IS_CUSTOM: _llama_dir = Path.home() / ".unsloth" / "llama.cpp" else: _llama_dir = STUDIO_HOME / "llama.cpp" - os.environ.setdefault("UNSLOTH_LLAMA_CPP_PATH", str(_llama_dir)) + if not os.environ.get("UNSLOTH_LLAMA_CPP_PATH"): + os.environ["UNSLOTH_LLAMA_CPP_PATH"] = str(_llama_dir) BOOTSTRAP_PASSWORD_FILE = ".bootstrap_password" DESKTOP_SECRET_FILE = ".desktop_secret" DEFAULT_ADMIN_USERNAME = "unsloth"