Compare commits

...
Sign in to create a new pull request.

8 commits

Author SHA1 Message Date
shine1i
f8c7ca67b1 fix desktop auth runtime installer regressions 2026-04-28 10:44:22 +02:00
shine1i
7d3e01a9c5 fix desktop updater production channel 2026-04-28 10:12:21 +02:00
wasimysaid
0bf893b19a Scope setup failure exit to Tauri installer 2026-04-27 07:34:10 +02:00
wasimysaid
b3ed1af1a8 Fix desktop installer assets and setup script skew 2026-04-27 06:59:01 +02:00
Wasim Yousef Said
1c5bbf9085
Merge branch 'main' into fix/tauri-tray-and-installer 2026-04-27 05:58:57 +02:00
wasimysaid
c23e91e009 Fix desktop auth gate after backend startup 2026-04-27 05:40:49 +02:00
wasimysaid
1729454db0 feat(tauri): add linux windows custom titlebar 2026-04-25 05:51:36 +02:00
wasimysaid
47dd6367ee fix(tauri): dedupe tray and brand nsis installer 2026-04-25 04:40:21 +02:00
31 changed files with 1070 additions and 271 deletions

View file

@ -8,6 +8,29 @@ function Install-UnslothStudio {
$ErrorActionPreference = "Stop"
$script:UnslothVerbose = ($env:UNSLOTH_VERBOSE -eq "1")
# ── Tauri structured output ──
function Write-TauriLog {
param([string]$Tag, [string]$Message)
if ($TauriMode) {
Write-Host "[TAURI:$Tag] $Message"
}
}
function Exit-InstallFailure {
param(
[Parameter(Mandatory = $true)][string]$Message,
[int]$Code = 1
)
if ($Code -eq 0) { $Code = 1 }
Write-TauriLog "ERROR" $Message
if (Get-Command Restore-StudioVenvRollback -CommandType Function -ErrorAction SilentlyContinue) {
Restore-StudioVenvRollback
}
if ($TauriMode) {
exit $Code
}
}
# ── Parse flags ──
$StudioLocalInstall = $false
$PackageName = "unsloth"
@ -26,7 +49,7 @@ function Install-UnslothStudio {
$i++
if ($i -ge $argList.Count) {
Write-Host "[ERROR] --package requires an argument." -ForegroundColor Red
return
return (Exit-InstallFailure "--package requires an argument.")
}
$PackageName = $argList[$i]
}
@ -42,22 +65,14 @@ function Install-UnslothStudio {
$RepoRoot = (Resolve-Path (Split-Path -Parent $PSCommandPath)).Path
if (-not (Test-Path (Join-Path $RepoRoot "pyproject.toml"))) {
Write-Host "[ERROR] --local must be run from the unsloth repo root (pyproject.toml not found at $RepoRoot)" -ForegroundColor Red
return
return (Exit-InstallFailure "--local must be run from the unsloth repo root")
}
}
# Validate --package to prevent injection into shell/Python commands
if ($PackageName -notmatch '^[a-zA-Z0-9][a-zA-Z0-9._-]*$') {
Write-Host "[ERROR] --package name contains invalid characters (allowed: a-z A-Z 0-9 . _ -)" -ForegroundColor Red
return
}
# ── Tauri structured output ──
function Write-TauriLog {
param([string]$Tag, [string]$Message)
if ($TauriMode) {
Write-Host "[TAURI:$Tag] $Message"
}
return (Exit-InstallFailure "--package name contains invalid characters")
}
$PythonVersion = "3.13"
@ -630,7 +645,7 @@ shell.Run cmd, 0, False
step "winget" "not available" "Red"
substep "Install it from https://aka.ms/getwinget" "Yellow"
substep "or install Python $PythonVersion and uv manually, then re-run." "Yellow"
return
return (Exit-InstallFailure "winget is not available")
}
# ── Helper: detect a working Python 3.11-3.13 on the system ──
@ -749,7 +764,7 @@ shell.Run cmd, 0, False
Write-Host " Please install Python $PythonVersion manually from https://www.python.org/downloads/" -ForegroundColor Yellow
Write-Host " Make sure to check 'Add Python to PATH' during installation." -ForegroundColor Yellow
Write-Host " Then re-run this installer." -ForegroundColor Yellow
return
return (Exit-InstallFailure "Python installation failed")
}
}
@ -773,7 +788,7 @@ shell.Run cmd, 0, False
if (-not (Get-Command uv -ErrorAction SilentlyContinue)) {
step "uv" "could not be installed" "Red"
substep "Install it from https://docs.astral.sh/uv/" "Yellow"
return
return (Exit-InstallFailure "uv could not be installed")
}
# ── Create venv (migrate old layout if possible, otherwise fresh) ──
@ -786,11 +801,68 @@ shell.Run cmd, 0, False
$VenvPython = Join-Path $VenvDir "Scripts\python.exe"
$_Migrated = $false
$script:StudioVenvRollbackDir = $null
$script:StudioVenvRollbackTarget = $VenvDir
$script:StudioVenvRollbackActive = $false
function Start-StudioVenvRollback {
param([Parameter(Mandatory = $true)][string]$ExistingDir)
$stamp = Get-Date -Format "yyyyMMddHHmmss"
$candidate = Join-Path $StudioHome "unsloth_studio.rollback.$stamp.$PID"
$suffix = 0
while (Test-Path $candidate) {
$suffix++
$candidate = Join-Path $StudioHome "unsloth_studio.rollback.$stamp.$PID.$suffix"
}
Move-Item -Path $ExistingDir -Destination $candidate -ErrorAction Stop
$script:StudioVenvRollbackDir = $candidate
$script:StudioVenvRollbackTarget = $ExistingDir
$script:StudioVenvRollbackActive = $true
substep "previous environment preserved for rollback"
}
function Restore-StudioVenvRollback {
if (-not $script:StudioVenvRollbackActive) { return }
$backup = $script:StudioVenvRollbackDir
$target = $script:StudioVenvRollbackTarget
if (-not $backup -or -not (Test-Path $backup)) {
$script:StudioVenvRollbackActive = $false
return
}
substep "restoring previous environment after failed install..." "Yellow"
try {
if (Test-Path $target) {
Remove-Item -Recurse -Force $target -ErrorAction SilentlyContinue
}
Move-Item -Path $backup -Destination $target -Force -ErrorAction Stop
substep "restored previous environment"
$script:StudioVenvRollbackActive = $false
$script:StudioVenvRollbackDir = $null
} catch {
Write-Host "[WARN] Could not restore previous environment from $backup to $target" -ForegroundColor Yellow
Write-Host " $($_.Exception.Message)" -ForegroundColor Yellow
}
}
function Complete-StudioVenvRollback {
if (-not $script:StudioVenvRollbackActive) { return }
$backup = $script:StudioVenvRollbackDir
if ($backup -and (Test-Path $backup)) {
Remove-Item -Recurse -Force $backup -ErrorAction SilentlyContinue
}
$script:StudioVenvRollbackActive = $false
$script:StudioVenvRollbackDir = $null
}
if (Test-Path $VenvPython) {
# New layout already exists -- nuke for fresh install
substep "removing existing environment for fresh install..."
Remove-Item -Recurse -Force $VenvDir
# New layout already exists -- replace only after preserving rollback copy.
substep "preserving existing environment for rollback..."
try {
Start-StudioVenvRollback -ExistingDir $VenvDir
} catch {
Write-Host "[ERROR] Could not prepare existing environment for reinstall: $($_.Exception.Message)" -ForegroundColor Red
return (Exit-InstallFailure "Could not prepare existing environment for reinstall")
}
} elseif (Test-Path (Join-Path $StudioHome ".venv\Scripts\python.exe")) {
# Old layout (~/.unsloth/studio/.venv) exists -- validate before migrating
$OldVenv = Join-Path $StudioHome ".venv"
@ -799,18 +871,23 @@ shell.Run cmd, 0, False
$prevEAP2 = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
& $OldPy -c "import torch; A = torch.ones((2,2)); B = A + A" 2>$null | Out-Null
$torchOk = ($LASTEXITCODE -eq 0)
} catch { $torchOk = $false }
if ($SkipTorch) {
& $OldPy -c "import sys; print(sys.executable)" 2>$null | Out-Null
} else {
& $OldPy -c "import torch; A = torch.ones((2,2)); B = A + A" 2>$null | Out-Null
}
$legacyOk = ($LASTEXITCODE -eq 0)
} catch { $legacyOk = $false }
$ErrorActionPreference = $prevEAP2
if ($torchOk) {
if ($legacyOk) {
substep "legacy environment is healthy -- migrating..."
Move-Item -Path $OldVenv -Destination $VenvDir -Force
substep "moved .venv -> unsloth_studio"
$_Migrated = $true
} else {
substep "legacy environment failed validation -- creating fresh environment" "Yellow"
Remove-Item -Recurse -Force $OldVenv -ErrorAction SilentlyContinue
$invalidVenv = Join-Path $StudioHome (".venv.invalid.{0}.{1}" -f (Get-Date -Format "yyyyMMddHHmmss"), $PID)
Move-Item -Path $OldVenv -Destination $invalidVenv -Force -ErrorAction SilentlyContinue
}
} elseif (Test-Path (Join-Path $env:USERPROFILE "unsloth_studio\Scripts\python.exe")) {
# CWD-relative venv from old install.ps1 -- migrate to absolute path
@ -826,9 +903,8 @@ shell.Run cmd, 0, False
substep "$VenvDir"
$venvExit = Invoke-InstallCommand { uv venv $VenvDir --python "$($DetectedPython.Path)" }
if ($venvExit -ne 0) {
Write-TauriLog "ERROR" "Failed to create virtual environment (exit code $venvExit)"
Write-Host "[ERROR] Failed to create virtual environment (exit code $venvExit)" -ForegroundColor Red
return
return (Exit-InstallFailure "Failed to create virtual environment (exit code $venvExit)" $venvExit)
}
} else {
step "venv" "using migrated environment"
@ -946,14 +1022,14 @@ shell.Run cmd, 0, False
}
if ($baseInstallExit -ne 0) {
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
return
return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit)
}
if ($StudioLocalInstall) {
substep "overlaying local repo (editable)..."
$overlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython -e $RepoRoot --no-deps }
if ($overlayExit -ne 0) {
Write-Host "[ERROR] Failed to overlay local repo (exit code $overlayExit)" -ForegroundColor Red
return
return (Exit-InstallFailure "Failed to overlay local repo (exit code $overlayExit)" $overlayExit)
}
}
} elseif ($TorchIndexUrl) {
@ -964,9 +1040,8 @@ shell.Run cmd, 0, False
substep "installing PyTorch ($TorchIndexUrl)..."
$torchInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython "torch>=2.4,<2.11.0" torchvision torchaudio --index-url $TorchIndexUrl }
if ($torchInstallExit -ne 0) {
Write-TauriLog "ERROR" "Failed to install PyTorch (exit code $torchInstallExit)"
Write-Host "[ERROR] Failed to install PyTorch (exit code $torchInstallExit)" -ForegroundColor Red
return
return (Exit-InstallFailure "Failed to install PyTorch (exit code $torchInstallExit)" $torchInstallExit)
}
}
@ -988,9 +1063,8 @@ shell.Run cmd, 0, False
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth -- "$PackageName" }
}
if ($baseInstallExit -ne 0) {
Write-TauriLog "ERROR" "Failed to install unsloth (exit code $baseInstallExit)"
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
return
return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit)
}
if ($StudioLocalInstall) {
@ -998,7 +1072,7 @@ shell.Run cmd, 0, False
$overlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython -e $RepoRoot --no-deps }
if ($overlayExit -ne 0) {
Write-Host "[ERROR] Failed to overlay local repo (exit code $overlayExit)" -ForegroundColor Red
return
return (Exit-InstallFailure "Failed to overlay local repo (exit code $overlayExit)" $overlayExit)
}
}
} else {
@ -1009,53 +1083,23 @@ shell.Run cmd, 0, False
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.4.8" --torch-backend=auto }
if ($baseInstallExit -ne 0) {
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
return
return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit)
}
substep "overlaying local repo (editable)..."
$overlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython -e $RepoRoot --no-deps }
if ($overlayExit -ne 0) {
Write-Host "[ERROR] Failed to overlay local repo (exit code $overlayExit)" -ForegroundColor Red
return
return (Exit-InstallFailure "Failed to overlay local repo (exit code $overlayExit)" $overlayExit)
}
} else {
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --torch-backend=auto -- "$PackageName" }
if ($baseInstallExit -ne 0) {
Write-TauriLog "ERROR" "Failed to install unsloth (exit code $baseInstallExit)"
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
return
return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit)
}
}
}
# Hotfix: patch install_python_stack.py for Windows GUI stdout
# The PyPI version crashes with OSError when stdout is piped from a GUI app.
# Copy our fixed version (bundled by Tauri) over the installed one.
# Remove this block once PyPI ships the fix from commit 18c5aae7.
if ($TauriMode) {
$rawPath = if ($PSCommandPath) { $PSCommandPath } else { $MyInvocation.ScriptName }
$scriptDir = Split-Path -Parent ($rawPath -replace '^\\\\\?\\', '')
$fixedPy = Join-Path $scriptDir "install_python_stack.py"
$target = Join-Path $VenvDir "Lib\site-packages\studio\install_python_stack.py"
$sentinel = "# UNSLOTH_DESKTOP_HOTFIX_APPLIED_v1"
$sentinelPattern = [regex]::Escape($sentinel)
if ((Test-Path $fixedPy) -and (Test-Path $target)) {
$installed = Get-Content $target -Raw
if ($installed -notmatch $sentinelPattern) {
Copy-Item $fixedPy $target -Force
Add-Content -Path $target -Value "`n$sentinel"
substep "patched install_python_stack.py (stdout fix)"
} else {
substep "install_python_stack.py already has stdout fix"
}
} elseif ((Test-Path $fixedPy) -and (Test-Path (Split-Path $target))) {
Copy-Item $fixedPy $target -Force
Add-Content -Path $target -Value "`n$sentinel"
substep "patched install_python_stack.py (stdout fix)"
} else {
Write-Host "[WARN] Could not patch install_python_stack.py (bundled file or target dir missing)" -ForegroundColor Yellow
}
}
# ── Run studio setup ──
# setup.ps1 will handle installing Git, CMake, Visual Studio Build Tools,
# CUDA Toolkit, Node.js, and other dependencies automatically via winget.
@ -1063,12 +1107,11 @@ shell.Run cmd, 0, False
step "setup" "running unsloth studio setup..."
$UnslothExe = Join-Path $VenvDir "Scripts\unsloth.exe"
if (-not (Test-Path $UnslothExe)) {
Write-TauriLog "ERROR" "unsloth CLI was not installed correctly"
Write-Host "[ERROR] unsloth CLI was not installed correctly." -ForegroundColor Red
Write-Host " Expected: $UnslothExe" -ForegroundColor Yellow
Write-Host " This usually means an older unsloth version was installed that does not include the Studio CLI." -ForegroundColor Yellow
Write-Host " Try re-running the installer or see: https://github.com/unslothai/unsloth?tab=readme-ov-file#-quickstart" -ForegroundColor Yellow
return
return (Exit-InstallFailure "unsloth CLI was not installed correctly")
}
# Tell setup.ps1 to skip base package installation (install.ps1 already did it)
$env:SKIP_STUDIO_BASE = "1"
@ -1090,12 +1133,16 @@ shell.Run cmd, 0, False
# and bypass the fast-path version check from PR #4667.
$studioArgs = @('studio', 'setup')
if ($script:UnslothVerbose) { $studioArgs += '--verbose' }
& $UnslothExe @studioArgs
$setupExit = $LASTEXITCODE
$env:UNSLOTH_INSTALL_ROLLBACK_MANAGED = "1"
try {
& $UnslothExe @studioArgs
$setupExit = $LASTEXITCODE
} finally {
Remove-Item Env:UNSLOTH_INSTALL_ROLLBACK_MANAGED -ErrorAction SilentlyContinue
}
if ($setupExit -ne 0) {
Write-TauriLog "ERROR" "unsloth studio setup failed (exit code $setupExit)"
Write-Host "[ERROR] unsloth studio setup failed (exit code $setupExit)" -ForegroundColor Red
return
return (Exit-InstallFailure "unsloth studio setup failed (exit code $setupExit)" $setupExit)
}
# ── Expose `unsloth` via a shim dir containing only unsloth.exe ──
@ -1168,6 +1215,7 @@ shell.Run cmd, 0, False
step "path" "added unsloth launcher to PATH"
}
Refresh-SessionPath # sync current session with registry
Complete-StudioVenvRollback
# ── Tauri mode: done, skip shortcuts and auto-launch ──
if ($TauriMode) {

View file

@ -165,6 +165,60 @@ tauri_log() {
PYTHON_VERSION="" # resolved after platform detection
STUDIO_HOME="$HOME/.unsloth/studio"
VENV_DIR="$STUDIO_HOME/unsloth_studio"
_VENV_ROLLBACK_DIR=""
_VENV_ROLLBACK_TARGET="$VENV_DIR"
_VENV_ROLLBACK_ACTIVE=false
_start_studio_venv_replacement() {
_existing_dir="$1"
_stamp=$(date +%Y%m%d%H%M%S 2>/dev/null || echo "time")
_candidate="$STUDIO_HOME/unsloth_studio.rollback.$_stamp.$$"
_suffix=0
while [ -e "$_candidate" ]; do
_suffix=$((_suffix + 1))
_candidate="$STUDIO_HOME/unsloth_studio.rollback.$_stamp.$$.$_suffix"
done
mv "$_existing_dir" "$_candidate"
_VENV_ROLLBACK_DIR="$_candidate"
_VENV_ROLLBACK_TARGET="$_existing_dir"
_VENV_ROLLBACK_ACTIVE=true
substep "previous environment preserved for rollback"
}
_restore_studio_venv_replacement() {
[ "$_VENV_ROLLBACK_ACTIVE" = true ] || return 0
[ -n "$_VENV_ROLLBACK_DIR" ] && [ -d "$_VENV_ROLLBACK_DIR" ] || {
_VENV_ROLLBACK_ACTIVE=false
return 0
}
substep "restoring previous environment after failed install..." "$C_WARN"
rm -rf "$_VENV_ROLLBACK_TARGET"
if mv "$_VENV_ROLLBACK_DIR" "$_VENV_ROLLBACK_TARGET"; then
substep "restored previous environment"
_VENV_ROLLBACK_ACTIVE=false
_VENV_ROLLBACK_DIR=""
else
echo "⚠️ Could not restore previous environment from $_VENV_ROLLBACK_DIR to $_VENV_ROLLBACK_TARGET" >&2
fi
}
_commit_studio_venv_replacement() {
[ "$_VENV_ROLLBACK_ACTIVE" = true ] || return 0
if [ -n "$_VENV_ROLLBACK_DIR" ] && [ -d "$_VENV_ROLLBACK_DIR" ]; then
rm -rf "$_VENV_ROLLBACK_DIR" || true
fi
_VENV_ROLLBACK_ACTIVE=false
_VENV_ROLLBACK_DIR=""
}
_on_install_exit() {
_status=$?
if [ "$_status" -ne 0 ]; then
_restore_studio_venv_replacement
fi
exit "$_status"
}
trap _on_install_exit EXIT
# ── Helper: download a URL to a file (supports curl and wget) ──
download() {
@ -883,9 +937,15 @@ if [ -n "$MISSING" ]; then
if command -v apt-get >/dev/null 2>&1; then
_smart_apt_install $MISSING
else
echo " apt-get is not available. Please install with your package manager:"
echo " Automatic system package installation is supported on apt-based"
echo " Linux distributions (Ubuntu/Debian) only. Please install the"
echo " missing dependencies with your package manager, then re-run setup:"
echo " $MISSING"
echo " Then re-run Unsloth Studio setup."
echo ""
echo " Examples:"
echo " Fedora/RHEL: sudo dnf install cmake git gcc gcc-c++ make libcurl-devel"
echo " Arch: sudo pacman -S --needed cmake git base-devel curl"
echo " openSUSE: sudo zypper install cmake git gcc gcc-c++ make libcurl-devel"
exit 1
fi
;;
@ -957,12 +1017,19 @@ mkdir -p "$STUDIO_HOME"
_MIGRATED=false
if [ -x "$VENV_DIR/bin/python" ]; then
# New layout already exists — nuke for fresh install
rm -rf "$VENV_DIR"
# New layout already exists — replace only after preserving rollback copy.
substep "preserving existing environment for rollback..."
_start_studio_venv_replacement "$VENV_DIR"
elif [ -x "$STUDIO_HOME/.venv/bin/python" ]; then
# Old layout exists — validate before migrating
# Old layout exists — validate before migrating.
# In no-torch mode, a missing torch package is expected; validate Python only.
substep "found legacy Studio environment, validating..."
if "$STUDIO_HOME/.venv/bin/python" -c "
_legacy_ok=false
if [ "$SKIP_TORCH" = true ]; then
if "$STUDIO_HOME/.venv/bin/python" -c "import sys; print(sys.executable)" >/dev/null 2>&1; then
_legacy_ok=true
fi
elif "$STUDIO_HOME/.venv/bin/python" -c "
import torch
device = 'cuda' if torch.cuda.is_available() else 'cpu'
A = torch.ones((10, 10), device=device)
@ -972,13 +1039,17 @@ D = A + B
E = D @ C
torch.testing.assert_close(torch.unique(E), torch.tensor((20,), device=E.device, dtype=E.dtype))
" >/dev/null 2>&1; then
_legacy_ok=true
fi
if [ "$_legacy_ok" = true ]; then
echo "✅ Legacy environment is healthy — migrating..."
mv "$STUDIO_HOME/.venv" "$VENV_DIR"
echo " Moved ~/.unsloth/studio/.venv → $VENV_DIR"
_MIGRATED=true
else
echo "⚠️ Legacy environment failed validation — creating fresh environment"
rm -rf "$STUDIO_HOME/.venv"
_invalid_venv="$STUDIO_HOME/.venv.invalid.$(date +%Y%m%d%H%M%S 2>/dev/null || echo time).$$"
mv "$STUDIO_HOME/.venv" "$_invalid_venv" 2>/dev/null || true
fi
fi
@ -1679,6 +1750,8 @@ if [ "$_SETUP_EXIT" -ne 0 ]; then
exit "$_SETUP_EXIT"
fi
_commit_studio_venv_replacement
# ── Tauri mode: done, skip shortcuts and auto-launch ──
if [ "$TAURI_MODE" = true ]; then
tauri_log "DONE" ""

View file

@ -9,7 +9,6 @@ import {
hasRefreshToken,
mustChangePassword,
refreshSession,
tauriAutoAuth,
} from "@/features/auth";
async function hasActiveSession(): Promise<boolean> {
@ -39,7 +38,7 @@ function authRedirect(to: "/login" | "/change-password"): never {
export async function requireAuth(): Promise<void> {
if (isTauri) {
await tauriAutoAuth();
// AppProvider owns backend startup + desktop auth; route guards run before it mounts.
return;
}
@ -59,7 +58,6 @@ export async function requireAuth(): Promise<void> {
export async function requireGuest(): Promise<void> {
if (isTauri) {
await tauriAutoAuth();
throw redirect({ to: "/chat" });
}
if (!(await hasActiveSession())) return;
@ -68,7 +66,6 @@ export async function requireGuest(): Promise<void> {
export async function requirePasswordChangeFlow(): Promise<void> {
if (isTauri) {
await tauriAutoAuth();
throw redirect({ to: "/chat" });
}

View file

@ -4,12 +4,18 @@
import { StartupScreen } from "@/components/tauri/startup-screen";
import { UpdateBanner } from "@/components/tauri/update-banner";
import { UpdateScreen } from "@/components/tauri/update-screen";
import {
WindowTitlebar,
shouldUseCustomWindowTitlebar,
} from "@/components/tauri/window-titlebar";
import { Toaster } from "@/components/ui/sonner";
import { getTauriAuthFailure, tauriAutoAuth } from "@/features/auth";
import { useTauriBackend } from "@/hooks/use-tauri-backend";
import { useTauriUpdate } from "@/hooks/use-tauri-update";
import { isTauri } from "@/lib/api-base";
import { useRouterState } from "@tanstack/react-router";
import { ThemeProvider } from "next-themes";
import { useEffect, useRef, type ReactNode } from "react";
import { useEffect, useRef, useState, type ReactNode } from "react";
interface AppProviderProps {
children: ReactNode;
@ -119,7 +125,15 @@ function TauriUpdateLayer({ isExternalServer }: { isExternalServer: boolean }) {
);
}
const HIDDEN_TITLEBAR_SIDEBAR_ROUTES = new Set([
"/onboarding",
"/login",
"/change-password",
"/signup",
]);
function TauriWrapper({ children }: { children: ReactNode }) {
const pathname = useRouterState({ select: (s) => s.location.pathname });
const {
status, logs, error, isExternalServer,
currentStepIndex, progressDetail, elevationPackages,
@ -128,6 +142,8 @@ function TauriWrapper({ children }: { children: ReactNode }) {
const hasResized = useRef(false);
const abortRef = useRef(false);
const [desktopAuthReady, setDesktopAuthReady] = useState(!isTauri);
const [desktopAuthRetry, setDesktopAuthRetry] = useState(0);
// Show the window once the frontend mounts (for pre-running states)
useEffect(() => {
@ -150,16 +166,56 @@ function TauriWrapper({ children }: { children: ReactNode }) {
return () => { abortRef.current = true; };
}, [status]);
if (!isTauri) return <>{children}</>;
if (status === "running") return <><TauriUpdateLayer isExternalServer={isExternalServer} />{children}</>;
useEffect(() => {
if (!isTauri) {
setDesktopAuthReady(true);
return;
}
if (status !== "running") {
setDesktopAuthReady(false);
setDesktopAuthRetry(0);
return;
}
return (
let disposed = false;
setDesktopAuthReady(false);
tauriAutoAuth({ force: true }).then((authenticated) => {
if (disposed) return;
if (authenticated) {
setDesktopAuthReady(true);
return;
}
if (!getTauriAuthFailure()) {
window.setTimeout(() => {
if (!disposed) setDesktopAuthRetry((value) => value + 1);
}, 500);
}
});
return () => { disposed = true; };
}, [status, desktopAuthRetry]);
if (!isTauri) return <>{children}</>;
const showApp = status === "running" && desktopAuthReady;
const startupStatus = status === "running" ? "starting" : status;
const startupProgressDetail =
status === "running" && !desktopAuthReady
? "Signing in to desktop session..."
: progressDetail;
const content = showApp ? (
<>
<TauriUpdateLayer isExternalServer={isExternalServer} />
{children}
</>
) : (
<StartupScreen
status={status}
status={startupStatus}
logs={logs}
error={error}
currentStepIndex={currentStepIndex}
progressDetail={progressDetail}
progressDetail={startupProgressDetail}
elevationPackages={elevationPackages}
onInstall={startInstall}
onRetry={retry}
@ -168,6 +224,20 @@ function TauriWrapper({ children }: { children: ReactNode }) {
onStartServer={retry}
/>
);
if (!shouldUseCustomWindowTitlebar()) return content;
const showSidebarSurface =
showApp && !HIDDEN_TITLEBAR_SIDEBAR_ROUTES.has(pathname);
return (
<div className="flex h-dvh min-h-0 flex-col overflow-hidden bg-background [--studio-titlebar-height:34px]">
<WindowTitlebar showSidebarSurface={showSidebarSurface} />
<div className="min-h-0 flex-1 overflow-hidden">
{content}
</div>
</div>
);
}
export function AppProvider({ children }: AppProviderProps) {

View file

@ -81,7 +81,7 @@ function RootLayout() {
pinned={pinned}
setPinned={setPinned}
togglePinned={togglePinned}
className="!min-h-0 h-dvh overflow-hidden"
className="!min-h-0 h-[calc(100dvh-var(--studio-titlebar-height,0px))] overflow-hidden"
>
<AppSidebar />
<SidebarInset className={isChatRoute ? "overflow-hidden" : "overflow-y-auto"}>

View file

@ -381,7 +381,7 @@ export function StartupScreen({
}
return (
<div className="flex h-screen w-full flex-col items-center bg-background">
<div className="flex h-full w-full flex-col items-center bg-background">
<div className="flex flex-1 w-full max-w-md items-center justify-center px-6">
<AnimatePresence mode="wait">
<motion.div

View file

@ -100,7 +100,7 @@ export function UpdateScreen({
const isError = status === "error";
return (
<div className="flex h-screen w-full items-center justify-center bg-background">
<div className="flex h-full w-full items-center justify-center bg-background">
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}

View file

@ -0,0 +1,329 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { useSidebarPin } from "@/hooks/use-sidebar-pin";
import { isTauri } from "@/lib/api-base";
import { cn } from "@/lib/utils";
import type { Window as TauriWindow } from "@tauri-apps/api/window";
import {
type MouseEvent,
type ReactElement,
type ReactNode,
useCallback,
useEffect,
useState,
} from "react";
const CUSTOM_TITLEBAR_PLATFORMS = ["win", "linux", "x11"] as const;
type WindowResizeDirection =
| "East"
| "North"
| "NorthEast"
| "NorthWest"
| "South"
| "SouthEast"
| "SouthWest"
| "West";
type NavigatorWithUserAgentData = Navigator & {
userAgentData?: {
platform?: string;
};
};
function getClientPlatform(): string {
if (typeof navigator === "undefined") {
return "";
}
const nav = navigator as NavigatorWithUserAgentData;
return (
nav.userAgentData?.platform ??
navigator.platform ??
navigator.userAgent
).toLowerCase();
}
export function shouldUseCustomWindowTitlebar(): boolean {
if (!isTauri) {
return false;
}
const platform = getClientPlatform();
if (!platform || platform.includes("mac")) {
return false;
}
return CUSTOM_TITLEBAR_PLATFORMS.some((token) => platform.includes(token));
}
async function getAppWindow(): Promise<TauriWindow> {
const { getCurrentWindow } = await import("@tauri-apps/api/window");
return getCurrentWindow();
}
function WindowControlButton({
label,
className,
onClick,
children,
}: {
label: string;
className?: string;
onClick: () => void;
children: ReactNode;
}): ReactElement {
return (
<button
type="button"
aria-label={label}
title={label}
onClick={onClick}
className={cn(
"relative z-[80] inline-flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted/80 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
>
{children}
</button>
);
}
function MinimizeGlyph(): ReactElement {
return (
<span aria-hidden="true" className="h-px w-3.5 rounded-full bg-current" />
);
}
function MaximizeGlyph(): ReactElement {
return (
<span
aria-hidden="true"
className="size-3 rounded-[2px] border border-current"
/>
);
}
function RestoreGlyph(): ReactElement {
return (
<span aria-hidden="true" className="relative size-3.5">
<span className="absolute left-0.5 top-0 size-2.5 rounded-[2px] border border-current" />
<span className="absolute bottom-0 right-0 size-2.5 rounded-[2px] border border-current bg-muted" />
</span>
);
}
function CloseGlyph(): ReactElement {
return (
<span aria-hidden="true" className="relative size-3.5">
<span className="absolute left-1/2 top-0 h-3.5 w-px -translate-x-1/2 rotate-45 rounded-full bg-current" />
<span className="absolute left-1/2 top-0 h-3.5 w-px -translate-x-1/2 -rotate-45 rounded-full bg-current" />
</span>
);
}
export function WindowTitlebar({
showSidebarSurface = false,
}: {
showSidebarSurface?: boolean;
}): ReactElement | null {
const [enabled] = useState(shouldUseCustomWindowTitlebar);
const [maximized, setMaximized] = useState(false);
const { pinned } = useSidebarPin();
const refreshMaximized = useCallback(async () => {
if (!enabled) {
return;
}
try {
const appWindow = await getAppWindow();
setMaximized(await appWindow.isMaximized());
} catch {
// If a window permission is not ready yet, keep the previous visual state.
}
}, [enabled]);
useEffect(() => {
if (!enabled) {
return;
}
let mounted = true;
let unlistenResize: (() => void) | undefined;
let unlistenFocus: (() => void) | undefined;
const setupWindowListeners = async () => {
try {
const appWindow = await getAppWindow();
if (!mounted) {
return;
}
setMaximized(await appWindow.isMaximized());
unlistenResize = await appWindow.onResized(() => {
refreshMaximized().catch(() => undefined);
});
unlistenFocus = await appWindow.onFocusChanged(() => {
refreshMaximized().catch(() => undefined);
});
} catch {
// Missing capabilities should not break the rest of the app shell.
}
};
setupWindowListeners().catch(() => undefined);
return () => {
mounted = false;
unlistenResize?.();
unlistenFocus?.();
};
}, [enabled, refreshMaximized]);
const runWindowAction = useCallback(
(action: (appWindow: TauriWindow) => Promise<void>) => {
const runAction = async () => {
try {
const appWindow = await getAppWindow();
await action(appWindow);
await refreshMaximized();
} catch {
// Keep custom chrome inert rather than throwing into React on denied commands.
}
};
runAction().catch(() => undefined);
},
[refreshMaximized],
);
const handleDragMouseDown = useCallback(
(event: MouseEvent<HTMLDivElement>) => {
if (event.button !== 0 || event.detail > 1) {
return;
}
runWindowAction((appWindow) => appWindow.startDragging());
},
[runWindowAction],
);
const handleDragDoubleClick = useCallback(
(event: MouseEvent<HTMLDivElement>) => {
if (event.button !== 0) {
return;
}
runWindowAction((appWindow) => appWindow.toggleMaximize());
},
[runWindowAction],
);
const handleResizeMouseDown = useCallback(
(direction: WindowResizeDirection) =>
(event: MouseEvent<HTMLDivElement>) => {
if (event.button !== 0) {
return;
}
event.preventDefault();
event.stopPropagation();
runWindowAction(async (appWindow) => {
if (!(await appWindow.isResizable())) {
return;
}
await appWindow.startResizeDragging(direction);
});
},
[runWindowAction],
);
if (!enabled) {
return null;
}
return (
<>
<header
className="relative z-[60] flex h-[var(--studio-titlebar-height)] shrink-0 select-none items-center text-foreground"
aria-label="Window titlebar"
>
{showSidebarSurface && (
<div
className="h-full shrink-0 border-r border-sidebar-border bg-sidebar"
style={{ width: pinned ? "16rem" : "3rem" }}
onMouseDown={handleDragMouseDown}
onDoubleClick={handleDragDoubleClick}
aria-hidden="true"
/>
)}
<div
className="h-full min-w-0 flex-1 border-b border-border/35 bg-muted/35"
onMouseDown={handleDragMouseDown}
onDoubleClick={handleDragDoubleClick}
aria-hidden="true"
/>
<div
className="flex h-full shrink-0 items-center gap-0.5 border-b border-border/35 bg-muted/35 px-1"
role="toolbar"
aria-label="Window controls"
>
<WindowControlButton
label="Minimize window"
onClick={() => runWindowAction((appWindow) => appWindow.minimize())}
>
<MinimizeGlyph />
</WindowControlButton>
<WindowControlButton
label={maximized ? "Restore window" : "Maximize window"}
onClick={() =>
runWindowAction((appWindow) => appWindow.toggleMaximize())
}
>
{maximized ? <RestoreGlyph /> : <MaximizeGlyph />}
</WindowControlButton>
<WindowControlButton
label="Close window"
onClick={() => runWindowAction((appWindow) => appWindow.close())}
className="hover:bg-destructive hover:text-destructive-foreground focus-visible:ring-destructive/70"
>
<CloseGlyph />
</WindowControlButton>
</div>
</header>
<div
aria-hidden="true"
className="fixed inset-x-2 top-0 z-[70] h-1 cursor-n-resize"
onMouseDown={handleResizeMouseDown("North")}
/>
<div
aria-hidden="true"
className="fixed inset-x-2 bottom-0 z-[70] h-1 cursor-s-resize"
onMouseDown={handleResizeMouseDown("South")}
/>
<div
aria-hidden="true"
className="fixed inset-y-2 left-0 z-[70] w-1 cursor-w-resize"
onMouseDown={handleResizeMouseDown("West")}
/>
<div
aria-hidden="true"
className="fixed inset-y-2 right-0 z-[70] w-1 cursor-e-resize"
onMouseDown={handleResizeMouseDown("East")}
/>
<div
aria-hidden="true"
className="fixed left-0 top-0 z-[70] size-3 cursor-nw-resize"
onMouseDown={handleResizeMouseDown("NorthWest")}
/>
<div
aria-hidden="true"
className="fixed right-0 top-0 z-[70] size-3 cursor-ne-resize"
onMouseDown={handleResizeMouseDown("NorthEast")}
/>
<div
aria-hidden="true"
className="fixed bottom-0 left-0 z-[70] size-3 cursor-sw-resize"
onMouseDown={handleResizeMouseDown("SouthWest")}
/>
<div
aria-hidden="true"
className="fixed bottom-0 right-0 z-[70] size-3 cursor-se-resize"
onMouseDown={handleResizeMouseDown("SouthEast")}
/>
</>
);
}

View file

@ -7,7 +7,7 @@ import { AuthForm } from "./components/auth-form";
export function ChangePasswordPage() {
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background px-4 py-8 sm:px-6 sm:py-10 md:px-10">
<div className="relative flex min-h-[calc(100dvh-var(--studio-titlebar-height,0px))] items-center justify-center overflow-hidden bg-background px-4 py-8 sm:px-6 sm:py-10 md:px-10">
<LightRays
count={6}
color="rgba(34, 197, 94, 0.25)"

View file

@ -7,7 +7,7 @@ import { AuthForm } from "./components/auth-form";
export function LoginPage() {
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background px-4 py-8 sm:px-6 sm:py-10 md:px-10">
<div className="relative flex min-h-[calc(100dvh-var(--studio-titlebar-height,0px))] items-center justify-center overflow-hidden bg-background px-4 py-8 sm:px-6 sm:py-10 md:px-10">
<LightRays
count={6}
color="rgba(34, 197, 94, 0.25)"

View file

@ -15,9 +15,13 @@ type DesktopAuthResponse = {
refresh_token: string;
};
type TauriAutoAuthOptions = {
force?: boolean;
};
// Concurrency guard: multiple route guards can call tauriAutoAuth simultaneously.
// Without this, the first-launch password-change could race with itself.
let pending: Promise<boolean> | null = null;
let pending: { promise: Promise<boolean>; force: boolean } | null = null;
let lastTauriAuthFailure: string | null = null;
const TAURI_AUTH_FAILURE_FALLBACK =
@ -49,15 +53,15 @@ function isBackendNotReady(error: unknown): boolean {
return authFailureMessage(error).includes(BACKEND_NOT_READY_MESSAGE);
}
async function doTauriAutoAuth(): Promise<boolean> {
async function doTauriAutoAuth(options: TauriAutoAuthOptions): Promise<boolean> {
// Desktop must handle password-change state internally in Rust.
if (hasAuthToken() && !mustChangePassword()) {
if (!options.force && hasAuthToken() && !mustChangePassword()) {
clearTauriAuthFailure();
return true;
}
// Try refreshing existing session
if (hasRefreshToken()) {
if (!options.force && hasRefreshToken()) {
const refreshed = await refreshSession();
if (refreshed && hasAuthToken() && !mustChangePassword()) {
clearTauriAuthFailure();
@ -86,10 +90,17 @@ async function doTauriAutoAuth(): Promise<boolean> {
* Returns true if authentication succeeded.
* Concurrent calls are coalesced into a single in-flight attempt.
*/
export function tauriAutoAuth(): Promise<boolean> {
export function tauriAutoAuth(
options: TauriAutoAuthOptions = {},
): Promise<boolean> {
if (!isTauri) return Promise.resolve(false);
if (!pending) {
pending = doTauriAutoAuth().finally(() => { pending = null; });
const force = options.force === true;
if (!pending || (force && !pending.force)) {
let promise: Promise<boolean>;
promise = doTauriAutoAuth({ force }).finally(() => {
if (pending?.promise === promise) pending = null;
});
pending = { promise, force };
}
return pending;
return pending.promise;
}

View file

@ -399,7 +399,7 @@ export function DataRecipesPage(): ReactElement {
const isBusy = creatingRecipe || Boolean(loadingTemplateId);
return (
<div className="min-h-screen bg-background">
<div className="min-h-[calc(100dvh-var(--studio-titlebar-height,0px))] bg-background">
<main className="mx-auto w-full max-w-7xl px-6 py-8">
<div className="flex items-center justify-between gap-4">
<div>

View file

@ -28,7 +28,7 @@ function RecipeLoadState({
onBack: () => void;
}): ReactElement {
return (
<div className="min-h-screen bg-background">
<div className="min-h-[calc(100dvh-var(--studio-titlebar-height,0px))] bg-background">
<main className="mx-auto flex min-h-[70vh] w-full max-w-4xl items-center justify-center px-6 py-8">
<div className="w-full rounded-2xl border bg-card p-8 text-center">
<h1 className="text-lg font-semibold">{title}</h1>

View file

@ -523,7 +523,7 @@ export function ExportPage() {
// ---- Render ----
return (
<div className="min-h-screen bg-background">
<div className="min-h-[calc(100dvh-var(--studio-titlebar-height,0px))] bg-background">
<main className="mx-auto max-w-7xl px-4 py-4 sm:px-6">
<GuidedTour {...tour.tourProps} />

View file

@ -73,7 +73,7 @@ export function WizardLayout() {
}, [isFinalStep]);
return (
<div className="relative min-h-screen flex items-center justify-center overflow-hidden bg-gradient-to-br from-primary/5 via-background to-primary/3 p-4 sm:p-6 md:p-8">
<div className="relative flex min-h-[calc(100dvh-var(--studio-titlebar-height,0px))] items-center justify-center overflow-hidden bg-gradient-to-br from-primary/5 via-background to-primary/3 p-4 sm:p-6 md:p-8">
{showSplash && (
<SplashScreen
onStartOnboarding={() => setShowSplash(false)}

View file

@ -776,7 +776,7 @@ export function RecipeStudioPage({
}
return (
<div className="min-h-screen bg-background">
<div className="min-h-[calc(100dvh-var(--studio-titlebar-height,0px))] bg-background">
<main className="w-full px-6 py-8">
<div
className="relative w-full overflow-hidden rounded-2xl corner-squircle border"

View file

@ -127,7 +127,7 @@ export function StudioPage(): ReactElement {
})();
return (
<div className="relative min-h-screen bg-background">
<div className="relative min-h-[calc(100dvh-var(--studio-titlebar-height,0px))] bg-background">
<main className="relative z-10 mx-auto max-w-7xl px-4 py-4 sm:px-6">
<GuidedTour {...tour.tourProps} celebrate={isConfigTour} />

View file

@ -35,6 +35,54 @@ interface DesktopPreflightResult {
managed_bin: string | null;
}
const MANAGED_STARTUP_TIMEOUT_MS = 5 * 60_000;
const MANAGED_STARTUP_POLL_MS = 500;
type TauriInvoke = typeof import("@tauri-apps/api/core").invoke;
type ManagedStartupResult =
| { status: "ready"; port: number }
| { status: "aborted" }
| { status: "missing-port" }
| { status: "unhealthy" };
function wait(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitForManagedServerReady(
invoke: TauriInvoke,
getPort: () => number | null,
shouldContinue: () => boolean,
): Promise<ManagedStartupResult> {
const deadline = Date.now() + MANAGED_STARTUP_TIMEOUT_MS;
while (Date.now() < deadline) {
if (!shouldContinue()) {
return { status: "aborted" };
}
const port = getPort();
if (port === null) {
await wait(MANAGED_STARTUP_POLL_MS);
continue;
}
const healthy = await invoke<boolean>("check_health", { port });
if (!shouldContinue()) {
return { status: "aborted" };
}
if (healthy && getPort() === port) {
return { status: "ready", port };
}
await wait(MANAGED_STARTUP_POLL_MS);
}
return getPort() === null
? { status: "missing-port" }
: { status: "unhealthy" };
}
export function useTauriBackend() {
const [status, setStatus] = useState<BackendStatus>("checking");
const statusRef = useRef<BackendStatus>(status);
@ -181,8 +229,11 @@ export function useTauriBackend() {
async function startManagedServer() {
// Prevent double-start race condition
if (startingRef.current) return;
if (startingRef.current) {
return;
}
startingRef.current = true;
portRef.current = null;
try {
const { invoke } = await import("@tauri-apps/api/core");
@ -192,23 +243,27 @@ export function useTauriBackend() {
// Wait for the owned backend's server-port event. Do not attach to an
// external backend if the managed start does not report a port.
for (let i = 0; i < 120; i++) {
if (portRef.current) {
const healthy = await invoke<boolean>("check_health", {
port: portRef.current,
});
if (healthy) {
setApiBase(portRef.current);
setRunningStatus();
startingRef.current = false;
return;
}
}
await new Promise((r) => setTimeout(r, 500));
const startupResult = await waitForManagedServerReady(
invoke,
() => portRef.current,
() => startingRef.current,
);
if (startupResult.status === "ready") {
setApiBase(startupResult.port);
setRunningStatus();
startingRef.current = false;
return;
}
const message = !portRef.current
? "Managed server started without reporting a port. Check the logs for details."
: "Server started but is not responding. Check the logs for details.";
if (startupResult.status === "aborted") {
return;
}
const message =
startupResult.status === "missing-port"
? "Managed server started without reporting a port. Check the logs for details."
: "Server started but is not responding. Check the logs for details.";
setBackendError(message);
} catch (e) {
const msg = String(e);
@ -283,12 +338,12 @@ export function useTauriBackend() {
const { invoke } = await import("@tauri-apps/api/core");
try {
await invoke("start_install");
// Install completed — this is the ONLY path that starts the server
// after install. The install-complete event listener does NOT call
// Install completed — validate the managed binary's desktop capability
// before starting it. The install-complete event listener does NOT call
// startServer() to avoid a double-start race condition.
setBackendStatus("starting");
elevationResumeRef.current = null;
await startServer();
await checkInstallAndStart();
} catch (e) {
const msg = String(e);
// NEEDS_ELEVATION is not a real error — the Rust side also emits

View file

@ -1460,8 +1460,14 @@ substep "Using $PythonCmd ($(& $PythonCmd --version 2>&1))"
$VenvDir = Join-Path $env:USERPROFILE ".unsloth\studio\unsloth_studio"
# Stale-venv detection: if the venv exists but its torch flavor no longer
# matches the current machine, wipe it so we get a clean install.
if (Test-Path $VenvDir -PathType Container) {
# matches the current machine, repair according to invocation context.
# - install.ps1 sets UNSLOTH_INSTALL_ROLLBACK_MANAGED=1 so setup can delegate
# to the installer-level rollback that restores the previous environment.
# - direct `unsloth studio update` keeps the pre-existing self-repair behavior.
# In no-torch mode, a missing torch package is expected.
$NoTorchMode = $env:UNSLOTH_NO_TORCH -match '^(?i:true|1|yes)$'
$InstallerManagedSetup = $env:UNSLOTH_INSTALL_ROLLBACK_MANAGED -match '^(?i:true|1|yes)$'
if ((Test-Path $VenvDir -PathType Container) -and -not $NoTorchMode) {
$VenvPyExe = Join-Path $VenvDir "Scripts\python.exe"
$installedTorchTag = $null
$shouldRebuild = $false
@ -1508,6 +1514,12 @@ if (Test-Path $VenvDir -PathType Container) {
if ($shouldRebuild) {
$reason = if ($installedTorchTag) { "torch $installedTorchTag != required $expectedTorchTag" } else { "torch could not be imported" }
if ($InstallerManagedSetup) {
substep "Stale venv detected ($reason)." "Yellow"
Write-Host " [ERROR] The existing Studio environment needs repair." -ForegroundColor Red
Write-Host " Re-run install.ps1 so it can replace the environment safely with rollback." -ForegroundColor Yellow
exit 1
}
substep "Stale venv detected ($reason) -- rebuilding..." "Yellow"
try {
Remove-Item $VenvDir -Recurse -Force -ErrorAction Stop

View file

@ -1,6 +1,6 @@
[package]
name = "unsloth-studio"
version = "2026.4.7"
version = "2026.4.8"
description = "Unsloth Studio Desktop App"
authors = ["Unsloth AI"]
edition = "2021"

View file

@ -13,6 +13,11 @@
"core:window:allow-set-size-constraints",
"core:window:allow-center",
"core:window:allow-current-monitor",
"core:window:allow-start-dragging",
"core:window:allow-start-resize-dragging",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-close",
"core:tray:default",
"process:default",
{

View file

@ -2,8 +2,17 @@ use crate::install;
use crate::process::{self, BackendState, ShutdownFlag};
use crate::update;
use log::{error, info, warn};
use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter};
const BACKEND_STARTUP_GRACE_PERIOD: Duration = Duration::from_secs(5 * 60);
const HEALTH_WATCHDOG_INTERVAL: Duration = Duration::from_secs(15);
const HEALTH_WATCHDOG_MAX_FAILURES: u32 = 3;
fn should_count_watchdog_failure(has_seen_healthy: bool, elapsed_since_start: Duration) -> bool {
has_seen_healthy || elapsed_since_start >= BACKEND_STARTUP_GRACE_PERIOD
}
async fn managed_install_ready_after_repair() -> bool {
crate::preflight::managed_install_ready().await
}
@ -86,7 +95,7 @@ pub async fn start_server(
) -> Result<(), String> {
info!("start_server command called with port {}", port);
process::start_backend(&app, &state, port, &shutdown)?;
let generation = process::start_backend(&app, &state, port, &shutdown)?;
// Spawn health watchdog for the owned backend — detects
// deadlocks and hangs that stdout-based crash detection misses.
@ -94,7 +103,7 @@ pub async fn start_server(
let watchdog_shutdown = shutdown.inner().clone();
let watchdog_app = app.clone();
tokio::spawn(async move {
health_watchdog(watchdog_app, watchdog_state, watchdog_shutdown).await;
health_watchdog(watchdog_app, watchdog_state, watchdog_shutdown, generation).await;
});
Ok(())
@ -109,13 +118,13 @@ pub async fn start_managed_server(
port: u16,
) -> Result<(), String> {
info!("start_managed_server command called with port {}", port);
process::start_backend(&app, &state, port, &shutdown)?;
let generation = process::start_backend(&app, &state, port, &shutdown)?;
let watchdog_state = state.inner().clone();
let watchdog_shutdown = shutdown.inner().clone();
let watchdog_app = app.clone();
tokio::spawn(async move {
health_watchdog(watchdog_app, watchdog_state, watchdog_shutdown).await;
health_watchdog(watchdog_app, watchdog_state, watchdog_shutdown, generation).await;
});
Ok(())
@ -403,6 +412,8 @@ pub async fn start_managed_repair(
#[cfg(test)]
mod tests {
use std::time::Duration;
#[test]
fn repair_elevation_is_not_a_terminal_repair_failure() {
assert!(!super::should_emit_repair_failed("NEEDS_ELEVATION"));
@ -410,61 +421,119 @@ mod tests {
"Installer exited with code 1"
));
}
#[test]
fn watchdog_ignores_startup_failures_within_grace_period() {
assert!(!super::should_count_watchdog_failure(
false,
super::BACKEND_STARTUP_GRACE_PERIOD - Duration::from_secs(1)
));
}
#[test]
fn watchdog_counts_failures_after_backend_was_healthy() {
assert!(super::should_count_watchdog_failure(
true,
Duration::from_secs(1)
));
}
#[test]
fn watchdog_counts_startup_failures_after_grace_period() {
assert!(super::should_count_watchdog_failure(
false,
super::BACKEND_STARTUP_GRACE_PERIOD
));
}
}
/// Periodic health check that detects deadlocked or hung backends.
/// Starts 30s after the backend is launched (to allow initial startup),
/// then pings /api/health every 15s. After 3 consecutive failures (45s)
/// with the process still alive, emits `server-crashed` so the frontend
/// can offer a restart.
async fn health_watchdog(app: AppHandle, state: BackendState, shutdown: ShutdownFlag) {
/// During startup, failures are ignored for a generous grace period so a slow
/// but legitimate backend boot is not killed. After the backend has answered at
/// least once, or after the startup grace expires, 3 consecutive failed checks
/// emit `server-crashed` so the frontend can offer a restart.
async fn health_watchdog(
app: AppHandle,
state: BackendState,
shutdown: ShutdownFlag,
generation: u64,
) {
use std::sync::atomic::Ordering;
// Give the backend time to start up
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
let started_at = Instant::now();
let mut consecutive_failures: u32 = 0;
let mut has_seen_healthy = false;
loop {
tokio::time::sleep(std::time::Duration::from_secs(15)).await;
tokio::time::sleep(HEALTH_WATCHDOG_INTERVAL).await;
if shutdown.load(Ordering::SeqCst) {
info!("Health watchdog: shutdown flag set, exiting");
break;
}
let (port, has_child) = {
let (port, has_child, current_generation) = {
let proc = match state.lock() {
Ok(p) => p,
Err(_) => break,
};
(proc.port, proc.child.is_some())
(proc.port, proc.child.is_some(), proc.generation)
};
if current_generation != generation {
info!("Health watchdog: backend generation changed, exiting");
break;
}
// Stop watching if the backend is gone
if !has_child {
info!("Health watchdog: backend stopped, exiting");
break;
}
let should_count_failure =
should_count_watchdog_failure(has_seen_healthy, started_at.elapsed());
let Some(port) = port else {
continue; // Port not yet known
if should_count_failure {
consecutive_failures += 1;
warn!(
"Health watchdog: backend has not reported a port ({}/{})",
consecutive_failures, HEALTH_WATCHDOG_MAX_FAILURES
);
}
if consecutive_failures >= HEALTH_WATCHDOG_MAX_FAILURES {
error!(
"Health watchdog: backend never reported a port, killing and declaring dead"
);
let _ = process::stop_backend(&state, &shutdown);
let _ = app.emit("server-crashed", ());
break;
}
continue;
};
match check_health_inner(port).await {
Ok(true) => {
has_seen_healthy = true;
consecutive_failures = 0;
}
_ if !should_count_failure => {
info!(
"Health watchdog: startup health check failed on port {} before grace period elapsed",
port
);
}
_ => {
consecutive_failures += 1;
warn!(
"Health watchdog: failure {}/3 on port {}",
consecutive_failures, port
"Health watchdog: failure {}/{} on port {}",
consecutive_failures, HEALTH_WATCHDOG_MAX_FAILURES, port
);
if consecutive_failures >= 3 {
error!(
"Health watchdog: backend unresponsive for 45s, killing and declaring dead"
);
if consecutive_failures >= HEALTH_WATCHDOG_MAX_FAILURES {
error!("Health watchdog: backend unresponsive, killing and declaring dead");
// Kill the zombie process so retry can start fresh
let _ = process::stop_backend(&state, &shutdown);
let _ = app.emit("server-crashed", ());

View file

@ -1,6 +1,6 @@
use crate::preflight::{DesktopPreflightDisposition, DesktopPreflightResult};
use crate::process::BackendState;
use log::info;
use log::{info, warn};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
@ -39,13 +39,16 @@ struct BackendPort {
#[derive(Debug)]
enum AuthError {
Connectivity(String),
StaleResponder(String),
Failed(String),
}
impl AuthError {
fn message(self) -> String {
match self {
Self::Connectivity(message) | Self::Failed(message) => message,
Self::Connectivity(message) | Self::StaleResponder(message) | Self::Failed(message) => {
message
}
}
}
}
@ -73,11 +76,14 @@ fn read_secret_if_exists(path: &Path) -> Result<Option<String>, String> {
match std::fs::read_to_string(path) {
Ok(s) => Ok(Some(s.trim().to_string())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(format!(
"Failed to read auth secret at {}: {}",
path.display(),
e
)),
Err(e) => {
warn!(
"Desktop auth: ignoring unreadable auth secret at {}: {}",
path.display(),
e
);
Ok(None)
}
}
}
@ -138,7 +144,10 @@ fn classify_auth_send_error(error: reqwest::Error) -> AuthError {
fn should_retry_with_discovered_port(source: PortSource, error: &AuthError) -> bool {
matches!(
(source, error),
(PortSource::Cached, AuthError::Connectivity(_))
(
PortSource::Cached,
AuthError::Connectivity(_) | AuthError::StaleResponder(_)
)
)
}
@ -157,7 +166,7 @@ async fn exchange_desktop_secret(
.map_err(classify_auth_send_error)?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Err(AuthError::Failed(
return Err(AuthError::StaleResponder(
"Running Studio backend is too old for this desktop app. Update that backend and restart."
.to_string(),
));
@ -166,7 +175,7 @@ async fn exchange_desktop_secret(
return Ok(None);
}
if !response.status().is_success() {
return Err(AuthError::Failed("Desktop auth failed".to_string()));
return Err(AuthError::StaleResponder("Desktop auth failed".to_string()));
}
response
@ -213,6 +222,33 @@ async fn provision_desktop_auth() -> Result<(), String> {
))
}
async fn retry_on_discovered_port(
client: &Client,
state: &tauri::State<'_, BackendState>,
previous: BackendPort,
secret: &str,
) -> Result<Option<(Option<DesktopAuthResponse>, BackendPort)>, String> {
if previous.source != PortSource::Cached {
return Ok(None);
}
let Some(port) = discover_compatible_backend_port().await else {
return Ok(None);
};
if port == previous.port {
return Ok(None);
}
update_backend_port(state, port)?;
let backend = BackendPort {
port,
source: PortSource::Discovered,
};
exchange_desktop_secret(client, port, secret)
.await
.map(|tokens| Some((tokens, backend)))
.map_err(AuthError::message)
}
async fn authenticate_with_stale_port_retry(
client: &Client,
state: &tauri::State<'_, BackendState>,
@ -220,20 +256,18 @@ async fn authenticate_with_stale_port_retry(
secret: &str,
) -> Result<(Option<DesktopAuthResponse>, BackendPort), String> {
match exchange_desktop_secret(client, backend.port, secret).await {
Ok(tokens) => Ok((tokens, backend)),
Ok(Some(tokens)) => Ok((Some(tokens), backend)),
Ok(None) => {
if let Some(retried) = retry_on_discovered_port(client, state, backend, secret).await? {
return Ok(retried);
}
Ok((None, backend))
}
Err(error) if should_retry_with_discovered_port(backend.source, &error) => {
let Some(port) = discover_compatible_backend_port().await else {
return Err(error.message());
};
update_backend_port(state, port)?;
let backend = BackendPort {
port,
source: PortSource::Discovered,
};
exchange_desktop_secret(client, port, secret)
.await
.map(|tokens| (tokens, backend))
.map_err(AuthError::message)
if let Some(retried) = retry_on_discovered_port(client, state, backend, secret).await? {
return Ok(retried);
}
Err(error.message())
}
Err(error) => Err(error.message()),
}
@ -317,11 +351,15 @@ mod tests {
}
#[test]
fn retry_discovery_only_for_cached_connectivity_errors() {
fn retry_discovery_only_for_cached_recoverable_errors() {
assert!(should_retry_with_discovered_port(
PortSource::Cached,
&AuthError::Connectivity("connection refused".to_string())
));
assert!(should_retry_with_discovered_port(
PortSource::Cached,
&AuthError::StaleResponder("old responder".to_string())
));
assert!(!should_retry_with_discovered_port(
PortSource::Discovered,
&AuthError::Connectivity("connection refused".to_string())
@ -332,6 +370,64 @@ mod tests {
));
}
#[test]
fn read_secret_returns_none_for_missing_file() {
let path = std::env::temp_dir().join(format!(
"unsloth-missing-desktop-secret-{}",
std::process::id()
));
let _ = std::fs::remove_file(&path);
assert_eq!(read_secret_if_exists(&path).unwrap(), None);
}
#[test]
fn read_secret_trims_existing_file() {
let path =
std::env::temp_dir().join(format!("unsloth-desktop-secret-{}", std::process::id()));
std::fs::write(&path, " desktop-secret\n").unwrap();
assert_eq!(
read_secret_if_exists(&path).unwrap(),
Some("desktop-secret".to_string())
);
std::fs::remove_file(path).unwrap();
}
#[test]
fn read_secret_treats_invalid_utf8_as_missing_for_repair() {
let path = std::env::temp_dir().join(format!(
"unsloth-invalid-desktop-secret-{}",
std::process::id()
));
std::fs::write(&path, [0xff, 0xfe]).unwrap();
assert_eq!(read_secret_if_exists(&path).unwrap(), None);
std::fs::remove_file(path).unwrap();
}
#[cfg(unix)]
#[test]
fn read_secret_treats_permission_denied_as_missing_for_repair() {
use std::os::unix::fs::PermissionsExt;
let path = std::env::temp_dir().join(format!(
"unsloth-unreadable-desktop-secret-{}",
std::process::id()
));
std::fs::write(&path, "desktop-stale").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
let result = read_secret_if_exists(&path);
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
std::fs::remove_file(path).unwrap();
if matches!(result, Ok(Some(_))) {
return;
}
assert_eq!(result.unwrap(), None);
}
#[test]
fn attached_ready_port_requires_attached_ready_with_port() {
let compatible = DesktopPreflightResult {

View file

@ -481,7 +481,7 @@ pub fn stop_install(state: &InstallState) -> Result<(), String> {
}
}
/// Install system packages with elevated permissions (Linux only).
/// Install apt system packages with elevated permissions (Linux only).
/// Uses `elevated-command` crate for native auth dialog.
#[cfg(target_os = "linux")]
pub fn install_system_packages(packages: &[String]) -> Result<(), String> {
@ -497,42 +497,44 @@ pub fn install_system_packages(packages: &[String]) -> Result<(), String> {
}
}
// AppImage bundles run on non-Debian distros too. Pick the first package
// manager we find. Names in `packages` are Debian-style; callers that want
// cross-distro support should translate before invoking.
let (program, base_args): (&str, &[&str]) = if Path::new("/usr/bin/apt-get").exists() {
("apt-get", &["install", "-y"])
} else if Path::new("/usr/bin/dnf").exists() {
("dnf", &["install", "-y"])
} else if Path::new("/usr/bin/zypper").exists() {
("zypper", &["install", "-y"])
} else if Path::new("/usr/bin/pacman").exists() {
("pacman", &["-S", "--noconfirm"])
} else {
// install.sh reports Debian package names. Do not pass them to dnf,
// zypper, or pacman where names differ; show an explicit support boundary
// instead of offering an elevation flow that is likely to fail.
if !Path::new("/usr/bin/apt-get").exists() {
return Err(
"No supported system package manager found (apt-get, dnf, zypper, pacman)".to_string(),
"Automatic system package installation is supported on apt-based Linux distributions (Ubuntu/Debian) only. Install the missing dependencies with your package manager and retry."
.to_string(),
);
};
}
info!(
"[install] Elevated install of packages via {}: {}",
program,
"[install] Elevated install of apt packages: {}",
packages.join(", ")
);
let mut cmd = StdCommand::new(program);
cmd.args(base_args).args(packages);
let mut update_cmd = StdCommand::new("apt-get");
update_cmd.args(["update", "-y"]);
let elevated_update = elevated_command::Command::new(update_cmd)
.output()
.map_err(|e| format!("Elevated apt update failed: {}", e))?;
if !elevated_update.status.success() {
let stderr = String::from_utf8_lossy(&elevated_update.stderr);
return Err(format!("Package index update failed: {}", stderr));
}
let elevated = elevated_command::Command::new(cmd)
let mut install_cmd = StdCommand::new("apt-get");
install_cmd.args(["install", "-y"]).args(packages);
let elevated_install = elevated_command::Command::new(install_cmd)
.output()
.map_err(|e| format!("Elevated install failed: {}", e))?;
if !elevated.status.success() {
let stderr = String::from_utf8_lossy(&elevated.stderr);
if !elevated_install.status.success() {
let stderr = String::from_utf8_lossy(&elevated_install.stderr);
return Err(format!("Package installation failed: {}", stderr));
}
info!("[install] Elevated package install succeeded");
info!("[install] Elevated apt package install succeeded");
Ok(())
}

View file

@ -58,6 +58,30 @@ fn setup_logging() {
}
}
#[cfg(any(target_os = "windows", target_os = "linux"))]
fn setup_custom_titlebar(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
let window = app.get_webview_window("main").ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::NotFound, "main window not found")
})?;
window.set_decorations(false)?;
Ok(())
}
fn cleanup_child_processes(app: &tauri::AppHandle) {
if let Some(install_state) = app.try_state::<install::InstallState>() {
let _ = install::stop_install(&install_state);
}
if let Some(update_state) = app.try_state::<update::UpdateState>() {
let _ = update::stop_update(&update_state);
}
if let Some(backend_state) = app.try_state::<process::BackendState>() {
let shutdown = app
.try_state::<process::ShutdownFlag>()
.expect("ShutdownFlag must be managed");
let _ = process::stop_backend(&backend_state, &shutdown);
}
}
fn setup_tray(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
let open = MenuItemBuilder::with_id("open", "Open Studio").build(app)?;
let toggle = MenuItemBuilder::with_id("toggle", "Start/Stop Server").build(app)?;
@ -81,17 +105,15 @@ fn setup_tray(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
let _ = app.emit("tray-toggle-server", ());
}
"quit" => {
let install_state = app.state::<crate::install::InstallState>();
let _ = crate::install::stop_install(&install_state);
let update_state = app.state::<crate::update::UpdateState>();
let _ = crate::update::stop_update(&update_state);
// Detach the 5s graceful-wait so the tray click does not
// block the Tauri main loop. Exit runs the RunEvent::Exit
// safety net which also calls stop_backend synchronously.
let shutdown = app.state::<crate::process::ShutdownFlag>().inner().clone();
let backend_state = app.state::<crate::process::BackendState>().inner().clone();
crate::process::stop_backend_detached(backend_state, shutdown);
app.exit(0);
// Run cleanup off the menu callback, but only exit after the
// backend tree has been reaped. Exiting first can terminate this
// process while a detached cleanup thread is still waiting,
// leaving the backend orphaned.
let app_handle = app.clone();
std::thread::spawn(move || {
cleanup_child_processes(&app_handle);
app_handle.exit(0);
});
}
_ => {}
})
@ -153,6 +175,8 @@ fn main() {
desktop_auth::desktop_auth,
])
.setup(|app| {
#[cfg(any(target_os = "windows", target_os = "linux"))]
setup_custom_titlebar(app)?;
setup_tray(app)?;
Ok(())
})
@ -172,18 +196,7 @@ fn main() {
.run(|app, event| {
if let tauri::RunEvent::Exit = event {
// Cleanup on ALL exit paths — safety net for non-tray exits
if let Some(install_state) = app.try_state::<install::InstallState>() {
let _ = install::stop_install(&install_state);
}
if let Some(update_state) = app.try_state::<update::UpdateState>() {
let _ = update::stop_update(&update_state);
}
if let Some(backend_state) = app.try_state::<process::BackendState>() {
let shutdown = app
.try_state::<process::ShutdownFlag>()
.expect("ShutdownFlag must be managed");
let _ = process::stop_backend(&backend_state, &shutdown);
}
cleanup_child_processes(app);
}
});
}

View file

@ -15,6 +15,7 @@ pub struct BackendProcess {
pub port: Option<u16>,
pub logs: VecDeque<String>,
pub intentional_stop: bool,
pub generation: u64,
}
impl Default for BackendProcess {
@ -24,6 +25,7 @@ impl Default for BackendProcess {
port: None,
logs: VecDeque::with_capacity(MAX_LOG_LINES),
intentional_stop: false,
generation: 0,
}
}
}
@ -245,7 +247,7 @@ pub fn start_backend(
state: &BackendState,
port: u16,
shutdown: &ShutdownFlag,
) -> Result<(), String> {
) -> Result<u64, String> {
let bin = resolve_backend_binary()?;
shutdown.store(false, Ordering::SeqCst);
@ -308,17 +310,19 @@ pub fn start_backend(
let stderr = child.stderr().take();
// Store child in state
{
let generation = {
let mut proc = state.lock().map_err(|e| e.to_string())?;
proc.child = Some(child);
}
proc.generation = proc.generation.wrapping_add(1);
proc.generation
};
// Spawn stdout reader thread
if let Some(stdout) = stdout {
let app_handle = app.clone();
let state_clone = Arc::clone(state);
std::thread::spawn(move || {
read_output_stream(stdout, &app_handle, &state_clone, false);
read_output_stream(stdout, &app_handle, &state_clone, false, generation);
});
}
@ -327,11 +331,11 @@ pub fn start_backend(
let app_handle = app.clone();
let state_clone = Arc::clone(state);
std::thread::spawn(move || {
read_output_stream(stderr, &app_handle, &state_clone, true);
read_output_stream(stderr, &app_handle, &state_clone, true, generation);
});
}
Ok(())
Ok(generation)
}
/// Read lines from a child process stream (stdout or stderr).
@ -342,6 +346,7 @@ fn read_output_stream<R: std::io::Read>(
app: &AppHandle,
state: &BackendState,
is_stderr: bool,
generation: u64,
) {
let mut reader = std::io::BufReader::new(stream);
let port_re = Regex::new(r"TAURI_PORT=(\d+)").unwrap();
@ -360,26 +365,42 @@ fn read_output_stream<R: std::io::Read>(
};
// Check for TAURI_PORT on stdout only
if !is_stderr {
if let Some(caps) = port_re.captures(&text) {
if let Some(port_str) = caps.get(1) {
if let Ok(port) = port_str.as_str().parse::<u16>() {
info!("Detected backend port: {}", port);
if let Ok(mut proc) = state.lock() {
proc.port = Some(port);
}
let _ = app.emit("server-port", port);
}
let detected_port = if !is_stderr {
port_re
.captures(&text)
.and_then(|caps| caps.get(1))
.and_then(|port_str| port_str.as_str().parse::<u16>().ok())
} else {
None
};
// Buffer the log line only for the current backend generation.
// Old reader threads can briefly outlive a stop/start cycle;
// they must not overwrite the new backend's port or logs.
let current_generation = if let Ok(mut proc) = state.lock() {
if proc.generation != generation {
false
} else {
if let Some(port) = detected_port {
proc.port = Some(port);
}
if proc.logs.len() >= MAX_LOG_LINES {
proc.logs.pop_front();
}
proc.logs.push_back(log_line.clone());
true
}
} else {
false
};
if !current_generation {
break;
}
// Buffer the log line
if let Ok(mut proc) = state.lock() {
if proc.logs.len() >= MAX_LOG_LINES {
proc.logs.pop_front();
}
proc.logs.push_back(log_line.clone());
if let Some(port) = detected_port {
info!("Detected backend port: {}", port);
let _ = app.emit("server-port", port);
}
info!("[backend] {}", log_line);
@ -401,6 +422,9 @@ fn read_output_stream<R: std::io::Read>(
// Stream closed. Only the stdout reader checks for crashes.
if !is_stderr {
if let Ok(mut proc) = state.lock() {
if proc.generation != generation {
return;
}
let intentional = proc.intentional_stop;
let exited = if let Some(ref mut child) = proc.child {
match child.try_wait() {
@ -526,12 +550,3 @@ pub fn stop_backend(state: &BackendState, shutdown: &ShutdownFlag) -> Result<(),
Ok(())
}
}
/// Spawn `stop_backend` on a background thread and return immediately.
/// Used by the tray "quit" path so the 5s graceful-wait does not block
/// the Tauri main event loop before `app.exit(0)` fires.
pub fn stop_backend_detached(state: BackendState, shutdown: ShutdownFlag) {
std::thread::spawn(move || {
let _ = stop_backend(&state, &shutdown);
});
}

View file

@ -21,18 +21,13 @@
"visible": false,
"resizable": false
}
],
"trayIcon": {
"iconPath": "icons/icon.png",
"id": "main",
"tooltip": "Unsloth Studio (Desktop)"
}
]
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDJENjJENDYxMTQ1QTYyOEIKUldTTFlsb1VZZFJpTFY1aHgyRkRqZUdjWGg0Sm1BVEZoMDVWME5PdE42bjZiekFRc1Fsb0JNbmIK",
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEQ2RDhGMDMzNDNEMTlGMkQKUldRdG45RkRNL0RZMXZsaVo2TElUQlVHb1hVNWEyajhUOGFXeTdNVDFZTFdBVUtlZUh5L2wwWVYK",
"endpoints": [
"https://github.com/danielhanchen/unsloth-staging-2/releases/latest/download/latest.json"
"https://github.com/unslothai/unsloth/releases/latest/download/latest.json"
],
"windows": {
"installMode": "passive"
@ -42,11 +37,14 @@
"bundle": {
"active": true,
"createUpdaterArtifacts": true,
"publisher": "Unsloth AI",
"homepage": "https://unsloth.ai/",
"copyright": "© 2026 Unsloth AI. All rights reserved.",
"license": "AGPL-3.0-only",
"targets": ["app", "appimage", "deb", "dmg", "nsis"],
"resources": {
"../../install.sh": "install.sh",
"../../install.ps1": "install.ps1",
"../install_python_stack.py": "install_python_stack.py"
"../../install.ps1": "install.ps1"
},
"icon": [
"icons/32x32.png",
@ -54,6 +52,17 @@
"icons/icon.ico",
"icons/icon.icns"
],
"windows": {
"nsis": {
"installerHooks": "./windows/hooks.nsh",
"installerIcon": "./icons/icon.ico",
"headerImage": "./windows/branding/nsis-header.bmp",
"sidebarImage": "./windows/branding/nsis-sidebar.bmp",
"installMode": "currentUser",
"languages": ["English"],
"displayLanguageSelector": false
}
},
"linux": {
"deb": {
"postRemoveScript": "./linux/postremove.sh"

View file

@ -4,12 +4,6 @@
"signCommand": {
"cmd": "trusted-signing-cli",
"args": ["-e", "https://eus.codesigning.azure.net", "-d", "Unsloth Studio (Desktop)", "%1"]
},
"nsis": {
"installerHooks": "./windows/hooks.nsh",
"installerIcon": "./icons/icon.ico",
"headerImage": "./windows/branding/nsis-header.bmp",
"sidebarImage": "./windows/branding/nsis-sidebar.bmp"
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 151 KiB

Before After
Before After

View file

@ -1,8 +1,9 @@
; Unsloth Studio NSIS installer hooks
!macro NSIS_HOOK_POSTUNINSTALL
MessageBox MB_YESNO|MB_ICONQUESTION "Remove all Unsloth data ($PROFILE\.unsloth)?$\n$\nThis deletes installed models, training outputs, and configuration." IDNO skip_cleanup
RMDir /r "$PROFILE\.unsloth"
DetailPrint "Removed $PROFILE\.unsloth"
skip_cleanup:
; Desktop uninstall must not remove $PROFILE\.unsloth. The CLI/web
; installers also use that tree for environments, models, outputs, and
; configuration, and there has been no prior public desktop release whose
; private state needs cleanup here.
DetailPrint "Preserved shared Unsloth data at $PROFILE\.unsloth"
!macroend