Compare commits
8 commits
main
...
fix/tauri-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8c7ca67b1 | ||
|
|
7d3e01a9c5 | ||
|
|
0bf893b19a | ||
|
|
b3ed1af1a8 | ||
|
|
1c5bbf9085 |
||
|
|
c23e91e009 | ||
|
|
1729454db0 | ||
|
|
47dd6367ee |
31 changed files with 1070 additions and 271 deletions
188
install.ps1
188
install.ps1
|
|
@ -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) {
|
||||
|
|
|
|||
87
install.sh
87
install.sh
|
|
@ -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" ""
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"}>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
329
studio/frontend/src/components/tauri/window-titlebar.tsx
Normal file
329
studio/frontend/src/components/tauri/window-titlebar.tsx
Normal 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")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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", ());
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 |
Binary file not shown.
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 151 KiB |
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue