* add unsloth studio desktop app

* Fix review findings

- studio/src-tauri/tauri.conf.json: retarget updater to staging repo
  (danielhanchen/unsloth-staging-2); switch to unslothai/unsloth on upstream merge.
- studio/src-tauri/linux/postremove.sh: drop the interactive read loop and the
  /home/* iteration. Package maintainer scripts must stay non-interactive and
  must not touch other users' data.
- studio/frontend/src/app/auth-guards.ts: honor tauriAutoAuth() boolean. Failed
  auto-auth now redirects to /login; requireGuest/requirePasswordChangeFlow
  only redirect to /chat when auth succeeds. The new early-return on failed
  auth is intentional so the login / change-password flows remain reachable
  when desktop auth is not yet established.
- studio/frontend/src/config/env.ts: keep fetched=false on health failure so
  later calls retry instead of caching the client-side platform guess.
- studio/src-tauri/src/install.rs: pick the available system package manager
  (apt-get, dnf, zypper, pacman); AppImage bundles run on non-Debian distros.
- studio/frontend/src/lib/open-link.ts + markdown-text/sources callers: return
  boolean from openLink so callers only preventDefault on handled URLs; relative
  hrefs now navigate natively.
- studio/frontend/src/features/settings/tabs/about-tab.tsx: fetch(apiUrl(...))
  so the version request targets the backend port in desktop mode. The bare
  /api/health predates the Tauri webview (blame: the earlier onboarding commit,
  which ran with same-origin frontend/backend); in desktop mode the webview
  origin is tauri://localhost so the bare path fails.
- install.ps1: gate the install_python_stack.py hotfix on a sentinel comment
  instead of a content regex; append the sentinel after applying so reruns
  are unambiguous.
- unsloth_cli/commands/studio.py _write_auth_secret: use the atomic mkstemp +
  os.replace path on Windows too; chmod calls are wrapped in try/except OSError.
- studio/src-tauri/src/preflight.rs probe_existing_backends: fan out the health
  probes concurrently; desktop-auth status still runs sequentially per candidate.
  reqwest::Client is internally Arc-wrapped so the in-loop .clone() is a
  refcount bump, not a deep clone; annotated inline.
- studio/src-tauri/src/preflight.rs run_cli_probe: wait() after kill() to reap
  the child, matching probe_cli_capability.
- studio/src-tauri/src/process.rs + main.rs: add stop_backend_detached and use
  it from the tray quit handler so the 5s graceful-wait does not block the
  Tauri main loop. RunEvent::Exit keeps the synchronous safety-net call.
- studio/backend/main.py: drop the permissive localhost CORS regex in
  api-only mode; the explicit allow_origins list is sufficient.
- .github/workflows/release-desktop.yml: drop max-parallel: 1 so platform
  builds run in parallel, and lift releaseBody to an env var so the three
  tauri-action invocations share one source of truth.

* Fix review findings (loop 2)

- studio/backend/auth/storage.py update_password: clear_desktop_secret()
  alongside clear_bootstrap_password() so rotating the admin password
  also revokes any previously provisioned .desktop_secret. Without this,
  an old local desktop credential keeps minting fresh admin tokens via
  /api/auth/desktop-login after a password rotation.
- studio/src-tauri/src/desktop_auth.rs provision_desktop_auth: wrap
  cmd.output().await in tokio::time::timeout(30s). DESKTOP_AUTH_LOCK is
  held across the whole desktop_auth flow, and previously a hanging
  `unsloth studio provision-desktop-auth` subprocess would pin the lock
  indefinitely and freeze every subsequent desktop_auth call.

* Add review tests

* Consolidate review tests

Merge review-added tests into the existing studio/backend/tests/test_desktop_auth.py
(the PR's authoritative desktop-auth test file). Drops three scaffolding files under
tests/python/ in favor of five focused tests next to the tests they extend:
- test_update_password_clears_desktop_secret (runtime)
- test_update_password_on_unknown_user_leaves_desktop_secret_intact (runtime)
- test_cli_provisioning_delegates_to_storage_create_desktop_secret (source-level)
- test_cli_connect_auth_db_reads_storage_db_path (source-level)
- test_desktop_auth_provision_has_bounded_timeout (Rust source-level)

* Revert auth-guards.ts Tauri branches to unconditional form

The review loop on PR 5144 introduced a regression: the isTauri branch of
requireAuth redirected to /login when tauriAutoAuth() returned false, and
requireGuest / requirePasswordChangeFlow silently fell through on the same
condition. The Tauri desktop app authenticates via a local auto-generated
secret; it must never surface /login or /change-password to the user. A
failed auto-auth should let the startup layer retry, not expose a password
form.

Restore the three Tauri branches to the author's original unconditional
form (requireAuth: return; requireGuest / requirePasswordChangeFlow: throw
redirect({to: '/chat'})). Keep the rest of the review fixes -- the
apiUrl() fetch wrapping, authRedirect helper, and fetchAuthStatus refactor
are all legitimate improvements and are preserved.

* Revert release-desktop.yml to author's version

The review loop's workflow-file tweaks (drop max-parallel: 1, lift releaseBody
to an env var) are cosmetic. OAuth tokens cannot push workflow-file changes,
and fine-grained PATs cannot honor maintainerCanModify on a third-party fork.
Reverting the workflow file to wasimysaid's version lets the push go through
without needing a classic PAT with both repo and workflow scopes.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
Co-authored-by: Daniel Han <unslothai@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Wasim Yousef Said 2026-04-23 13:50:10 +02:00 committed by GitHub
commit a5eb2e3d50
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
76 changed files with 13533 additions and 134 deletions

187
.github/workflows/release-desktop.yml vendored Normal file
View file

@ -0,0 +1,187 @@
name: Release Desktop App
on:
workflow_dispatch:
inputs:
draft:
description: 'Create as draft release'
type: boolean
default: true
permissions:
contents: write
jobs:
build:
strategy:
fail-fast: false
max-parallel: 1
matrix:
include:
- platform: macos-latest
args: '--target aarch64-apple-darwin'
label: macOS (Apple Silicon)
# - platform: macos-latest
# args: '--target x86_64-apple-darwin'
# label: macOS (Intel)
- platform: ubuntu-22.04
args: ''
label: Linux (x64)
- platform: windows-latest
args: ''
label: Windows (x64)
name: Build ${{ matrix.label }}
runs-on: ${{ matrix.platform }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
# ── Linux dependencies ──
- name: Install Linux dependencies
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev libssl-dev patchelf
# ── Node.js ──
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: 24
- name: Install frontend dependencies
working-directory: studio/frontend
run: npm install
# ── Rust ──
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Rust cache
uses: swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae
with:
workspaces: 'studio/src-tauri -> target'
# ── macOS: import signing certificate ──
- name: Import Apple certificate
if: matrix.platform == 'macos-latest'
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security set-keychain-settings -t 3600 -u build.keychain
security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
security find-identity -v -p codesigning build.keychain
rm -f certificate.p12
# ── Windows: install Azure Trusted Signing CLI ──
- name: Install trusted-signing-cli
if: matrix.platform == 'windows-latest'
run: |
cargo install trusted-signing-cli --version 0.9.0 --locked
echo "$env:USERPROFILE\.cargo\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
# ── Windows: verify signing CLI is accessible ──
- name: Verify trusted-signing-cli
if: matrix.platform == 'windows-latest'
run: |
Write-Output "PATH: $env:PATH"
Get-Command trusted-signing-cli -ErrorAction SilentlyContinue || Write-Output "trusted-signing-cli NOT in PATH"
trusted-signing-cli --version || Write-Output "trusted-signing-cli failed to run"
# ── Linux: build + sign + upload ──
- name: Build Linux app
if: matrix.platform == 'ubuntu-22.04'
uses: tauri-apps/tauri-action@fce9c6108b31ea247710505d3aaaa893ee6768d4
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
projectPath: studio
tagName: desktop-v__VERSION__
releaseName: 'Unsloth Studio (Desktop) v__VERSION__'
releaseBody: |
Desktop app for Unsloth Studio.
**macOS**: Download the Apple Silicon `.dmg`.
**Windows**: Download the `-setup.exe` installer.
**Linux**: Download `.deb` (Ubuntu/Debian) or `.AppImage` (universal).
> Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
> Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
releaseDraft: ${{ inputs.draft }}
prerelease: false
args: -v ${{ matrix.args }}
# ── macOS: build + sign + notarize + upload ──
- name: Build macOS app
if: matrix.platform == 'macos-latest'
uses: tauri-apps/tauri-action@fce9c6108b31ea247710505d3aaaa893ee6768d4
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
with:
projectPath: studio
tagName: desktop-v__VERSION__
releaseName: 'Unsloth Studio (Desktop) v__VERSION__'
releaseBody: |
Desktop app for Unsloth Studio.
**macOS**: Download the Apple Silicon `.dmg`.
**Windows**: Download the `-setup.exe` installer.
**Linux**: Download `.deb` (Ubuntu/Debian) or `.AppImage` (universal).
> Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
> Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
releaseDraft: ${{ inputs.draft }}
prerelease: false
args: -v ${{ matrix.args }}
# ── Windows: build + sign + upload ──
- name: Build Windows app
if: matrix.platform == 'windows-latest'
uses: tauri-apps/tauri-action@fce9c6108b31ea247710505d3aaaa893ee6768d4
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
AZURE_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_CERTIFICATE_PROFILE_NAME }}
with:
projectPath: studio
tagName: desktop-v__VERSION__
releaseName: 'Unsloth Studio (Desktop) v__VERSION__'
releaseBody: |
Desktop app for Unsloth Studio.
**macOS**: Download the Apple Silicon `.dmg`.
**Windows**: Download the `-setup.exe` installer.
**Linux**: Download `.deb` (Ubuntu/Debian) or `.AppImage` (universal).
> Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
> Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
releaseDraft: ${{ inputs.draft }}
prerelease: false
args: -v ${{ matrix.args }}

12
.gitignore vendored
View file

@ -204,6 +204,18 @@ tmp/
**/node_modules/
auth.db
# Tauri local build/generated output
studio/src-tauri/target/
studio/src-tauri/gen/
studio/src-tauri/artifacts/
studio/src-tauri/icons/android/
studio/src-tauri/icons/ios/
studio/src-tauri/icons/128x128@2x.png
studio/src-tauri/icons/64x64.png
studio/src-tauri/icons/Square*Logo.png
studio/src-tauri/icons/StoreLogo.png
studio/src-tauri/icons/squarehq.png
# Local working docs
**/CLAUDE.md
**/claude.md

View file

@ -12,11 +12,13 @@ function Install-UnslothStudio {
$StudioLocalInstall = $false
$PackageName = "unsloth"
$RepoRoot = ""
$TauriMode = $false
$SkipTorch = $false
$argList = $args
for ($i = 0; $i -lt $argList.Count; $i++) {
switch ($argList[$i]) {
"--local" { $StudioLocalInstall = $true }
"--tauri" { $TauriMode = $true }
"--no-torch" { $SkipTorch = $true }
"--verbose" { $script:UnslothVerbose = $true }
"-v" { $script:UnslothVerbose = $true }
@ -44,6 +46,20 @@ function Install-UnslothStudio {
}
}
# 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"
}
}
$PythonVersion = "3.13"
$StudioHome = Join-Path $env:USERPROFILE ".unsloth\studio"
$VenvDir = Join-Path $StudioHome "unsloth_studio"
@ -609,6 +625,7 @@ shell.Run cmd, 0, False
}
# ── Check winget ──
Write-TauriLog "STEP" "Checking system dependencies"
if (-not (Get-Command winget -ErrorAction SilentlyContinue)) {
step "winget" "not available" "Red"
substep "Install it from https://aka.ms/getwinget" "Yellow"
@ -688,6 +705,7 @@ shell.Run cmd, 0, False
# ── Install Python if no compatible version (3.11-3.13) found ──
# Find-CompatiblePython returns @{ Version = "3.13"; Path = "C:\...\python.exe" } or $null.
Write-TauriLog "STEP" "Installing Python"
$DetectedPython = Find-CompatiblePython
if ($DetectedPython) {
step "python" "Python $($DetectedPython.Version) already installed"
@ -736,6 +754,7 @@ shell.Run cmd, 0, False
}
# ── Install uv if not present ──
Write-TauriLog "STEP" "Installing uv package manager"
if (-not (Get-Command uv -ErrorAction SilentlyContinue)) {
substep "installing uv package manager..."
$prevEAP = $ErrorActionPreference
@ -746,7 +765,7 @@ shell.Run cmd, 0, False
# Fallback: if winget didn't put uv on PATH, try the PowerShell installer
if (-not (Get-Command uv -ErrorAction SilentlyContinue)) {
substep "trying alternative uv installer..." "Yellow"
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
Invoke-Expression (Invoke-RestMethod -Uri "https://astral.sh/uv/install.ps1")
Refresh-SessionPath
}
}
@ -760,6 +779,7 @@ shell.Run cmd, 0, False
# ── Create venv (migrate old layout if possible, otherwise fresh) ──
# Pass the resolved executable path to uv so it does not re-resolve
# a version string back to a conda interpreter.
Write-TauriLog "STEP" "Creating virtual environment"
if (-not (Test-Path $StudioHome)) {
New-Item -ItemType Directory -Path $StudioHome -Force | Out-Null
}
@ -806,6 +826,7 @@ 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
}
@ -908,6 +929,7 @@ shell.Run cmd, 0, False
if ($_Migrated) {
# Migrated env: force-reinstall unsloth+unsloth-zoo to ensure clean state
# in the new venv location, while preserving existing torch/CUDA
Write-TauriLog "STEP" "Installing unsloth"
substep "upgrading unsloth in migrated environment..."
if ($SkipTorch) {
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
@ -938,14 +960,17 @@ shell.Run cmd, 0, False
if ($SkipTorch) {
substep "skipping PyTorch (--no-torch flag set)." "Yellow"
} else {
Write-TauriLog "STEP" "Installing PyTorch"
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
}
}
Write-TauriLog "STEP" "Installing unsloth"
substep "installing unsloth (this may take a few minutes)..."
if ($SkipTorch) {
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
@ -960,9 +985,10 @@ shell.Run cmd, 0, False
} elseif ($StudioLocalInstall) {
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.4.7" unsloth-zoo }
} else {
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "$PackageName" }
$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
}
@ -977,6 +1003,7 @@ shell.Run cmd, 0, False
}
} else {
# Fallback: GPU detection failed to produce a URL -- let uv resolve torch
Write-TauriLog "STEP" "Installing unsloth"
substep "installing unsloth (this may take a few minutes)..."
if ($StudioLocalInstall) {
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.4.7" --torch-backend=auto }
@ -991,20 +1018,52 @@ shell.Run cmd, 0, False
return
}
} else {
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython "$PackageName" --torch-backend=auto }
$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
}
}
}
# 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.
Write-TauriLog "STEP" "Running studio setup"
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
@ -1015,6 +1074,8 @@ shell.Run cmd, 0, False
$env:SKIP_STUDIO_BASE = "1"
$env:STUDIO_PACKAGE_NAME = $PackageName
$env:UNSLOTH_NO_TORCH = if ($SkipTorch) { "true" } else { "false" }
# Tauri desktop app bundles its own frontend — skip Node/npm/frontend build
$env:SKIP_STUDIO_FRONTEND = if ($TauriMode) { "1" } else { "0" }
# Always set STUDIO_LOCAL_INSTALL explicitly to avoid stale values from
# a previous --local run in the same PowerShell session.
if ($StudioLocalInstall) {
@ -1032,12 +1093,11 @@ shell.Run cmd, 0, False
& $UnslothExe @studioArgs
$setupExit = $LASTEXITCODE
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
}
New-StudioShortcuts -UnslothExePath $UnslothExe
# ── Expose `unsloth` via a shim dir containing only unsloth.exe ──
# We do NOT add the venv Scripts dir to PATH (it also holds python.exe
# and pip.exe, which would hijack the user's system interpreter).
@ -1109,6 +1169,14 @@ shell.Run cmd, 0, False
}
Refresh-SessionPath # sync current session with registry
# ── Tauri mode: done, skip shortcuts and auto-launch ──
if ($TauriMode) {
Write-TauriLog "DONE" ""
return
}
New-StudioShortcuts -UnslothExePath $UnslothExe
# Launch studio automatically in interactive terminals;
# in non-interactive environments (CI, Docker) just print instructions.
$IsInteractive = [Environment]::UserInteractive -and (-not [Console]::IsInputRedirected)

View file

@ -35,6 +35,7 @@ substep() { printf " ${C_DIM}%-15s${2:-$C_DIM}%s${C_RST}\n" "" "$1"; }
# ── Parse flags ──
STUDIO_LOCAL_INSTALL=false
PACKAGE_NAME="unsloth"
TAURI_MODE=false
_USER_PYTHON=""
_NO_TORCH_FLAG=false
_VERBOSE=false
@ -54,6 +55,7 @@ for arg in "$@"; do
case "$arg" in
--local) STUDIO_LOCAL_INSTALL=true ;;
--package) _next_is_package=true ;;
--tauri) TAURI_MODE=true ;;
--python) _next_is_python=true ;;
--no-torch) _NO_TORCH_FLAG=true ;;
--verbose|-v) _VERBOSE=true ;;
@ -142,6 +144,24 @@ if [ "$_next_is_python" = true ]; then
exit 1
fi
# Validate --package to prevent injection into shell/Python commands.
# Must start with a letter/digit (rejects leading dashes that uv would parse as flags).
case "$PACKAGE_NAME" in
[!a-zA-Z0-9]*)
echo "❌ ERROR: --package name must start with a letter or digit." >&2
exit 1 ;;
*[!a-zA-Z0-9._-]*)
echo "❌ ERROR: --package name contains invalid characters (allowed: a-z A-Z 0-9 . _ -)" >&2
exit 1 ;;
esac
# ── Tauri structured output ──
tauri_log() {
if [ "$TAURI_MODE" = true ]; then
echo "[TAURI:$1] $2"
fi
}
PYTHON_VERSION="" # resolved after platform detection
STUDIO_HOME="$HOME/.unsloth/studio"
VENV_DIR="$STUDIO_HOME/unsloth_studio"
@ -192,6 +212,12 @@ _smart_apt_install() {
return 0
fi
# In Tauri mode, report needed packages and exit — Rust handles elevation
if [ "$TAURI_MODE" = true ]; then
tauri_log "NEED_SUDO" "$_STILL_MISSING"
exit 2
fi
# Step 3: Escalate -- need elevated permissions for remaining packages
if command -v sudo >/dev/null 2>&1; then
echo ""
@ -752,6 +778,7 @@ printf " ${C_DIM}%s${C_RST}\n" "$RULE"
echo ""
# ── Detect platform ──
tauri_log "STEP" "Detecting platform"
OS="linux"
if [ "$(uname)" = "Darwin" ]; then
OS="macos"
@ -804,6 +831,7 @@ fi
# ── Check system dependencies ──
# cmake and git are needed by unsloth studio setup to build the GGUF inference
# engine (llama.cpp). build-essential and libcurl-dev are also needed on Linux.
tauri_log "STEP" "Checking system dependencies"
MISSING=""
command -v cmake >/dev/null 2>&1 || MISSING="$MISSING cmake"
@ -868,6 +896,7 @@ else
fi
# ── Install uv ──
tauri_log "STEP" "Installing uv package manager"
UV_MIN_VERSION="0.7.14"
version_ge() {
@ -922,6 +951,7 @@ if ! command -v uv >/dev/null 2>&1 || ! _uv_version_ok uv; then
fi
# ── Create venv (migrate old layout if possible, otherwise fresh) ──
tauri_log "STEP" "Creating virtual environment"
mkdir -p "$STUDIO_HOME"
_MIGRATED=false
@ -1304,6 +1334,7 @@ case "$TORCH_INDEX_URL" in
esac
# ── Install unsloth directly into the venv (no activation needed) ──
tauri_log "STEP" "Installing PyTorch"
_VENV_PY="$VENV_DIR/bin/python"
if [ "$_MIGRATED" = true ]; then
# Migrated env: force-reinstall unsloth+unsloth-zoo to ensure clean state
@ -1481,6 +1512,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
esac
fi
# Fresh: Step 2 - install unsloth, preserving pre-installed torch
tauri_log "STEP" "Installing Unsloth"
substep "installing unsloth (this may take a few minutes)..."
if [ "$SKIP_TORCH" = true ]; then
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
@ -1503,7 +1535,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps
else
run_install_cmd "install unsloth" uv pip install --python "$_VENV_PY" \
--upgrade-package unsloth "$PACKAGE_NAME"
--upgrade-package unsloth -- "$PACKAGE_NAME"
fi
# AMD ROCm: repair torch if the unsloth/unsloth-zoo install pulled in
# CUDA torch from PyPI, overwriting the ROCm wheels installed in Step 1.
@ -1523,17 +1555,19 @@ elif [ -n "$TORCH_INDEX_URL" ]; then
fi
else
# Fallback: GPU detection failed to produce a URL -- let uv resolve torch
tauri_log "STEP" "Installing Unsloth"
substep "installing unsloth (this may take a few minutes)..."
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.4.7" --torch-backend=auto
substep "overlaying local repo (editable)..."
run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps
else
run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" "$PACKAGE_NAME" --torch-backend=auto
run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" --torch-backend=auto -- "$PACKAGE_NAME"
fi
fi
# ── Run studio setup ──
tauri_log "STEP" "Running Studio setup"
# When --local, use the repo's own setup.sh directly.
# Otherwise, find it inside the installed package.
SETUP_SH=""
@ -1554,6 +1588,7 @@ if [ -z "$SETUP_SH" ] || [ ! -f "$SETUP_SH" ]; then
fi
if [ -z "$SETUP_SH" ] || [ ! -f "$SETUP_SH" ]; then
tauri_log "ERROR" "Could not find studio/setup.sh in the installed package"
echo "❌ ERROR: Could not find studio/setup.sh in the installed package."
exit 1
fi
@ -1571,16 +1606,16 @@ if ! command -v bash >/dev/null 2>&1; then
fi
step "setup" "running unsloth studio update..."
# install.sh already installs base packages (unsloth + unsloth-zoo) and
# no-torch-runtime.txt above, so tell install_python_stack.py to skip
# the base step to avoid redundant reinstallation.
_SKIP_BASE=1
# Run setup.sh outside set -e so that a llama.cpp build failure (exit 1)
# does not skip PATH setup, shortcuts, and launch below. We capture the
# exit code and propagate it after post-install steps finish.
_SETUP_EXIT=0
# Tauri desktop app bundles its own frontend — skip Node/npm/frontend build
_SKIP_FRONTEND=0
if [ "$TAURI_MODE" = true ]; then
_SKIP_FRONTEND=1
fi
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
SKIP_STUDIO_BASE="$_SKIP_BASE" \
SKIP_STUDIO_FRONTEND="$_SKIP_FRONTEND" \
STUDIO_PACKAGE_NAME="$PACKAGE_NAME" \
STUDIO_LOCAL_INSTALL=1 \
STUDIO_LOCAL_REPO="$_REPO_ROOT" \
@ -1593,6 +1628,7 @@ else
# local-dev path in setup.sh and install_python_stack.py. Mirrors the
# reset already done in install.ps1 for PowerShell.
SKIP_STUDIO_BASE="$_SKIP_BASE" \
SKIP_STUDIO_FRONTEND="$_SKIP_FRONTEND" \
STUDIO_PACKAGE_NAME="$PACKAGE_NAME" \
STUDIO_LOCAL_INSTALL=0 \
STUDIO_LOCAL_REPO= \
@ -1629,23 +1665,26 @@ case ":$PATH:" in
;;
esac
create_studio_shortcuts "$VENV_ABS_BIN/unsloth" "$OS"
# Non-Tauri installs keep shortcuts even if setup reports failure.
if [ "$TAURI_MODE" != true ]; then
create_studio_shortcuts "$VENV_ABS_BIN/unsloth" "$OS"
fi
# If setup.sh failed, report and exit now.
# PATH and shortcuts are already set up so the user can fix and retry.
if [ "$_SETUP_EXIT" -ne 0 ]; then
echo ""
step "error" "studio setup failed (exit code $_SETUP_EXIT)" "$C_ERR"
substep "Check the output above for details, then re-run:"
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
substep " unsloth studio update --local"
else
substep " unsloth studio update"
fi
echo ""
exit "$_SETUP_EXIT"
fi
# ── Tauri mode: done, skip shortcuts and auto-launch ──
if [ "$TAURI_MODE" = true ]; then
tauri_log "DONE" ""
exit 0
fi
echo ""
printf " ${C_TITLE}%s${C_RST}\n" "Unsloth Studio installed!"
printf " ${C_DIM}%s${C_RST}\n" "$RULE"

View file

@ -52,6 +52,8 @@ def _decode_subject_without_verification(token: str) -> Optional[str]:
def create_access_token(
subject: str,
expires_delta: Optional[timedelta] = None,
*,
desktop: bool = False,
) -> str:
"""
Create a signed JWT for the given subject (e.g. username).
@ -59,6 +61,8 @@ def create_access_token(
Tokens are valid across restarts because the signing secret is stored in SQLite.
"""
to_encode = {"sub": subject}
if desktop:
to_encode["desktop"] = True
expire = datetime.now(timezone.utc) + (
expires_delta or timedelta(minutes = ACCESS_TOKEN_EXPIRE_MINUTES)
)
@ -70,7 +74,29 @@ def create_access_token(
)
def create_refresh_token(subject: str) -> str:
def is_desktop_access_token(token: str) -> bool:
"""Return true only for a valid desktop-issued JWT access token."""
if token.startswith(API_KEY_PREFIX):
return False
subject = _decode_subject_without_verification(token)
if subject is None:
return False
record = get_user_and_secret(subject)
if record is None:
return False
_salt, _pwd_hash, jwt_secret, _must_change_password = record
try:
payload = jwt.decode(token, jwt_secret, algorithms = [ALGORITHM])
except jwt.InvalidTokenError:
return False
return payload.get("sub") == subject and payload.get("desktop") is True
def create_refresh_token(subject: str, *, desktop: bool = False) -> str:
"""
Create a random refresh token, store its hash in SQLite, and return it.
@ -78,21 +104,28 @@ def create_refresh_token(subject: str) -> str:
"""
token = secrets.token_urlsafe(48)
expires_at = datetime.now(timezone.utc) + timedelta(days = REFRESH_TOKEN_EXPIRE_DAYS)
save_refresh_token(token, subject, expires_at.isoformat())
save_refresh_token(token, subject, expires_at.isoformat(), is_desktop = desktop)
return token
def refresh_access_token(refresh_token: str) -> Tuple[Optional[str], Optional[str]]:
def refresh_access_token(
refresh_token: str,
) -> Tuple[Optional[str], Optional[str], bool]:
"""
Validate a refresh token and issue a new access token.
The refresh token itself is NOT consumed it stays valid until expiry.
Returns a new access_token or None if the refresh token is invalid/expired.
"""
username = verify_refresh_token(refresh_token)
if username is None:
return None, None
return create_access_token(subject = username), username
verified = verify_refresh_token(refresh_token)
if verified is None:
return None, None, False
username, is_desktop = verified
return (
create_access_token(subject = username, desktop = is_desktop),
username,
is_desktop,
)
def reload_secret() -> None:
@ -173,7 +206,8 @@ async def _get_current_subject(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Invalid token payload",
)
if must_change_password and not allow_password_change:
is_desktop = payload.get("desktop") is True
if must_change_password and not allow_password_change and not is_desktop:
raise HTTPException(
status_code = status.HTTP_403_FORBIDDEN,
detail = "Password change required",

View file

@ -6,6 +6,7 @@ SQLite storage for authentication data (user credentials + JWT secret).
"""
import hashlib
import os
import secrets
import sqlite3
from datetime import datetime, timezone
@ -54,6 +55,10 @@ def generate_bootstrap_password() -> str:
# before the user changes the password.
ensure_dir(_BOOTSTRAP_PW_PATH.parent)
_BOOTSTRAP_PW_PATH.write_text(_bootstrap_password)
try:
os.chmod(_BOOTSTRAP_PW_PATH, 0o600)
except OSError:
pass
return _bootstrap_password
@ -63,6 +68,17 @@ def get_bootstrap_password() -> Optional[str]:
return _bootstrap_password
def _load_bootstrap_password() -> Optional[str]:
"""Load an existing bootstrap password without creating one."""
global _bootstrap_password
_bootstrap_password = None
if _BOOTSTRAP_PW_PATH.is_file():
bootstrap_password = _BOOTSTRAP_PW_PATH.read_text().strip()
if bootstrap_password:
_bootstrap_password = bootstrap_password
return _bootstrap_password
def clear_bootstrap_password() -> None:
"""Delete the persisted bootstrap password file (called after password change)."""
global _bootstrap_password
@ -114,7 +130,8 @@ def get_connection() -> sqlite3.Connection:
id INTEGER PRIMARY KEY,
token_hash TEXT NOT NULL,
username TEXT NOT NULL,
expires_at TEXT NOT NULL
expires_at TEXT NOT NULL,
is_desktop INTEGER NOT NULL DEFAULT 0
);
"""
)
@ -146,6 +163,13 @@ def get_connection() -> sqlite3.Connection:
conn.execute(
"ALTER TABLE auth_user ADD COLUMN must_change_password INTEGER NOT NULL DEFAULT 0"
)
refresh_columns = {
row["name"] for row in conn.execute("PRAGMA table_info(refresh_tokens)")
}
if "is_desktop" not in refresh_columns:
conn.execute(
"ALTER TABLE refresh_tokens ADD COLUMN is_desktop INTEGER NOT NULL DEFAULT 0"
)
conn.commit()
return conn
@ -201,6 +225,9 @@ def _get_or_create_api_key_pbkdf2_salt() -> bytes:
_API_KEY_PBKDF2_ITERATIONS = 100_000
DESKTOP_SECRET_PREFIX = "desktop-"
_DESKTOP_SECRET_HASH_KEY = "desktop_secret_hash"
_DESKTOP_SECRET_CREATED_AT_KEY = "desktop_secret_created_at"
def _pbkdf2_api_key(raw_key: str) -> str:
@ -233,6 +260,10 @@ def _pbkdf2_api_key(raw_key: str) -> str:
return dk.hex()
def _pbkdf2_desktop_secret(raw_secret: str) -> str:
return _pbkdf2_api_key(raw_secret)
def is_initialized() -> bool:
"""Check if auth is ready for login (at least one user exists in DB)."""
conn = get_connection()
@ -374,6 +405,10 @@ def ensure_default_admin() -> bool:
Uses a randomly generated diceware passphrase as the bootstrap password.
Returns True when the default admin was created in this call.
"""
if get_user_and_secret(DEFAULT_ADMIN_USERNAME) is not None:
_load_bootstrap_password()
return False
bootstrap_pw = generate_bootstrap_password()
try:
create_initial_user(
@ -406,12 +441,19 @@ def update_password(username: str, new_password: str) -> bool:
conn.commit()
if cursor.rowcount > 0:
clear_bootstrap_password()
clear_desktop_secret()
return cursor.rowcount > 0
finally:
conn.close()
def save_refresh_token(token: str, username: str, expires_at: str) -> None:
def save_refresh_token(
token: str,
username: str,
expires_at: str,
*,
is_desktop: bool = False,
) -> None:
"""
Store a hashed refresh token with its associated username and expiry.
"""
@ -420,21 +462,21 @@ def save_refresh_token(token: str, username: str, expires_at: str) -> None:
try:
conn.execute(
"""
INSERT INTO refresh_tokens (token_hash, username, expires_at)
VALUES (?, ?, ?)
INSERT INTO refresh_tokens (token_hash, username, expires_at, is_desktop)
VALUES (?, ?, ?, ?)
""",
(token_hash, username, expires_at),
(token_hash, username, expires_at, int(is_desktop)),
)
conn.commit()
finally:
conn.close()
def verify_refresh_token(token: str) -> Optional[str]:
def verify_refresh_token(token: str) -> Optional[Tuple[str, bool]]:
"""
Verify a refresh token and return the username.
Verify a refresh token and return the username plus desktop marker.
Returns the username if valid and not expired, None otherwise.
Returns the username and desktop marker if valid and not expired, None otherwise.
The token is NOT consumed it stays valid until it expires.
"""
token_hash = _hash_token(token)
@ -449,7 +491,7 @@ def verify_refresh_token(token: str) -> Optional[str]:
cur = conn.execute(
"""
SELECT id, username, expires_at FROM refresh_tokens
SELECT id, username, expires_at, is_desktop FROM refresh_tokens
WHERE token_hash = ?
""",
(token_hash,),
@ -465,7 +507,7 @@ def verify_refresh_token(token: str) -> Optional[str]:
conn.commit()
return None
return row["username"]
return row["username"], bool(row["is_desktop"])
finally:
conn.close()
@ -480,6 +522,65 @@ def revoke_user_refresh_tokens(username: str) -> None:
conn.close()
def create_desktop_secret() -> str:
"""Create/rotate the local desktop credential and return it once."""
ensure_default_admin()
raw_secret = DESKTOP_SECRET_PREFIX + secrets.token_urlsafe(48)
secret_hash = _pbkdf2_desktop_secret(raw_secret)
now = datetime.now(timezone.utc).isoformat()
conn = get_connection()
try:
conn.execute(
"INSERT OR REPLACE INTO app_secrets (key, value) VALUES (?, ?)",
(_DESKTOP_SECRET_HASH_KEY, secret_hash),
)
conn.execute(
"INSERT OR REPLACE INTO app_secrets (key, value) VALUES (?, ?)",
(_DESKTOP_SECRET_CREATED_AT_KEY, now),
)
conn.commit()
return raw_secret
finally:
conn.close()
def validate_desktop_secret(raw_secret: str) -> Optional[str]:
"""Return the real admin username when the desktop secret matches."""
if not raw_secret.startswith(DESKTOP_SECRET_PREFIX):
return None
if get_user_and_secret(DEFAULT_ADMIN_USERNAME) is None:
return None
secret_hash = _pbkdf2_desktop_secret(raw_secret)
conn = get_connection()
try:
cur = conn.execute(
"SELECT value FROM app_secrets WHERE key = ?",
(_DESKTOP_SECRET_HASH_KEY,),
)
row = cur.fetchone()
if row is None:
return None
if not secrets.compare_digest(row["value"], secret_hash):
return None
return DEFAULT_ADMIN_USERNAME
finally:
conn.close()
def clear_desktop_secret() -> None:
"""Remove backend-side desktop auth state."""
conn = get_connection()
try:
conn.execute(
"DELETE FROM app_secrets WHERE key IN (?, ?)",
(_DESKTOP_SECRET_HASH_KEY, _DESKTOP_SECRET_CREATED_AT_KEY),
)
conn.commit()
finally:
conn.close()
# ---------------------------------------------------------------------------
# API key management
# ---------------------------------------------------------------------------

View file

@ -33,6 +33,11 @@ _OXC_TOOL_DIR = Path(__file__).resolve().parent / "oxc-validator"
_OXC_RUNNER_PATH = _OXC_TOOL_DIR / "validate.mjs"
from utils.subprocess_compat import (
windows_hidden_subprocess_kwargs as _windows_hidden_subprocess_kwargs,
)
@dataclass(frozen = True)
class OxcLocalCallableValidatorSpec:
name: str
@ -256,6 +261,7 @@ def _run_oxc_batch(
capture_output = True,
check = False,
env = env,
**_windows_hidden_subprocess_kwargs(),
)
except (OSError, ValueError) as exc:
logger.warning("OXC subprocess launch failed: %s", exc)

View file

@ -8,6 +8,7 @@ Supports: SNAC (Orpheus), CSM (Sesame), BiCodec (Spark), DAC (OuteTTS)
import io
import re
import subprocess
import wave
import structlog
from loggers import get_logger
@ -16,6 +17,10 @@ from typing import Optional, Tuple
import numpy as np
import torch
from utils.subprocess_compat import (
windows_hidden_subprocess_kwargs as _windows_hidden_subprocess_kwargs,
)
logger = get_logger(__name__)
@ -81,7 +86,6 @@ class AudioCodecManager:
return
import os
import sys
import subprocess
# Clone SparkAudio/Spark-TTS GitHub repo for the sparktts Python package
# (same approach as training — the HF model repos don't contain the package)
@ -101,6 +105,7 @@ class AudioCodecManager:
spark_code_dir,
],
check = True,
**_windows_hidden_subprocess_kwargs(),
)
if spark_code_dir not in sys.path:
@ -119,7 +124,6 @@ class AudioCodecManager:
return
import os
import sys
import subprocess
# Clone OuteTTS repo (same pattern as Spark-TTS / BiCodec)
# The pip package has problematic dependencies; the notebook clones and
@ -139,6 +143,7 @@ class AudioCodecManager:
outetts_code_dir,
],
check = True,
**_windows_hidden_subprocess_kwargs(),
)
# Remove files that pull in heavy / incompatible dependencies
# (matches notebook: gguf_model.py is under models/, others under outetts/)

View file

@ -18,6 +18,7 @@ from loggers import get_logger
import shutil
import socket
import subprocess
import sys
import threading
import time
from pathlib import Path
@ -26,8 +27,13 @@ from urllib.parse import urlparse
import httpx
from utils.subprocess_compat import (
windows_hidden_subprocess_kwargs as _windows_hidden_subprocess_kwargs,
)
logger = get_logger(__name__)
# ── Pre-compiled patterns for plan-without-action re-prompt ──
# Forward-looking intent signals that indicate the model is
# describing what it *will* do rather than giving a final answer.
@ -440,6 +446,7 @@ class LlamaCppBackend:
capture_output = True,
text = True,
timeout = 10,
**_windows_hidden_subprocess_kwargs(),
)
if result.returncode != 0:
return []
@ -1658,6 +1665,7 @@ class LlamaCppBackend:
stderr = subprocess.STDOUT,
text = True,
env = env,
**_windows_hidden_subprocess_kwargs(),
)
# Start background thread to drain stdout and prevent pipe deadlock

View file

@ -49,6 +49,7 @@ from unsloth.chat_templates import get_chat_template
import json
import threading
import math
import subprocess
import structlog
from loggers import get_logger
import time
@ -69,6 +70,10 @@ from utils.paths import (
)
from trl import SFTTrainer, SFTConfig
from utils.subprocess_compat import (
windows_hidden_subprocess_kwargs as _windows_hidden_subprocess_kwargs,
)
logger = get_logger(__name__)
@ -1765,6 +1770,7 @@ class UnslothTrainer:
spark_code_dir,
],
check = True,
**_windows_hidden_subprocess_kwargs(),
)
if spark_code_dir not in sys.path:
@ -1982,8 +1988,6 @@ class UnslothTrainer:
device = "cuda" if torch.cuda.is_available() else "cpu"
# Clone OuteTTS repo (same as audio_codecs._load_dac)
import subprocess
base_dir = os.path.dirname(os.path.abspath(__file__))
outetts_code_dir = os.path.join(base_dir, "inference", "OuteTTS")
outetts_pkg = os.path.join(outetts_code_dir, "outetts")
@ -2000,6 +2004,7 @@ class UnslothTrainer:
outetts_code_dir,
],
check = True,
**_windows_hidden_subprocess_kwargs(),
)
for fpath in [
os.path.join(outetts_pkg, "models", "gguf_model.py"),

View file

@ -44,6 +44,14 @@ class LogConfig:
# Fallback to INFO if an invalid level is provided
log_level = getattr(logging, log_level_name, logging.INFO)
if sys.platform == "win32":
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
try:
stream.reconfigure(encoding = "utf-8", errors = "replace")
except Exception:
pass
structlog.configure(
processors = [
# Reorder processors to control field order

View file

@ -179,9 +179,22 @@ logger = LogConfig.setup_logging(
app.add_middleware(LoggingMiddleware)
# CORS middleware
_api_only = os.environ.get("UNSLOTH_API_ONLY") == "1"
_cors_origins = ["*"]
if _api_only:
_cors_origins = [
"tauri://localhost", # Linux/macOS Tauri webview
"http://tauri.localhost", # Windows Tauri webview
"http://localhost", # dev fallback
]
_cors_origin_regex = None
else:
_cors_origin_regex = None
app.add_middleware(
CORSMiddleware,
allow_origins = ["*"], # In production, specify allowed origins
allow_origins = _cors_origins,
allow_origin_regex = _cors_origin_regex,
allow_credentials = True,
allow_methods = ["*"],
allow_headers = ["*"],
@ -223,6 +236,8 @@ async def health_check():
"version": UNSLOTH_VERSION,
"device_type": device_type,
"chat_only": _hw_module.CHAT_ONLY,
"desktop_protocol_version": 1,
"supports_desktop_auth": True,
}

View file

@ -17,6 +17,12 @@ class AuthLoginRequest(BaseModel):
password: str = Field(..., description = "Password")
class DesktopLoginRequest(BaseModel):
"""Desktop-only local secret exchange payload."""
secret: str = Field(..., description = "Desktop local auth secret")
class RefreshTokenRequest(BaseModel):
"""Refresh token payload to obtain new access + refresh tokens."""

View file

@ -17,6 +17,7 @@ from models.auth import (
ChangePasswordRequest,
CreateApiKeyRequest,
CreateApiKeyResponse,
DesktopLoginRequest,
RefreshTokenRequest,
)
from models.users import Token
@ -80,6 +81,24 @@ async def login(payload: AuthLoginRequest) -> Token:
)
@router.post("/desktop-login", response_model = Token)
async def desktop_login(payload: DesktopLoginRequest) -> Token:
"""Exchange a local desktop secret for normal admin-subject tokens."""
username = storage.validate_desktop_secret(payload.secret)
if username is None:
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Desktop authentication failed",
)
return Token(
access_token = create_access_token(subject = username, desktop = True),
refresh_token = create_refresh_token(subject = username, desktop = True),
token_type = "bearer",
must_change_password = False,
)
@router.post("/refresh", response_model = Token)
async def refresh(payload: RefreshTokenRequest) -> Token:
"""
@ -87,7 +106,7 @@ async def refresh(payload: RefreshTokenRequest) -> Token:
The refresh token itself is reusable until it expires (7 days).
"""
new_access_token, username = refresh_access_token(payload.refresh_token)
new_access_token, username, is_desktop = refresh_access_token(payload.refresh_token)
if new_access_token is None or username is None:
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
@ -98,7 +117,9 @@ async def refresh(payload: RefreshTokenRequest) -> Token:
access_token = new_access_token,
refresh_token = payload.refresh_token,
token_type = "bearer",
must_change_password = storage.requires_password_change(username),
must_change_password = False
if is_desktop
else storage.requires_password_change(username),
)

View file

@ -57,6 +57,20 @@ def _resolve_local_v1_endpoint(request: Request) -> str:
return f"http://127.0.0.1:{int(port)}/v1"
def _request_has_desktop_access_token(request: Request) -> bool:
auth_header = request.headers.get("authorization")
if not auth_header:
return False
parts = auth_header.split(None, 1)
if len(parts) != 2 or parts[0].lower() != "bearer":
return False
from auth.authentication import is_desktop_access_token
return is_desktop_access_token(parts[1])
def _used_llm_model_aliases(recipe: dict[str, Any]) -> set[str]:
"""Return the set of model_aliases that are actually referenced by an
LLM column. Used to narrow the "Chat model loaded" gate so that orphan
@ -154,6 +168,7 @@ def _inject_local_providers(recipe: dict[str, Any], request: Request) -> None:
token = create_access_token(
subject = "unsloth",
expires_delta = timedelta(hours = 24),
desktop = _request_has_desktop_access_token(request),
)
# Defensively strip any stale "external"-only fields the frontend may

View file

@ -248,6 +248,7 @@ def run_server(
port: int = 8888,
frontend_path: Path = Path(__file__).resolve().parent.parent / "frontend" / "dist",
silent: bool = False,
api_only: bool = False,
llama_parallel_slots: int = 1,
):
"""
@ -258,6 +259,7 @@ def run_server(
port: Port to bind to (auto-increments if in use)
frontend_path: Path to frontend build directory (optional)
silent: Suppress startup messages
api_only: Run API server only, no frontend serving (for Tauri desktop app)
llama_parallel_slots: Number of parallel slots for llama-server
Note:
@ -275,6 +277,10 @@ def run_server(
except Exception:
pass
# Set env var BEFORE importing main so CORS middleware picks it up
if api_only:
os.environ["UNSLOTH_API_ONLY"] = "1"
import nest_asyncio
nest_asyncio.apply()
@ -310,8 +316,12 @@ def run_server(
print("=" * 50)
print("")
# Setup frontend if path provided
if frontend_path:
# Output port for Tauri to parse when in api-only mode
if api_only:
print(f"TAURI_PORT={port}", flush = True)
# Setup frontend if path provided (skip in api-only mode)
if frontend_path and not api_only:
if setup_frontend(app, frontend_path):
if not silent:
print(f"[OK] Frontend loaded from {frontend_path}")
@ -391,10 +401,17 @@ if __name__ == "__main__":
help = "Path to frontend build",
)
parser.add_argument("--silent", action = "store_true", help = "Suppress output")
parser.add_argument(
"--api-only",
action = "store_true",
help = "API server only, no frontend (for Tauri)",
)
args = parser.parse_args()
kwargs = dict(host = args.host, port = args.port, silent = args.silent)
kwargs = dict(
host = args.host, port = args.port, silent = args.silent, api_only = args.api_only
)
if args.frontend is not None:
kwargs["frontend_path"] = Path(args.frontend)

View file

@ -0,0 +1,597 @@
import importlib.util
import asyncio
import hashlib
import json
import os
import platform
import secrets
import sqlite3
import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace
import jwt
import pytest
from fastapi import APIRouter, FastAPI
from fastapi.security import HTTPAuthorizationCredentials
from fastapi.testclient import TestClient
from auth import storage
@pytest.fixture(autouse = True)
def isolated_auth_db(tmp_path, monkeypatch):
monkeypatch.setattr(storage, "DB_PATH", tmp_path / "auth.db")
monkeypatch.setattr(storage, "_BOOTSTRAP_PW_PATH", tmp_path / ".bootstrap_password")
monkeypatch.setattr(storage, "_bootstrap_password", None)
monkeypatch.setattr(storage, "_api_key_pbkdf2_salt_cache", None)
yield
def seed_user(*, must_change_password = False):
storage.create_initial_user(
username = storage.DEFAULT_ADMIN_USERNAME,
password = "human-password-123",
jwt_secret = secrets.token_urlsafe(64),
must_change_password = must_change_password,
)
def auth_client():
route_path = Path(__file__).resolve().parents[1] / "routes" / "auth.py"
spec = importlib.util.spec_from_file_location("_desktop_auth_route", route_path)
auth_route = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(auth_route)
app = FastAPI()
app.include_router(auth_route.router, prefix = "/api/auth")
return TestClient(app)
def data_recipe_jobs_module():
route_path = (
Path(__file__).resolve().parents[1] / "routes" / "data_recipe" / "jobs.py"
)
spec = importlib.util.spec_from_file_location(
"_desktop_data_recipe_jobs", route_path
)
jobs_route = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(jobs_route)
return jobs_route
def local_recipe():
return {
"model_providers": [{"name": "local", "is_local": True}],
"model_configs": [{"alias": "local-model", "provider": "local"}],
"columns": [{"column_type": "llm-text", "model_alias": "local-model"}],
}
def local_recipe_request(token):
return SimpleNamespace(
headers = {"authorization": f"Bearer {token}"},
app = SimpleNamespace(state = SimpleNamespace(server_port = 8888)),
scope = {},
base_url = "http://testserver/",
)
@pytest.fixture
def loaded_local_model(monkeypatch):
inference_module = SimpleNamespace(
get_llama_cpp_backend = lambda: SimpleNamespace(is_loaded = True),
)
monkeypatch.setitem(sys.modules, "routes.inference", inference_module)
def test_desktop_secret_round_trip_uses_real_admin_subject():
seed_user()
raw = storage.create_desktop_secret()
assert raw.startswith("desktop-")
assert storage.validate_desktop_secret(raw) == storage.DEFAULT_ADMIN_USERNAME
assert storage.validate_desktop_secret(raw + "x") is None
def test_create_desktop_secret_rotates_old_secret():
seed_user()
old = storage.create_desktop_secret()
new = storage.create_desktop_secret()
assert old != new
assert storage.validate_desktop_secret(old) is None
assert storage.validate_desktop_secret(new) == storage.DEFAULT_ADMIN_USERNAME
def test_clear_desktop_secret_invalidates_secret():
seed_user()
raw = storage.create_desktop_secret()
storage.clear_desktop_secret()
assert storage.validate_desktop_secret(raw) is None
def test_ensure_default_admin_does_not_recreate_bootstrap_for_existing_admin():
seed_user()
created = storage.ensure_default_admin()
assert created is False
assert not storage._BOOTSTRAP_PW_PATH.exists()
def test_ensure_default_admin_loads_existing_bootstrap_after_restart(monkeypatch):
created = storage.ensure_default_admin()
bootstrap_pw = storage._BOOTSTRAP_PW_PATH.read_text().strip()
monkeypatch.setattr(storage, "_bootstrap_password", None)
created_again = storage.ensure_default_admin()
assert created is True
assert storage._BOOTSTRAP_PW_PATH.exists()
assert created_again is False
assert storage.get_bootstrap_password() == bootstrap_pw
def test_ensure_default_admin_does_not_generate_for_empty_existing_bootstrap():
seed_user()
storage._BOOTSTRAP_PW_PATH.write_text(" \n")
created = storage.ensure_default_admin()
assert created is False
assert storage._BOOTSTRAP_PW_PATH.read_text() == " \n"
assert storage.get_bootstrap_password() is None
def test_web_login_token_has_no_desktop_marker_and_keeps_password_gate():
seed_user(must_change_password = True)
client = auth_client()
response = client.post(
"/api/auth/login",
json = {
"username": storage.DEFAULT_ADMIN_USERNAME,
"password": "human-password-123",
},
)
assert response.status_code == 200
body = response.json()
assert body["must_change_password"] is True
payload = jwt.decode(
body["access_token"],
storage.get_jwt_secret(storage.DEFAULT_ADMIN_USERNAME),
algorithms = ["HS256"],
)
assert payload["sub"] == storage.DEFAULT_ADMIN_USERNAME
assert "desktop" not in payload
gated = client.post(
"/api/auth/api-keys",
headers = {"Authorization": f"Bearer {body['access_token']}"},
json = {"name": "web"},
)
assert gated.status_code == 403
def test_desktop_login_mints_admin_token_without_clearing_web_password_change():
seed_user(must_change_password = True)
raw = storage.create_desktop_secret()
client = auth_client()
response = client.post("/api/auth/desktop-login", json = {"secret": raw})
assert response.status_code == 200
body = response.json()
assert body["access_token"]
assert body["refresh_token"]
assert body["token_type"] == "bearer"
assert body["must_change_password"] is False
assert storage.requires_password_change(storage.DEFAULT_ADMIN_USERNAME) is True
payload = jwt.decode(
body["access_token"],
storage.get_jwt_secret(storage.DEFAULT_ADMIN_USERNAME),
algorithms = ["HS256"],
)
assert payload["sub"] == storage.DEFAULT_ADMIN_USERNAME
assert payload["desktop"] is True
def test_desktop_refresh_preserves_desktop_marker():
seed_user(must_change_password = True)
raw = storage.create_desktop_secret()
client = auth_client()
login_body = client.post("/api/auth/desktop-login", json = {"secret": raw}).json()
response = client.post(
"/api/auth/refresh",
json = {"refresh_token": login_body["refresh_token"]},
)
assert response.status_code == 200
body = response.json()
assert body["must_change_password"] is False
payload = jwt.decode(
body["access_token"],
storage.get_jwt_secret(storage.DEFAULT_ADMIN_USERNAME),
algorithms = ["HS256"],
)
assert payload["sub"] == storage.DEFAULT_ADMIN_USERNAME
assert payload["desktop"] is True
def test_desktop_session_uses_real_admin_identity_for_api_keys():
seed_user(must_change_password = True)
raw = storage.create_desktop_secret()
client = auth_client()
token = client.post("/api/auth/desktop-login", json = {"secret": raw}).json()[
"access_token"
]
response = client.post(
"/api/auth/api-keys",
headers = {"Authorization": f"Bearer {token}"},
json = {"name": "desktop"},
)
assert response.status_code == 200
rows = storage.list_api_keys(storage.DEFAULT_ADMIN_USERNAME)
assert [row["name"] for row in rows] == ["desktop"]
def test_local_recipe_token_preserves_desktop_marker(loaded_local_model):
from auth.authentication import create_access_token, get_current_subject
seed_user(must_change_password = True)
jobs_route = data_recipe_jobs_module()
incoming_token = create_access_token(
subject = storage.DEFAULT_ADMIN_USERNAME,
desktop = True,
)
recipe = local_recipe()
jobs_route._inject_local_providers(recipe, local_recipe_request(incoming_token))
local_token = recipe["model_providers"][0]["api_key"]
payload = jwt.decode(
local_token,
storage.get_jwt_secret(storage.DEFAULT_ADMIN_USERNAME),
algorithms = ["HS256"],
)
assert payload["sub"] == storage.DEFAULT_ADMIN_USERNAME
assert payload["desktop"] is True
credentials = HTTPAuthorizationCredentials(
scheme = "Bearer",
credentials = local_token,
)
assert (
asyncio.run(get_current_subject(credentials)) == storage.DEFAULT_ADMIN_USERNAME
)
def test_local_recipe_token_keeps_web_marker_absent(loaded_local_model):
from auth.authentication import create_access_token
seed_user(must_change_password = False)
jobs_route = data_recipe_jobs_module()
incoming_token = create_access_token(subject = storage.DEFAULT_ADMIN_USERNAME)
recipe = local_recipe()
jobs_route._inject_local_providers(recipe, local_recipe_request(incoming_token))
local_token = recipe["model_providers"][0]["api_key"]
payload = jwt.decode(
local_token,
storage.get_jwt_secret(storage.DEFAULT_ADMIN_USERNAME),
algorithms = ["HS256"],
)
assert payload["sub"] == storage.DEFAULT_ADMIN_USERNAME
assert "desktop" not in payload
def test_desktop_login_rejects_invalid_secret():
seed_user(must_change_password = False)
client = auth_client()
response = client.post(
"/api/auth/desktop-login",
json = {"secret": "desktop-invalid"},
)
assert response.status_code == 401
def test_write_desktop_secret_file_is_0600_on_unix(tmp_path):
from unsloth_cli.commands import studio as studio_cli
path = tmp_path / ".desktop_secret"
if platform.system() != "Windows":
path.write_text("old-secret")
os.chmod(path, 0o644)
studio_cli._write_auth_secret(path, "desktop-secret")
assert path.read_text() == "desktop-secret"
if platform.system() != "Windows":
assert oct(path.stat().st_mode & 0o777) == "0o600"
def test_reset_password_removes_desktop_secret_files(tmp_path, monkeypatch):
from typer.testing import CliRunner
from unsloth_cli.commands import studio as studio_cli
auth_dir = tmp_path / "auth"
auth_dir.mkdir()
(auth_dir / "auth.db").write_text("db")
(auth_dir / ".bootstrap_password").write_text("boot")
(auth_dir / ".desktop_secret").write_text("new")
monkeypatch.setattr(studio_cli, "STUDIO_HOME", tmp_path)
result = CliRunner().invoke(studio_cli.studio_app, ["reset-password"])
assert result.exit_code == 0
assert not (auth_dir / "auth.db").exists()
assert not (auth_dir / ".bootstrap_password").exists()
assert not (auth_dir / ".desktop_secret").exists()
def test_reset_password_removes_desktop_secret_files_without_db(tmp_path, monkeypatch):
from typer.testing import CliRunner
from unsloth_cli.commands import studio as studio_cli
auth_dir = tmp_path / "auth"
auth_dir.mkdir()
(auth_dir / ".desktop_secret").write_text("new")
monkeypatch.setattr(studio_cli, "STUDIO_HOME", tmp_path)
result = CliRunner().invoke(studio_cli.studio_app, ["reset-password"])
assert result.exit_code == 0
assert not (auth_dir / ".desktop_secret").exists()
def test_desktop_capabilities_json_reports_rollout_safe_flags():
from typer.testing import CliRunner
import unsloth_cli.commands.studio as studio_cli
result = CliRunner().invoke(
studio_cli.studio_app,
["desktop-capabilities", "--json"],
)
assert result.exit_code == 0
body = json.loads(result.output)
assert body["desktop_protocol_version"] == 1
assert body["supports_provision_desktop_auth"] is True
assert body["supports_api_only"] is True
assert isinstance(body["version"], str)
def test_health_response_reports_desktop_capability_fields(monkeypatch):
router_stub = SimpleNamespace(
auth_router = APIRouter(),
data_recipe_router = APIRouter(),
datasets_router = APIRouter(),
export_router = APIRouter(),
inference_router = APIRouter(),
models_router = APIRouter(),
training_history_router = APIRouter(),
training_router = APIRouter(),
)
monkeypatch.setitem(sys.modules, "routes", router_stub)
import studio.backend.main as backend_main
monkeypatch.setattr(backend_main._hw_module, "CHAT_ONLY", False)
body = asyncio.run(backend_main.health_check())
assert body["desktop_protocol_version"] == 1
assert body["supports_desktop_auth"] is True
def test_provision_desktop_auth_writes_secret_and_creates_db_without_backend_deps(
tmp_path,
monkeypatch,
):
auth_dir = tmp_path / "auth"
auth_dir.mkdir()
code = """
import builtins
import sys
from pathlib import Path
from typer.testing import CliRunner
studio_home = Path(sys.argv[1])
real_import = builtins.__import__
def guarded_import(name, *args, **kwargs):
blocked = ("auth", "fastapi", "structlog", "utils")
if name in blocked or name.startswith(("auth.", "utils.")):
raise ModuleNotFoundError(name)
return real_import(name, *args, **kwargs)
builtins.__import__ = guarded_import
from unsloth_cli.commands import studio as studio_cli
studio_cli.STUDIO_HOME = studio_home
result = CliRunner().invoke(studio_cli.studio_app, ["provision-desktop-auth"])
if result.exit_code != 0:
print(result.output)
if result.exception is not None:
raise result.exception
raise SystemExit(result.exit_code)
"""
result = subprocess.run(
[sys.executable, "-c", code, str(tmp_path)],
cwd = Path(__file__).resolve().parents[3],
env = {**os.environ, "PYTHONPATH": "."},
text = True,
capture_output = True,
)
assert result.returncode == 0, result.stderr + result.stdout
secret = (auth_dir / ".desktop_secret").read_text()
assert secret.startswith("desktop-")
conn = sqlite3.connect(auth_dir / "auth.db")
conn.row_factory = sqlite3.Row
try:
user = conn.execute(
"""
SELECT username, password_salt, password_hash, must_change_password
FROM auth_user
"""
).fetchone()
app_secrets = {
row["key"]: row["value"]
for row in conn.execute("SELECT key, value FROM app_secrets")
}
refresh_columns = {
row["name"] for row in conn.execute("PRAGMA table_info(refresh_tokens)")
}
finally:
conn.close()
bootstrap_password = (auth_dir / ".bootstrap_password").read_text().strip()
bootstrap_hash = hashlib.pbkdf2_hmac(
"sha256",
bootstrap_password.encode("utf-8"),
user["password_salt"].encode("utf-8"),
100_000,
).hex()
assert bootstrap_password
assert user["username"] == "unsloth"
assert user["must_change_password"] == 1
assert bootstrap_hash == user["password_hash"]
assert len(app_secrets["api_key_pbkdf2_salt"]) == 64
assert len(app_secrets["desktop_secret_hash"]) == 64
assert app_secrets["desktop_secret_created_at"]
assert "is_desktop" in refresh_columns
monkeypatch.setattr(storage, "DB_PATH", auth_dir / "auth.db")
monkeypatch.setattr(storage, "_api_key_pbkdf2_salt_cache", None)
assert storage.validate_desktop_secret(secret) == storage.DEFAULT_ADMIN_USERNAME
assert storage.requires_password_change(storage.DEFAULT_ADMIN_USERNAME) is True
def test_provision_desktop_auth_keeps_existing_admin_password(tmp_path, monkeypatch):
from typer.testing import CliRunner
from unsloth_cli.commands import studio as studio_cli
auth_dir = tmp_path / "auth"
auth_dir.mkdir()
monkeypatch.setattr(studio_cli, "STUDIO_HOME", tmp_path)
conn = sqlite3.connect(auth_dir / "auth.db")
try:
conn.execute(
"""
CREATE TABLE auth_user (
id INTEGER PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password_salt TEXT NOT NULL,
password_hash TEXT NOT NULL,
jwt_secret TEXT NOT NULL,
must_change_password INTEGER NOT NULL DEFAULT 0
)
"""
)
conn.execute(
"""
INSERT INTO auth_user (
username, password_salt, password_hash, jwt_secret, must_change_password
)
VALUES (?, ?, ?, ?, ?)
""",
("unsloth", "existing-salt", "existing-hash", "existing-jwt", 0),
)
conn.commit()
finally:
conn.close()
result = CliRunner().invoke(studio_cli.studio_app, ["provision-desktop-auth"])
assert result.exit_code == 0
assert not (auth_dir / ".bootstrap_password").exists()
conn = sqlite3.connect(auth_dir / "auth.db")
conn.row_factory = sqlite3.Row
try:
user = conn.execute(
"""
SELECT password_salt, password_hash, jwt_secret, must_change_password
FROM auth_user WHERE username = ?
""",
("unsloth",),
).fetchone()
finally:
conn.close()
assert dict(user) == {
"password_salt": "existing-salt",
"password_hash": "existing-hash",
"jwt_secret": "existing-jwt",
"must_change_password": 0,
}
def test_update_password_clears_desktop_secret():
seed_user()
raw = storage.create_desktop_secret()
assert storage.validate_desktop_secret(raw) == storage.DEFAULT_ADMIN_USERNAME
changed = storage.update_password(
storage.DEFAULT_ADMIN_USERNAME, "new-admin-password"
)
assert changed is True
assert storage.validate_desktop_secret(raw) is None
def test_update_password_on_unknown_user_leaves_desktop_secret_intact():
seed_user()
raw = storage.create_desktop_secret()
changed = storage.update_password("not-a-user", "irrelevant")
assert changed is False
assert storage.validate_desktop_secret(raw) == storage.DEFAULT_ADMIN_USERNAME
def test_desktop_auth_provision_has_bounded_timeout():
rs_path = (
Path(__file__).resolve().parents[3]
/ "studio"
/ "src-tauri"
/ "src"
/ "desktop_auth.rs"
)
src = rs_path.read_text()
start = src.index("async fn provision_desktop_auth(")
depth = 0
body_start = src.index("{", start)
body_end = None
for i in range(body_start, len(src)):
c = src[i]
if c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
body_end = i + 1
break
assert body_end is not None
body = src[start:body_end]
assert "tokio::time::timeout" in body
import re
m = re.search(r"Duration::from_secs\(\s*(\d+)\s*\)", body)
assert m is not None
seconds = int(m.group(1))
assert 5 <= seconds <= 120

View file

@ -6,6 +6,10 @@ from typing import Any, Optional
from loggers import get_logger
from utils.subprocess_compat import (
windows_hidden_subprocess_kwargs as _windows_hidden_subprocess_kwargs,
)
logger = get_logger(__name__)
@ -61,6 +65,7 @@ def get_physical_gpu_count() -> Optional[int]:
capture_output = True,
text = True,
timeout = 5,
**_windows_hidden_subprocess_kwargs(),
)
if result.returncode == 0 and result.stdout.strip():
return len(result.stdout.strip().splitlines())
@ -85,6 +90,7 @@ def get_primary_gpu_utilization() -> dict[str, Any]:
capture_output = True,
text = True,
timeout = 5,
**_windows_hidden_subprocess_kwargs(),
)
except (OSError, subprocess.TimeoutExpired) as e:
logger.warning("nvidia-smi query failed in get_primary_gpu_utilization: %s", e)
@ -135,6 +141,7 @@ def get_visible_gpu_utilization(
capture_output = True,
text = True,
timeout = 5,
**_windows_hidden_subprocess_kwargs(),
)
except (OSError, subprocess.TimeoutExpired) as e:
logger.warning("nvidia-smi query failed in get_visible_gpu_utilization: %s", e)
@ -220,6 +227,7 @@ def get_backend_visible_gpu_info(
capture_output = True,
text = True,
timeout = 10,
**_windows_hidden_subprocess_kwargs(),
)
except (OSError, subprocess.TimeoutExpired) as e:
logger.warning("nvidia-smi query failed in get_backend_visible_gpu_info: %s", e)

View file

@ -32,6 +32,10 @@ import threading
import yaml
from utils.subprocess_compat import (
windows_hidden_subprocess_kwargs as _windows_hidden_subprocess_kwargs,
)
logger = get_logger(__name__)
# ── Model size extraction ────────────────────────────────────
@ -579,6 +583,7 @@ def _is_vision_model_subprocess(
capture_output = True,
text = True,
timeout = 60,
**_windows_hidden_subprocess_kwargs(),
)
if result.returncode != 0:

View file

@ -0,0 +1,34 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Cross-platform subprocess helpers for the Unsloth Studio backend."""
import subprocess
import sys
def windows_hidden_subprocess_kwargs() -> dict[str, object]:
"""Return Windows-only subprocess kwargs that suppress console windows.
On non-Windows platforms returns an empty dict so callers can always
unpack the result into ``subprocess.run`` / ``subprocess.Popen`` via
``**windows_hidden_subprocess_kwargs()``.
"""
if sys.platform != "win32":
return {}
kwargs: dict[str, object] = {}
create_no_window = getattr(subprocess, "CREATE_NO_WINDOW", 0)
if create_no_window:
kwargs["creationflags"] = create_no_window
startupinfo_factory = getattr(subprocess, "STARTUPINFO", None)
startf_use_showwindow = getattr(subprocess, "STARTF_USESHOWWINDOW", 0)
sw_hide = getattr(subprocess, "SW_HIDE", 0)
if startupinfo_factory is not None and startf_use_showwindow:
startupinfo = startupinfo_factory()
startupinfo.dwFlags |= startf_use_showwindow
startupinfo.wShowWindow = sw_hide
kwargs["startupinfo"] = startupinfo
return kwargs

View file

@ -36,6 +36,10 @@ import subprocess
import sys
from pathlib import Path
from utils.subprocess_compat import (
windows_hidden_subprocess_kwargs as _windows_hidden_subprocess_kwargs,
)
logger = get_logger(__name__)
@ -499,6 +503,7 @@ def _install_to_dir(pkg: str, target_dir: str) -> bool:
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
text = True,
**_windows_hidden_subprocess_kwargs(),
)
if result.returncode == 0:
return True
@ -520,6 +525,7 @@ def _install_to_dir(pkg: str, target_dir: str) -> bool:
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
text = True,
**_windows_hidden_subprocess_kwargs(),
)
if result.returncode != 0:
logger.error("install failed:\n%s", result.stdout)

View file

@ -41,6 +41,10 @@
"@tailwindcss/vite": "^4.2.2",
"@tanstack/react-router": "^1.159.10",
"@tanstack/react-table": "^8.21.3",
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"@toolwind/corner-shape": "^0.0.8-3",
"@types/canvas-confetti": "^1.9.0",
"@xyflow/react": "^12.10.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View file

@ -2,12 +2,14 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { redirect } from "@tanstack/react-router";
import { apiUrl, isTauri } from "@/lib/api-base";
import {
getPostAuthRoute,
hasAuthToken,
hasRefreshToken,
mustChangePassword,
refreshSession,
tauriAutoAuth,
} from "@/features/auth";
async function hasActiveSession(): Promise<boolean> {
@ -16,55 +18,64 @@ async function hasActiveSession(): Promise<boolean> {
return refreshSession();
}
async function checkAuthInitialized(): Promise<boolean> {
interface AuthStatus {
initialized: boolean;
requires_password_change: boolean;
}
async function fetchAuthStatus(): Promise<AuthStatus> {
try {
const res = await fetch("/api/auth/status");
if (!res.ok) return true; // fallback to login on error
const data = (await res.json()) as { initialized: boolean };
return data.initialized;
const res = await fetch(apiUrl("/api/auth/status"));
if (!res.ok) return { initialized: true, requires_password_change: mustChangePassword() };
return (await res.json()) as AuthStatus;
} catch {
return true; // fallback to login on error
return { initialized: true, requires_password_change: mustChangePassword() };
}
}
async function checkPasswordChangeRequired(): Promise<boolean> {
try {
const res = await fetch("/api/auth/status");
if (!res.ok) return mustChangePassword();
const data = (await res.json()) as { requires_password_change: boolean };
return data.requires_password_change || mustChangePassword();
} catch {
return mustChangePassword();
}
function authRedirect(to: "/login" | "/change-password"): never {
throw redirect({ to });
}
export async function requireAuth(): Promise<void> {
if (isTauri) {
await tauriAutoAuth();
return;
}
if (await hasActiveSession()) {
if (await checkPasswordChangeRequired()) {
throw redirect({ to: "/change-password" });
const { requires_password_change } = await fetchAuthStatus();
if (requires_password_change || mustChangePassword()) {
authRedirect("/change-password");
}
return;
}
const requiresPasswordChange = await checkPasswordChangeRequired();
if (requiresPasswordChange) throw redirect({ to: "/change-password" });
const initialized = await checkAuthInitialized();
throw redirect({ to: initialized ? "/login" : "/change-password" });
const status = await fetchAuthStatus();
if (status.requires_password_change || mustChangePassword()) {
authRedirect("/change-password");
}
authRedirect(status.initialized ? "/login" : "/change-password");
}
export async function requireGuest(): Promise<void> {
if (isTauri) {
await tauriAutoAuth();
throw redirect({ to: "/chat" });
}
if (!(await hasActiveSession())) return;
throw redirect({ to: getPostAuthRoute() });
}
export async function requirePasswordChangeFlow(): Promise<void> {
const requiresPasswordChange = await checkPasswordChangeRequired();
if (requiresPasswordChange) return;
if (isTauri) {
await tauriAutoAuth();
throw redirect({ to: "/chat" });
}
const status = await fetchAuthStatus();
if (status.requires_password_change || mustChangePassword()) return;
if (await hasActiveSession()) {
throw redirect({ to: getPostAuthRoute() });
}
const initialized = await checkAuthInitialized();
throw redirect({ to: initialized ? "/login" : "/change-password" });
authRedirect(status.initialized ? "/login" : "/change-password");
}

View file

@ -1,18 +1,181 @@
// 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 { StartupScreen } from "@/components/tauri/startup-screen";
import { UpdateBanner } from "@/components/tauri/update-banner";
import { UpdateScreen } from "@/components/tauri/update-screen";
import { Toaster } from "@/components/ui/sonner";
import { useTauriBackend } from "@/hooks/use-tauri-backend";
import { useTauriUpdate } from "@/hooks/use-tauri-update";
import { isTauri } from "@/lib/api-base";
import { ThemeProvider } from "next-themes";
import type { ReactNode } from "react";
import { useEffect, useRef, type ReactNode } from "react";
interface AppProviderProps {
children: ReactNode;
}
// ---------------------------------------------------------------------------
// Tauri window helpers (only imported in Tauri mode)
// ---------------------------------------------------------------------------
async function showWindow(): Promise<void> {
const { getCurrentWindow } = await import("@tauri-apps/api/window");
await getCurrentWindow().show();
}
function easeOutQuart(t: number): number {
return 1 - (1 - t) ** 4;
}
async function animateToGoldenRatio(abortRef: { current: boolean }): Promise<void> {
const { getCurrentWindow, currentMonitor, LogicalSize } = await import("@tauri-apps/api/window");
const win = getCurrentWindow();
// Ensure window is visible before resizing
await win.show();
const monitor = await currentMonitor();
if (!monitor) return;
// Convert physical pixels to logical using scale factor
const scale = monitor.scaleFactor;
const screenW = monitor.size.width / scale;
const screenH = monitor.size.height / scale;
// Target: 75% of screen width, golden ratio height, capped at min 900x600
const targetW = Math.max(900, Math.round(screenW * 0.75));
const targetH = Math.max(600, Math.round(targetW / 1.618));
// Don't exceed screen height
const finalH = Math.min(targetH, Math.round(screenH * 0.85));
const finalW = targetW;
// Check reduced motion preference
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (prefersReducedMotion) {
await win.setSize(new LogicalSize(finalW, finalH));
} else {
// Read current size instead of hardcoding — stays correct if tauri.conf.json changes
const inner = await win.innerSize();
const factor = await win.scaleFactor();
const startW = Math.round(inner.width / factor);
const startH = Math.round(inner.height / factor);
const steps = 15;
const stepDuration = 23; // ~350ms total
for (let i = 1; i <= steps; i++) {
if (abortRef.current) return;
const t = easeOutQuart(i / steps);
const w = Math.round(startW + (finalW - startW) * t);
const h = Math.round(startH + (finalH - startH) * t);
await win.setSize(new LogicalSize(w, h));
await new Promise((r) => setTimeout(r, stepDuration));
}
}
if (abortRef.current) return;
// Apply constraints and finalize
await win.setResizable(true);
await win.setSizeConstraints({ minWidth: 900, minHeight: 600 });
await win.center();
}
// ---------------------------------------------------------------------------
// TauriWrapper
// ---------------------------------------------------------------------------
function TauriUpdateLayer({ isExternalServer }: { isExternalServer: boolean }) {
const update = useTauriUpdate(isExternalServer);
const isUpdating =
update.status === "updating-backend" ||
update.status === "downloading" ||
update.status === "installing" ||
(update.status === "error" && !update.dismissed);
if (isUpdating) {
return (
<UpdateScreen
status={update.status}
logs={update.logs}
progress={update.progress}
error={update.error}
onRetry={update.retryUpdate}
onSkipRestart={update.skipAndRestart}
/>
);
}
return (
<UpdateBanner
status={update.status}
info={update.info}
dismissed={update.dismissed}
isExternalServer={isExternalServer}
onInstall={update.installUpdate}
onDismiss={update.dismiss}
/>
);
}
function TauriWrapper({ children }: { children: ReactNode }) {
const {
status, logs, error, isExternalServer,
currentStepIndex, progressDetail, elevationPackages,
startInstall, retry, retryInstall, approveElevation,
} = useTauriBackend();
const hasResized = useRef(false);
const abortRef = useRef(false);
// Show the window once the frontend mounts (for pre-running states)
useEffect(() => {
if (isTauri) void showWindow();
}, []);
// Animate resize when backend becomes ready
useEffect(() => {
if (status === "running" && !hasResized.current) {
hasResized.current = true;
abortRef.current = false;
animateToGoldenRatio(abortRef).catch(async () => {
// On failure, at minimum make the window resizable so user can fix manually
try {
const { getCurrentWindow } = await import("@tauri-apps/api/window");
await getCurrentWindow().setResizable(true);
} catch { /* swallow — window may still be functional */ }
});
}
return () => { abortRef.current = true; };
}, [status]);
if (!isTauri) return <>{children}</>;
if (status === "running") return <><TauriUpdateLayer isExternalServer={isExternalServer} />{children}</>;
return (
<StartupScreen
status={status}
logs={logs}
error={error}
currentStepIndex={currentStepIndex}
progressDetail={progressDetail}
elevationPackages={elevationPackages}
onInstall={startInstall}
onRetry={retry}
onRetryInstall={retryInstall}
onApproveElevation={approveElevation}
onStartServer={retry}
/>
);
}
export function AppProvider({ children }: AppProviderProps) {
return (
<ThemeProvider attribute="class" defaultTheme="light">
{children}
<TauriWrapper>
{children}
</TauriWrapper>
<Toaster position="top-right" visibleToasts={2} expand={true} />
</ThemeProvider>
);

View file

@ -3,8 +3,8 @@
import { AppSidebar } from "@/components/app-sidebar";
import { Navbar } from "@/components/navbar";
import { fetchDeviceType, usePlatformStore } from "@/config/env";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { usePlatformStore } from "@/config/env";
import { SettingsDialog, useSettingsDialogStore } from "@/features/settings";
import { useTrainingUnloadGuard } from "@/features/training/hooks/use-training-unload-guard";
import { useSidebarPin } from "@/hooks/use-sidebar-pin";
@ -33,7 +33,10 @@ function isChatOnlyAllowed(pathname: string): boolean {
}
export const Route = createRootRoute({
beforeLoad: ({ location }) => {
beforeLoad: async ({ location }) => {
// Ensure platform info is fetched before checking chat-only guard.
// fetchDeviceType caches after first call, so subsequent navigations are instant.
await fetchDeviceType();
const chatOnly = usePlatformStore.getState().isChatOnly();
if (chatOnly && !isChatOnlyAllowed(location.pathname)) {
throw redirect({ to: "/chat" });

View file

@ -5,6 +5,7 @@
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { preprocessLaTeX } from "@/lib/latex";
import { openLink } from "@/lib/open-link";
import { INTERNAL, useMessagePartText } from "@assistant-ui/react";
import { Copy02Icon, Tick02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
@ -32,9 +33,13 @@ const STREAMDOWN_COMPONENTS = {
}: React.ComponentProps<"a">) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2 decoration-primary/40 hover:decoration-primary transition-colors"
className="text-primary underline underline-offset-2 decoration-primary/40 hover:decoration-primary transition-colors cursor-pointer"
onClick={(e) => {
if (href && openLink(href)) {
e.preventDefault();
}
}}
{...props}
>
{children}

View file

@ -1,5 +1,6 @@
"use client";
import { openLink } from "@/lib/open-link";
import {
memo,
useState,
@ -93,8 +94,8 @@ function Source({
variant,
size,
asChild = false,
target = "_blank",
rel = "noopener noreferrer",
href,
onClick,
...props
}: SourceProps) {
return (
@ -109,8 +110,14 @@ function Source({
>
<a
data-slot="source"
target={target}
rel={rel}
href={href}
rel="noopener noreferrer"
onClick={(e) => {
if (href && openLink(href)) {
e.preventDefault();
}
onClick?.(e);
}}
{...(props as ComponentProps<"a">)}
/>
</Badge>

View file

@ -0,0 +1,401 @@
// 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 { ShimmerButton } from "@/components/ui/shimmer-button";
import type { BackendStatus } from "@/hooks/use-tauri-backend";
import { AnimatePresence, motion } from "motion/react";
interface StartupScreenProps {
status: BackendStatus;
logs: string[];
error: string | null;
currentStepIndex: number;
progressDetail: string | null;
elevationPackages: string[];
onInstall: () => void;
onRetry: () => void;
onRetryInstall: () => void;
onApproveElevation: () => void;
onStartServer: () => void;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const INSTALL_STEPS = [
"Detecting your system",
"Checking dependencies",
"Setting up package manager",
"Creating Python environment",
"Installing ML framework",
"Installing Unsloth",
"Finalizing setup",
] as const;
const EASE_OUT_QUART: [number, number, number, number] = [0.165, 0.84, 0.44, 1];
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
function TealSpinner({ size = 24 }: { size?: number }) {
return (
<span
className="inline-block animate-spin rounded-full border-2 border-primary border-t-transparent"
style={{ width: size, height: size, animationDuration: "0.8s" }}
/>
);
}
function Logo() {
return (
<div className="flex flex-col items-center gap-4">
<img src="/sticker.png" alt="Unsloth" className="h-[72px] w-[72px] object-contain" />
<img src="/studio.png" alt="Unsloth Studio" className="h-auto w-[250px] object-contain dark:invert" />
</div>
);
}
function ActionButton({
onClick,
variant = "primary",
children,
}: {
onClick: () => void;
variant?: "primary" | "secondary";
children: React.ReactNode;
}) {
const base = "rounded-lg px-5 py-2.5 text-sm font-medium cursor-pointer transition-colors";
const styles =
variant === "primary"
? `${base} bg-primary text-primary-foreground hover:bg-primary/80`
: `${base} bg-muted text-foreground hover:bg-muted/80`;
return (
<button type="button" className={styles} onClick={onClick}>
{children}
</button>
);
}
// ---------------------------------------------------------------------------
// Per-status renderers
// ---------------------------------------------------------------------------
function CheckingContent() {
return (
<div className="flex h-full flex-col items-center">
<div className="flex flex-1 items-center">
<Logo />
</div>
<div className="mb-10 flex flex-col items-center gap-2">
<TealSpinner />
<p className="text-sm text-muted-foreground">Checking...</p>
</div>
</div>
);
}
function NotInstalledContent({ onInstall }: { onInstall: () => void }) {
return (
<div className="flex h-full flex-col items-center">
<div className="flex flex-1 flex-col items-center justify-center">
<Logo />
<p className="mt-4 text-xs font-bold text-muted-foreground">
To install Unsloth, click Get Started.
</p>
</div>
<div className="mb-10">
<ShimmerButton
onClick={onInstall}
shimmerColor="#a7f3d0"
background="oklch(0.696 0.17 162.48)"
className="text-sm font-medium"
>
Get Started
</ShimmerButton>
</div>
</div>
);
}
function InstallingContent({
currentStepIndex,
progressDetail,
}: {
currentStepIndex: number;
progressDetail: string | null;
}) {
const stepNum = Math.max(0, currentStepIndex) + 1;
const stepLabel = INSTALL_STEPS[Math.min(currentStepIndex, INSTALL_STEPS.length - 1)];
return (
<div className="flex h-full flex-col items-center">
<div className="flex flex-1 items-center">
<Logo />
</div>
<div className="mb-10 flex flex-col items-center gap-2">
<TealSpinner />
<p className="text-sm font-bold text-foreground">Installing...</p>
<p className="text-sm font-bold text-muted-foreground">
Please wait a few mins, then you can start training.
</p>
{currentStepIndex >= 0 && (
<p className="mt-1 text-xs font-bold text-muted-foreground">
Step {stepNum} of {INSTALL_STEPS.length}: {stepLabel}
</p>
)}
{progressDetail && (
<p className="text-xs text-muted-foreground/70">{progressDetail}</p>
)}
</div>
</div>
);
}
function RepairingContent({
logs,
progressDetail,
}: {
logs: string[];
progressDetail: string | null;
}) {
const latest = progressDetail ?? logs.at(-1);
return (
<div className="flex h-full flex-col items-center">
<div className="flex flex-1 items-center">
<Logo />
</div>
<div className="mb-10 flex flex-col items-center gap-2">
<TealSpinner />
<p className="text-sm font-bold text-foreground">Updating existing Studio install...</p>
{latest && (
<p className="max-w-xs text-center text-xs text-muted-foreground">{latest}</p>
)}
</div>
</div>
);
}
function InstallErrorContent({
error,
logs,
onRetryInstall,
}: {
error: string | null;
logs: string[];
onRetryInstall: () => void;
}) {
return (
<>
<Logo />
<div className="mt-8 flex flex-col items-center gap-2">
<p className="text-sm font-medium text-destructive">Setup ran into a problem</p>
{error && (
<p className="max-w-xs text-center text-xs text-muted-foreground">{error}</p>
)}
<div className="mt-4 flex gap-3">
<ActionButton
variant="secondary"
onClick={() => void navigator.clipboard.writeText(logs.join("\n"))}
>
Copy Logs
</ActionButton>
<ActionButton onClick={onRetryInstall}>Try Again</ActionButton>
</div>
</div>
</>
);
}
function RepairErrorContent({
error,
logs,
onRetry,
}: {
error: string | null;
logs: string[];
onRetry: () => void;
}) {
return (
<>
<Logo />
<div className="mt-8 flex flex-col items-center gap-2">
<p className="text-sm font-medium text-destructive">Update failed</p>
{error && (
<p className="max-w-md text-center text-xs text-muted-foreground">{error}</p>
)}
<div className="mt-4 flex gap-3">
<ActionButton
variant="secondary"
onClick={() => void navigator.clipboard.writeText(logs.join("\n"))}
>
Copy Logs
</ActionButton>
<ActionButton onClick={onRetry}>Retry</ActionButton>
</div>
</div>
</>
);
}
function NeedsElevationContent({
elevationPackages,
onApproveElevation,
onRetryInstall,
}: {
elevationPackages: string[];
onApproveElevation: () => void;
onRetryInstall: () => void;
}) {
return (
<>
<Logo />
<div className="mt-8 flex flex-col items-center gap-2">
<p className="text-sm font-medium text-foreground">Permission needed</p>
<p className="text-xs text-muted-foreground">
The following system packages need to be installed:
</p>
<div className="mt-2 w-full max-w-xs rounded-lg bg-muted p-3 font-mono text-xs">
{elevationPackages.map((pkg) => (
<div key={pkg}>{pkg}</div>
))}
</div>
<div className="mt-4 flex gap-3">
<ActionButton variant="secondary" onClick={onRetryInstall}>Cancel</ActionButton>
<ActionButton onClick={onApproveElevation}>Allow</ActionButton>
</div>
</div>
</>
);
}
function StartingContent() {
return (
<div className="flex h-full flex-col items-center">
<div className="flex flex-1 items-center">
<Logo />
</div>
<div className="mb-10 flex flex-col items-center gap-2">
<TealSpinner />
<p className="text-sm text-muted-foreground">Starting server...</p>
</div>
</div>
);
}
function StoppedContent({ onStartServer }: { onStartServer: () => void }) {
return (
<>
<Logo />
<div className="mt-8 flex flex-col items-center gap-2">
<p className="text-sm font-medium text-foreground">Server stopped</p>
<div className="mt-4">
<ActionButton onClick={onStartServer}>Start Server</ActionButton>
</div>
</div>
</>
);
}
function ErrorContent({
error,
logs,
onRetry,
}: {
error: string | null;
logs: string[];
onRetry: () => void;
}) {
return (
<>
<Logo />
<div className="mt-8 flex flex-col items-center gap-2">
<p className="text-sm font-medium text-destructive">Something went wrong</p>
{error && (
<p className="max-w-md text-center text-xs text-muted-foreground">{error}</p>
)}
<div className="mt-4 flex gap-3">
<ActionButton
variant="secondary"
onClick={() => void navigator.clipboard.writeText(logs.join("\n"))}
>
Copy Logs
</ActionButton>
<ActionButton onClick={onRetry}>Retry</ActionButton>
</div>
</div>
</>
);
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export function StartupScreen({
status,
logs,
error,
currentStepIndex,
progressDetail,
elevationPackages,
onInstall,
onRetry,
onRetryInstall,
onApproveElevation,
onStartServer,
}: StartupScreenProps) {
function renderContent() {
switch (status) {
case "checking":
return <CheckingContent />;
case "not-installed":
return <NotInstalledContent onInstall={onInstall} />;
case "installing":
return <InstallingContent currentStepIndex={currentStepIndex} progressDetail={progressDetail} />;
case "install-error":
return <InstallErrorContent error={error} logs={logs} onRetryInstall={onRetryInstall} />;
case "repairing":
return <RepairingContent logs={logs} progressDetail={progressDetail} />;
case "repair-error":
return <RepairErrorContent error={error} logs={logs} onRetry={onRetry} />;
case "needs-elevation":
return (
<NeedsElevationContent
elevationPackages={elevationPackages}
onApproveElevation={onApproveElevation}
onRetryInstall={onRetryInstall}
/>
);
case "starting":
return <StartingContent />;
case "running":
return null;
case "stopped":
return <StoppedContent onStartServer={onStartServer} />;
case "error":
return <ErrorContent error={error} logs={logs} onRetry={onRetry} />;
}
}
return (
<div className="flex h-screen 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
key={status}
className="flex h-full w-full flex-col items-center text-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, ease: EASE_OUT_QUART }}
>
{renderContent()}
</motion.div>
</AnimatePresence>
</div>
</div>
);
}

View file

@ -0,0 +1,84 @@
// 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 { Button } from "@/components/ui/button";
import type { UpdateInfo, UpdateStatus } from "@/hooks/use-tauri-update";
import { AnimatePresence, motion } from "motion/react";
interface UpdateBannerProps {
status: UpdateStatus;
info: UpdateInfo | null;
dismissed: boolean;
isExternalServer?: boolean;
onInstall: () => void;
onDismiss: () => void;
}
const EASE_OUT_QUART: [number, number, number, number] = [0.165, 0.84, 0.44, 1];
export function UpdateBanner({
status,
info,
dismissed,
isExternalServer = false,
onInstall,
onDismiss,
}: UpdateBannerProps) {
const visible = status === "available";
const show = visible && !dismissed;
return (
<AnimatePresence>
{show && info && (
<motion.div
initial={{ opacity: 0, y: -12, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -8, scale: 0.97 }}
transition={{ duration: 0.35, ease: EASE_OUT_QUART }}
className="fixed top-4 right-4 z-[9999] w-[380px]"
>
<div className="corner-squircle relative overflow-hidden border border-border/60 bg-background/95 px-5 py-4 shadow-lg backdrop-blur-md">
{/* Close button */}
<button
type="button"
onClick={onDismiss}
className="absolute top-3 right-3 flex size-6 items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-muted hover:text-foreground"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11 3L3 11M3 3l8 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
{/* Header */}
<div className="flex items-center gap-2">
<span className="text-lg">🦥</span>
<div>
<p className="text-sm font-semibold text-foreground">
New version: v{info.version}
</p>
<p className="text-xs text-muted-foreground">
{isExternalServer
? "Run `unsloth studio update` from your terminal"
: "A new app update is available"}
</p>
</div>
</div>
{/* Actions */}
<div className="mt-3 flex items-center gap-2">
<Button size="sm" className="corner-squircle" onClick={onInstall} disabled={isExternalServer}>
Update Now
</Button>
<Button size="sm" variant="outline" className="corner-squircle" disabled>
Release Notes
</Button>
<Button size="sm" variant="ghost" className="corner-squircle" onClick={onDismiss}>
Later
</Button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
);
}

View file

@ -0,0 +1,173 @@
// 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 type { UpdateStatus } from "@/hooks/use-tauri-update";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useRef } from "react";
interface UpdateScreenProps {
status: UpdateStatus;
logs: string[];
progress: number;
error: string | null;
onRetry: () => void;
onSkipRestart: () => void;
}
const EASE_OUT_QUART: [number, number, number, number] = [0.165, 0.84, 0.44, 1];
function Spinner({ size = 24 }: { size?: number }) {
return (
<span
className="inline-block animate-spin rounded-full border-2 border-primary border-t-transparent"
style={{ width: size, height: size, animationDuration: "0.8s" }}
/>
);
}
function Logo() {
return (
<div className="flex flex-col items-center gap-4">
<img src="/sticker.png" alt="Unsloth" className="h-[72px] w-[72px] object-contain" />
<img src="/studio.png" alt="Unsloth Studio" className="h-auto w-[250px] object-contain dark:invert" />
</div>
);
}
function statusLabel(status: UpdateStatus): string {
switch (status) {
case "updating-backend":
return "Updating backend...";
case "downloading":
return "Downloading app update...";
case "installing":
return "Installing update...";
case "error":
return "Update failed";
default:
return "Updating...";
}
}
function statusSubtext(status: UpdateStatus, progress: number): string {
switch (status) {
case "updating-backend":
return "This may take a few minutes. Do not close the app.";
case "downloading":
return `${progress}% downloaded`;
case "installing":
return "The app will restart shortly.";
case "error":
return "Something went wrong during the update.";
default:
return "";
}
}
function LogViewer({ logs }: { logs: string[] }) {
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [logs]);
if (logs.length === 0) return null;
return (
<div
ref={scrollRef}
className="mt-4 h-[180px] w-full max-w-xl overflow-y-auto rounded-lg border border-border/40 bg-muted/30 p-3 font-mono text-[11px] leading-relaxed text-muted-foreground"
>
{logs.map((line, i) => (
<div key={i} className="whitespace-pre-wrap break-all">
{line}
</div>
))}
</div>
);
}
export function UpdateScreen({
status,
logs,
progress,
error,
onRetry,
onSkipRestart,
}: UpdateScreenProps) {
const isError = status === "error";
return (
<div className="flex h-screen w-full items-center justify-center bg-background">
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: EASE_OUT_QUART }}
className="flex w-full max-w-xl flex-col items-center px-6"
>
<Logo />
<div className="mt-8 flex flex-col items-center gap-2">
{!isError && <Spinner />}
<p className="text-sm font-semibold text-foreground">
{statusLabel(status)}
</p>
<p className="text-xs text-muted-foreground">
{statusSubtext(status, progress)}
</p>
</div>
{/* Download progress bar */}
{status === "downloading" && (
<div className="mt-4 h-1.5 w-full max-w-xs overflow-hidden rounded-full bg-muted">
<motion.div
className="h-full rounded-full bg-primary"
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.3 }}
/>
</div>
)}
{/* Error display */}
<AnimatePresence>
{isError && error && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="mt-4 w-full max-w-xl rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3"
>
<p className="text-xs text-destructive">{error}</p>
</motion.div>
)}
</AnimatePresence>
{/* Error actions */}
{isError && (
<div className="mt-4 flex items-center gap-2">
<button
type="button"
className="rounded-lg bg-primary px-5 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/80"
onClick={onRetry}
>
Retry
</button>
<button
type="button"
className="rounded-lg bg-muted px-5 py-2.5 text-sm font-medium text-foreground transition-colors hover:bg-muted/80"
onClick={onSkipRestart}
>
Skip & Restart
</button>
</div>
)}
{/* Log viewer */}
<LogViewer logs={logs} />
</motion.div>
</div>
);
}

View file

@ -0,0 +1,96 @@
import React, { type ComponentPropsWithoutRef, type CSSProperties } from "react"
import { cn } from "@/lib/utils"
export interface ShimmerButtonProps extends ComponentPropsWithoutRef<"button"> {
shimmerColor?: string
shimmerSize?: string
borderRadius?: string
shimmerDuration?: string
background?: string
className?: string
children?: React.ReactNode
}
export const ShimmerButton = React.forwardRef<
HTMLButtonElement,
ShimmerButtonProps
>(
(
{
shimmerColor = "#ffffff",
shimmerSize = "0.05em",
shimmerDuration = "3s",
borderRadius = "100px",
background = "rgba(0, 0, 0, 1)",
className,
children,
...props
},
ref
) => {
return (
<button
style={
{
"--spread": "90deg",
"--shimmer-color": shimmerColor,
"--radius": borderRadius,
"--speed": shimmerDuration,
"--cut": shimmerSize,
"--bg": background,
} as CSSProperties
}
className={cn(
"group relative z-0 flex cursor-pointer items-center justify-center overflow-hidden [border-radius:var(--radius)] border border-white/10 px-6 py-3 whitespace-nowrap text-white [background:var(--bg)]",
"transform-gpu transition-transform duration-300 ease-in-out active:translate-y-px",
className
)}
ref={ref}
{...props}
>
{/* spark container */}
<div
className={cn(
"-z-30 blur-[2px]",
"@container-[size] absolute inset-0 overflow-visible"
)}
>
{/* spark */}
<div className="animate-shimmer-slide absolute inset-0 aspect-[1] h-[100cqh] rounded-none [mask:none]">
{/* spark before */}
<div className="animate-spin-around absolute -inset-full w-auto [translate:0_0] rotate-0 [background:conic-gradient(from_calc(270deg-(var(--spread)*0.5)),transparent_0,var(--shimmer-color)_var(--spread),transparent_var(--spread))]" />
</div>
</div>
{children}
{/* Highlight */}
<div
className={cn(
"absolute inset-0 size-full",
"rounded-2xl px-4 py-1.5 text-sm font-medium shadow-[inset_0_-8px_10px_#ffffff1f]",
// transition
"transform-gpu transition-all duration-300 ease-in-out",
// on hover
"group-hover:shadow-[inset_0_-6px_10px_#ffffff3f]",
// on click
"group-active:shadow-[inset_0_-10px_10px_#ffffff3f]"
)}
/>
{/* backdrop */}
<div
className={cn(
"absolute inset-(--cut) -z-20 [border-radius:var(--radius)] [background:var(--bg)]"
)}
/>
</button>
)
}
)
ShimmerButton.displayName = "ShimmerButton"

View file

@ -1,6 +1,7 @@
// 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 { apiUrl } from "@/lib/api-base";
import { create } from "zustand";
export const env = {
@ -21,9 +22,21 @@ interface PlatformState {
isChatOnly: () => boolean;
}
// Client-side platform detection as fallback when backend isn't ready yet.
function detectLocalPlatform(): DeviceType {
if (typeof navigator === "undefined") return "linux";
const platform = navigator.platform.toLowerCase();
const ua = navigator.userAgent.toLowerCase();
if (platform.includes("mac") || ua.includes("mac")) return "mac";
if (platform.includes("win") || ua.includes("win")) return "windows";
return "linux";
}
const localDeviceType = detectLocalPlatform();
export const usePlatformStore = create<PlatformState>()((_, get) => ({
deviceType: "linux",
chatOnly: false,
deviceType: localDeviceType,
chatOnly: localDeviceType === "mac",
fetched: false,
isChatOnly: () => get().chatOnly,
}));
@ -33,16 +46,22 @@ export async function fetchDeviceType(): Promise<DeviceType> {
if (fetched) return usePlatformStore.getState().deviceType;
try {
const res = await fetch("/api/health");
const res = await fetch(apiUrl("/api/health"));
if (res.ok) {
const data = (await res.json()) as { device_type?: string; chat_only?: boolean };
const deviceType = data.device_type ?? "linux";
const deviceType = data.device_type ?? detectLocalPlatform();
const chatOnly = data.chat_only ?? deviceType === "mac";
usePlatformStore.setState({ deviceType, chatOnly, fetched: true });
return deviceType;
}
} catch (err) {
console.warn("[platform] Failed to fetch device type, will retry", err);
} catch {
// Backend not ready — use client-side detection so chat-only guard
// still works on initial load (important for macOS). Keep fetched=false
// so a later call retries against the backend.
const deviceType = detectLocalPlatform();
const chatOnly = deviceType === "mac";
usePlatformStore.setState({ deviceType, chatOnly, fetched: false });
return deviceType;
}
return usePlatformStore.getState().deviceType;

View file

@ -1,6 +1,7 @@
// 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 { apiUrl, isTauri } from "@/lib/api-base";
import {
clearAuthTokens,
getAuthToken,
@ -34,7 +35,7 @@ async function redirectToAuth(): Promise<void> {
let target = "/login";
try {
const res = await fetch("/api/auth/status");
const res = await fetch(apiUrl("/api/auth/status"));
if (res.ok) {
const data = (await res.json()) as { requires_password_change: boolean };
if (data.requires_password_change || mustChangePassword()) target = "/change-password";
@ -46,12 +47,32 @@ async function redirectToAuth(): Promise<void> {
window.location.href = target;
}
async function retryWithCurrentToken(
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> {
const retryHeaders = new Headers(init?.headers);
const token = getAuthToken();
if (token) retryHeaders.set("Authorization", `Bearer ${token}`);
return fetch(input, { ...init, headers: retryHeaders });
}
async function retryWithTauriAutoAuth(
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response | null> {
clearAuthTokens();
const { tauriAutoAuth } = await import("./tauri-auto-auth");
if (await tauriAutoAuth()) return retryWithCurrentToken(input, init);
return null;
}
export async function refreshSession(): Promise<boolean> {
const refreshToken = getRefreshToken();
if (!refreshToken) return false;
try {
const response = await fetch("/api/auth/refresh", {
const response = await fetch(apiUrl("/api/auth/refresh"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refreshToken }),
@ -78,6 +99,7 @@ export async function authFetch(
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> {
const resolvedInput = typeof input === 'string' ? apiUrl(input) : input;
const headers = new Headers(init?.headers);
const accessToken = getAuthToken();
if (accessToken) {
@ -86,14 +108,18 @@ export async function authFetch(
let response: Response;
try {
response = await fetch(input, { ...init, headers });
response = await fetch(resolvedInput, { ...init, headers });
} catch (err) {
if (err instanceof TypeError) {
throw new Error("Studio isn't running -- please relaunch it.");
}
throw err;
}
if (await isPasswordChangeRequiredResponse(response)) {
if (isTauri) {
return (await retryWithTauriAutoAuth(resolvedInput, init)) ?? response;
}
void redirectToAuth();
return response;
}
@ -101,25 +127,24 @@ export async function authFetch(
const refreshed = await refreshSession();
if (!refreshed) {
if (isTauri) {
return (await retryWithTauriAutoAuth(resolvedInput, init)) ?? response;
}
clearAuthTokens();
void redirectToAuth();
return response;
}
if (mustChangePassword()) {
if (isTauri) {
return (await retryWithTauriAutoAuth(resolvedInput, init)) ?? response;
}
void redirectToAuth();
return response;
}
const retryHeaders = new Headers(init?.headers);
const newToken = getAuthToken();
if (newToken) {
retryHeaders.set("Authorization", `Bearer ${newToken}`);
} else {
clearAuthTokens();
}
return fetch(input, { ...init, headers: retryHeaders });
if (!getAuthToken()) clearAuthTokens();
return retryWithCurrentToken(resolvedInput, init);
}
export function logout(): void {

View file

@ -1,6 +1,7 @@
// 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 { apiUrl } from "@/lib/api-base";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -49,7 +50,7 @@ async function loginWithPassword(
username: string,
password: string,
): Promise<TokenResponse> {
const response = await fetch("/api/auth/login", {
const response = await fetch(apiUrl("/api/auth/login"), {
method: "POST",
headers: {
"Content-Type": "application/json",
@ -96,7 +97,7 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null {
// (e.g. tokens from a previous install attempt). The server's
// /api/auth/status is the source of truth for requires_password_change.
try {
const response = await fetch("/api/auth/status");
const response = await fetch(apiUrl("/api/auth/status"));
if (!response.ok) throw new Error("Failed to load auth status.");
const result = (await response.json()) as AuthStatusResponse;
if (!canceled) {
@ -147,14 +148,15 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null {
};
}, [navigate]);
// Seed password from bootstrap credentials injected into HTML
// Seed password from bootstrap credentials injected into HTML by web CLI.
useEffect(() => {
const bootstrap = window.__UNSLOTH_BOOTSTRAP__;
if (bootstrap) {
if (!isLoginMode && !password) {
function loadBootstrap() {
const bootstrap = window.__UNSLOTH_BOOTSTRAP__;
if (bootstrap && !isLoginMode && !password) {
setPassword(bootstrap.password);
}
}
loadBootstrap();
}, []);
const blockedByState =
@ -241,7 +243,7 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null {
accessToken = bootstrapToken.access_token;
}
const response = await fetch("/api/auth/change-password", {
const response = await fetch(apiUrl("/api/auth/change-password"), {
method: "POST",
headers: {
"Content-Type": "application/json",

View file

@ -15,3 +15,8 @@ export {
resetOnboardingDone,
setMustChangePassword,
} from "./session";
export {
clearTauriAuthFailure,
getTauriAuthFailure,
tauriAutoAuth,
} from "./tauri-auto-auth";

View file

@ -2,6 +2,7 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { usePlatformStore } from "@/config/env";
import { isTauri } from "@/lib/api-base";
export const AUTH_TOKEN_KEY = "unsloth_auth_token";
export const AUTH_REFRESH_TOKEN_KEY = "unsloth_auth_refresh_token";
@ -78,6 +79,7 @@ export function resetOnboardingDone(): void {
}
export function getPostAuthRoute(): PostAuthRoute {
if (isTauri) return "/chat";
if (mustChangePassword()) return "/change-password";
if (usePlatformStore.getState().isChatOnly()) return "/chat";
return "/chat";

View file

@ -0,0 +1,95 @@
// 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 { isTauri } from "@/lib/api-base";
import {
hasAuthToken,
hasRefreshToken,
mustChangePassword,
storeAuthTokens,
} from "./session";
import { refreshSession } from "./api";
type DesktopAuthResponse = {
access_token: string;
refresh_token: string;
};
// 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 lastTauriAuthFailure: string | null = null;
const TAURI_AUTH_FAILURE_FALLBACK =
"Desktop authentication failed. Update or repair the managed Studio install, then restart Studio.";
const BACKEND_NOT_READY_MESSAGE = "Backend is not ready";
function authFailureMessage(error: unknown): string {
if (typeof error === "string" && error) return error;
if (error instanceof Error && error.message) return error.message;
return TAURI_AUTH_FAILURE_FALLBACK;
}
export function getTauriAuthFailure(): string | null {
return lastTauriAuthFailure;
}
export function clearTauriAuthFailure(): void {
lastTauriAuthFailure = null;
}
function setTauriAuthFailure(error: unknown): void {
lastTauriAuthFailure = authFailureMessage(error);
window.dispatchEvent(
new CustomEvent("tauri-auth-failed", { detail: lastTauriAuthFailure }),
);
}
function isBackendNotReady(error: unknown): boolean {
return authFailureMessage(error).includes(BACKEND_NOT_READY_MESSAGE);
}
async function doTauriAutoAuth(): Promise<boolean> {
// Desktop must handle password-change state internally in Rust.
if (hasAuthToken() && !mustChangePassword()) {
clearTauriAuthFailure();
return true;
}
// Try refreshing existing session
if (hasRefreshToken()) {
const refreshed = await refreshSession();
if (refreshed && hasAuthToken() && !mustChangePassword()) {
clearTauriAuthFailure();
return true;
}
}
try {
const { invoke } = await import("@tauri-apps/api/core");
const tokens = await invoke<DesktopAuthResponse>("desktop_auth");
storeAuthTokens(tokens.access_token, tokens.refresh_token, false);
clearTauriAuthFailure();
return true;
} catch (error) {
if (isBackendNotReady(error)) return false;
setTauriAuthFailure(error);
return false;
}
}
/**
* Silently authenticate in Tauri desktop mode.
*
* Delegates bootstrap/password handling to Rust and only stores returned tokens.
*
* Returns true if authentication succeeded.
* Concurrent calls are coalesced into a single in-flight attempt.
*/
export function tauriAutoAuth(): Promise<boolean> {
if (!isTauri) return Promise.resolve(false);
if (!pending) {
pending = doTauriAutoAuth().finally(() => { pending = null; });
}
return pending;
}

View file

@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
import { ShutdownDialog } from "@/components/shutdown-dialog";
import { UpdateStudioInstructions } from "../components/update-studio-instructions";
import { usePlatformStore } from "@/config/env";
import { apiUrl } from "@/lib/api-base";
import { removeTrainingUnloadGuard } from "@/features/training/hooks/use-training-unload-guard";
import {
ArrowUpRight01Icon,
@ -28,7 +29,7 @@ export function AboutTab() {
(async () => {
try {
const res = await fetch("/api/health");
const res = await fetch(apiUrl("/api/health"));
if (!res.ok) return;
const data = (await res.json()) as { version?: string };
if (!canceled && data.version) {

View file

@ -11,4 +11,5 @@ export { useHfDatasetSearch } from "./use-hf-dataset-search";
export { useHfDatasetSplits } from "./use-hf-dataset-splits";
export { useHfTokenValidation } from "./use-hf-token-validation";
export { useInfiniteScroll } from "./use-infinite-scroll";
export { useTauriBackend } from "./use-tauri-backend";
export { useCollapseScrollLock } from "./use-collapse-scroll-lock";

View file

@ -1,6 +1,7 @@
// 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 { apiUrl } from "@/lib/api-base";
import { useEffect, useState } from "react";
export interface GpuInfo {
@ -27,7 +28,7 @@ async function fetchGpuOnce(): Promise<GpuInfo> {
fetchPromise = (async () => {
try {
const res = await fetch("/api/system");
const res = await fetch(apiUrl("/api/system"));
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const gpuData = data?.gpu;

View file

@ -0,0 +1,487 @@
// 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 { useEffect, useState, useCallback, useRef } from "react";
import { isTauri, setApiBase } from "@/lib/api-base";
import {
clearTauriAuthFailure,
getTauriAuthFailure,
} from "@/features/auth";
export type BackendStatus =
| "checking"
| "not-installed"
| "installing"
| "install-error"
| "needs-elevation"
| "repairing"
| "repair-error"
| "starting"
| "running"
| "stopped"
| "error";
type DesktopPreflightDisposition =
| "not_installed"
| "managed_ready"
| "managed_stale"
| "attached_ready";
interface DesktopPreflightResult {
disposition: DesktopPreflightDisposition;
reason: string | null;
port: number | null;
can_auto_repair: boolean;
managed_bin: string | null;
}
export function useTauriBackend() {
const [status, setStatus] = useState<BackendStatus>("checking");
const statusRef = useRef<BackendStatus>(status);
const [logs, setLogs] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
// Guard against double startServer calls
const startingRef = useRef(false);
// Guard against React Strict Mode double-mount
const mountedRef = useRef(false);
// Track the discovered port from server-port event
const portRef = useRef<number | null>(null);
const [currentStepIndex, setCurrentStepIndex] = useState(-1);
const [elevationPackages, setElevationPackages] = useState<string[]>([]);
const [progressDetail, setProgressDetail] = useState<string | null>(null);
// Track seen step names to deduplicate (Strict Mode, event replay, etc.)
const seenStepsRef = useRef(new Set<string>());
// True when we attached to a server we didn't spawn (can't stop it)
const [isExternalServer, setIsExternalServer] = useState(false);
const externalPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const externalPollAbortedRef = useRef(false);
const authFailureRef = useRef<string | null>(getTauriAuthFailure());
const elevationResumeRef = useRef<"install" | "repair" | null>(null);
function setBackendStatus(nextStatus: BackendStatus) {
if (authFailureRef.current) return;
statusRef.current = nextStatus;
setStatus(nextStatus);
}
function setBackendError(
nextError: string,
nextStatus: BackendStatus = "error",
) {
if (authFailureRef.current) return;
statusRef.current = nextStatus;
setStatus(nextStatus);
setError(nextError);
}
function clearBackendError() {
if (authFailureRef.current) return;
setError(null);
}
function setRunningStatus() {
setBackendStatus("running");
}
function setAuthFailure(detail: string) {
authFailureRef.current = detail;
statusRef.current = "error";
setStatus("error");
setError(detail);
}
function clearAuthFailure() {
authFailureRef.current = null;
clearTauriAuthFailure();
}
function stopExternalServerPoll() {
externalPollAbortedRef.current = true;
if (externalPollRef.current) {
clearInterval(externalPollRef.current);
externalPollRef.current = null;
}
}
function startExternalServerPoll(port: number) {
stopExternalServerPoll();
externalPollAbortedRef.current = false;
let failures = 0;
externalPollRef.current = setInterval(async () => {
if (externalPollAbortedRef.current) return;
try {
const { invoke } = await import("@tauri-apps/api/core");
const healthy = await invoke<boolean>("check_health", { port });
if (externalPollAbortedRef.current) return;
if (healthy) {
failures = 0;
} else {
failures++;
}
} catch {
if (externalPollAbortedRef.current) return;
failures++;
}
if (failures >= 3) {
stopExternalServerPoll();
setIsExternalServer(false);
setBackendError("External server is no longer responding");
}
}, 15_000);
}
// Keep ref in sync for event listener closures
useEffect(() => {
statusRef.current = status;
}, [status]);
async function checkInstallAndStart() {
try {
const { invoke } = await import("@tauri-apps/api/core");
const preflight = await invoke<DesktopPreflightResult>("desktop_preflight");
switch (preflight.disposition) {
case "attached_ready": {
if (!preflight.port) {
setBackendError("Desktop preflight found a backend without a port.");
return;
}
setApiBase(preflight.port);
portRef.current = preflight.port;
setIsExternalServer(true);
setRunningStatus();
startExternalServerPoll(preflight.port);
return;
}
case "managed_ready":
setIsExternalServer(false);
stopExternalServerPoll();
setBackendStatus("starting");
await startManagedServer();
return;
case "managed_stale":
setIsExternalServer(false);
stopExternalServerPoll();
if (preflight.can_auto_repair) {
await startRepair();
} else {
setBackendError(
"Managed Studio install is too old. Run `unsloth studio update`.",
);
}
return;
case "not_installed":
setBackendStatus("not-installed");
return;
}
} catch (e) {
setBackendError(String(e));
}
}
async function startManagedServer() {
// Prevent double-start race condition
if (startingRef.current) return;
startingRef.current = true;
try {
const { invoke } = await import("@tauri-apps/api/core");
// backend/run.py keeps the existing 8888-8908 fallback via
// server-port/TAURI_PORT.
await invoke("start_managed_server", { port: 8888 });
// 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 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.";
setBackendError(message);
} catch (e) {
const msg = String(e);
if (msg.includes("already running")) {
startingRef.current = false;
setBackendError(
"Managed server is already running but did not report a port. Restart Studio and try again.",
);
return;
}
setBackendError(msg);
}
startingRef.current = false;
}
async function startRepair() {
elevationResumeRef.current = null;
setCurrentStepIndex(-1);
setProgressDetail(null);
seenStepsRef.current.clear();
startingRef.current = false;
portRef.current = null;
setIsExternalServer(false);
stopExternalServerPoll();
setLogs([]);
clearBackendError();
setBackendStatus("repairing");
const { invoke } = await import("@tauri-apps/api/core");
try {
await invoke("start_managed_repair");
setBackendStatus("starting");
elevationResumeRef.current = null;
await startManagedServer();
} catch (e) {
const msg = String(e);
if (msg.includes("NEEDS_ELEVATION")) return;
setBackendError(msg, "repair-error");
}
}
async function startServer() {
setBackendStatus("starting");
await startManagedServer();
}
async function stopServer() {
if (isExternalServer) {
// We attached to a server we didn't spawn — can't kill it,
// just disconnect the UI.
startingRef.current = false;
setIsExternalServer(false);
stopExternalServerPoll();
setBackendStatus("stopped");
return;
}
const { invoke } = await import("@tauri-apps/api/core");
await invoke("stop_server");
startingRef.current = false;
setBackendStatus("stopped");
}
async function startInstall() {
elevationResumeRef.current = null;
setCurrentStepIndex(-1);
setProgressDetail(null);
seenStepsRef.current.clear();
setBackendStatus("installing");
setLogs([]);
clearBackendError();
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
// startServer() to avoid a double-start race condition.
setBackendStatus("starting");
elevationResumeRef.current = null;
await startServer();
} catch (e) {
const msg = String(e);
// NEEDS_ELEVATION is not a real error — the Rust side also emits
// install-needs-elevation which sets needs-elevation status.
// Don't race with it by setting install-error here.
if (msg.includes("NEEDS_ELEVATION")) return;
setBackendError(msg, "install-error");
}
}
const retry = useCallback(() => {
clearAuthFailure();
setError(null);
setLogs([]);
startingRef.current = false;
portRef.current = null;
setCurrentStepIndex(-1);
setProgressDetail(null);
setElevationPackages([]);
elevationResumeRef.current = null;
setIsExternalServer(false);
stopExternalServerPoll();
seenStepsRef.current.clear();
checkInstallAndStart();
}, []);
const retryInstall = useCallback(() => {
const resume = elevationResumeRef.current;
elevationResumeRef.current = null;
clearBackendError();
setLogs([]);
setElevationPackages([]);
if (resume === "repair") {
setBackendError("Repair canceled before system packages were installed.", "repair-error");
return;
}
setBackendStatus("not-installed");
}, []);
const approveElevation = useCallback(async () => {
const resume = elevationResumeRef.current ?? "install";
try {
const { invoke } = await import("@tauri-apps/api/core");
await invoke("install_system_packages", { packages: elevationPackages });
// Packages installed successfully, resume the flow that requested them.
setCurrentStepIndex(-1);
setProgressDetail(null);
elevationResumeRef.current = null;
if (resume === "repair") {
await startRepair();
} else {
await startInstall();
}
} catch (e) {
setBackendError(String(e), resume === "repair" ? "repair-error" : "install-error");
}
}, [elevationPackages]);
// Initial check on mount (guarded against Strict Mode double-mount)
useEffect(() => {
if (mountedRef.current) return;
mountedRef.current = true;
if (!isTauri) {
setRunningStatus();
return;
}
checkInstallAndStart();
}, []);
// Listen for Tauri events
useEffect(() => {
if (!isTauri) return;
const cleanup: (() => void)[] = [];
let disposed = false;
import("@tauri-apps/api/event").then(({ listen }) => {
function register<T>(
event: string,
handler: Parameters<typeof listen<T>>[1],
) {
listen<T>(event, handler).then((unlisten) => {
if (disposed) {
unlisten();
} else {
cleanup.push(unlisten);
}
});
}
register<string>("install-progress", (e) => {
setLogs((prev) => [...prev.slice(-499), e.payload]);
});
// install-complete is informational only — does NOT trigger startServer.
// The invoke("start_install") success path handles that to avoid races.
register<void>("install-complete", () => {
setCurrentStepIndex(999); // all steps done
});
register<string>("install-step", (e) => {
const stepName = e.payload;
if (seenStepsRef.current.has(stepName)) return; // deduplicate
seenStepsRef.current.add(stepName);
setCurrentStepIndex((prev) => prev + 1);
setProgressDetail(null);
});
register<string[]>("install-needs-elevation", (e) => {
elevationResumeRef.current = "install";
setElevationPackages(e.payload);
setBackendStatus("needs-elevation");
});
register<string>("install-progress-detail", (e) => {
setProgressDetail(e.payload);
});
register<string>("install-failed", (e) => {
setBackendError(e.payload, "install-error");
});
register<string>("repair-progress", (e) => {
setLogs((prev) => [...prev.slice(-499), e.payload]);
});
register<string[]>("repair-needs-elevation", (e) => {
elevationResumeRef.current = "repair";
setElevationPackages(e.payload);
setBackendStatus("needs-elevation");
});
register<void>("repair-complete", () => {
if (statusRef.current !== "repairing") return;
setProgressDetail("Repair complete");
});
register<string>("repair-failed", (e) => {
if (statusRef.current !== "repairing") return;
setBackendError(e.payload, "repair-error");
});
register<number>("server-port", (e) => {
portRef.current = e.payload;
setApiBase(e.payload);
});
register<void>("server-crashed", () => {
startingRef.current = false;
setBackendError("Server stopped unexpectedly");
});
register<string>("server-log", (e) => {
setLogs((prev) => [...prev.slice(-499), e.payload]);
});
register<void>("tray-toggle-server", () => {
if (statusRef.current === "running") {
stopServer();
} else if (
statusRef.current === "stopped" ||
statusRef.current === "error"
) {
retry();
}
});
});
const onAuthFailed = (event: Event) => {
const detail =
event instanceof CustomEvent && typeof event.detail === "string"
? event.detail
: "Desktop authentication failed. Update or repair the managed Studio install, then restart Studio.";
setAuthFailure(detail);
};
window.addEventListener("tauri-auth-failed", onAuthFailed);
const authFailure = getTauriAuthFailure();
if (authFailure) setAuthFailure(authFailure);
cleanup.push(() =>
window.removeEventListener("tauri-auth-failed", onAuthFailed),
);
return () => {
disposed = true;
cleanup.forEach((fn) => fn());
stopExternalServerPoll();
};
}, []);
return {
status, logs, error, isExternalServer,
currentStepIndex, progressDetail, elevationPackages,
startServer, stopServer, startInstall,
retry, retryInstall, approveElevation,
};
}

View file

@ -0,0 +1,219 @@
// 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 { useEffect, useRef, useState } from "react";
import { isTauri } from "@/lib/api-base";
import { toast } from "sonner";
export type UpdateStatus =
| "idle"
| "checking"
| "available"
| "updating-backend"
| "downloading"
| "installing"
| "error";
export interface UpdateInfo {
version: string;
currentVersion: string;
body?: string;
date?: string;
}
export function useTauriUpdate(isExternalServer = false) {
const [status, setStatus] = useState<UpdateStatus>("idle");
const [info, setInfo] = useState<UpdateInfo | null>(null);
const [progress, setProgress] = useState(0);
const [logs, setLogs] = useState<string[]>([]);
const [dismissed, setDismissed] = useState(false);
const [error, setError] = useState<string | null>(null);
const updateRef = useRef<Awaited<
ReturnType<typeof import("@tauri-apps/plugin-updater").check>
> | null>(null);
const checkedRef = useRef(false);
const updatingRef = useRef(false);
useEffect(() => {
if (!isTauri || checkedRef.current) return;
checkedRef.current = true;
async function checkForUpdate() {
setStatus("checking");
try {
const { check } = await import("@tauri-apps/plugin-updater");
const update = await check();
if (update) {
updateRef.current = update;
setInfo({
version: update.version,
currentVersion: update.currentVersion,
body: update.body,
date: update.date,
});
setStatus("available");
} else {
setStatus("idle");
}
} catch (e) {
console.error("Update check failed:", e);
setStatus("idle");
}
}
const timer = setTimeout(checkForUpdate, 5000);
return () => clearTimeout(timer);
}, []);
async function installUpdate() {
const update = updateRef.current;
if (!update || updatingRef.current) return;
updatingRef.current = true;
const cleanups: (() => void)[] = [];
let phase: "backend" | "shell" = "backend";
try {
// ── Step 1: Backend update ──
setStatus("updating-backend");
setLogs([]);
setError(null);
setDismissed(false);
const { listen } = await import("@tauri-apps/api/event");
const { invoke } = await import("@tauri-apps/api/core");
// Listen for backend update progress
const unlistenProgress = await listen<string>(
"update-progress",
(e) => {
setLogs((prev) => [...prev.slice(-499), e.payload]);
},
);
cleanups.push(unlistenProgress);
// Wait for complete or failed
const backendResult = await new Promise<"complete" | string>(
(resolve) => {
listen<void>("update-complete", () => resolve("complete")).then(
(u) => cleanups.push(u),
);
listen<string>("update-failed", (e) =>
resolve(e.payload),
).then((u) => cleanups.push(u));
invoke("start_backend_update").catch((e) => resolve(String(e)));
},
);
if (backendResult !== "complete") {
setError(backendResult);
setStatus("error");
updatingRef.current = false;
cleanup(cleanups);
return;
}
// ── Step 2: Shell update ──
phase = "shell";
setStatus("downloading");
setProgress(0);
let downloaded = 0;
let contentLength = 0;
await update.downloadAndInstall((event) => {
switch (event.event) {
case "Started":
contentLength = event.data.contentLength ?? 0;
break;
case "Progress":
downloaded += event.data.chunkLength;
if (contentLength > 0) {
setProgress(Math.round((downloaded / contentLength) * 100));
}
break;
case "Finished":
setStatus("installing");
break;
}
});
// ── Step 3: Relaunch ──
const { relaunch } = await import("@tauri-apps/plugin-process");
await relaunch();
} catch (e) {
console.error("Update failed:", e);
const msg = String(e);
// Shell update failed — restart backend on updated code
if (phase === "shell") {
try {
const { invoke } = await import("@tauri-apps/api/core");
await invoke("start_server", { port: 8888 });
toast.error("App update failed", {
description:
"Backend was updated. The app update will be retried on next launch.",
});
setStatus("idle");
setDismissed(true);
} catch {
setError(msg);
setStatus("error");
}
} else {
setError(msg);
setStatus("error");
}
} finally {
updatingRef.current = false;
cleanup(cleanups);
}
}
async function retryUpdate() {
updatingRef.current = false;
await installUpdate();
}
async function skipAndRestart() {
try {
const { invoke } = await import("@tauri-apps/api/core");
await invoke("start_server", { port: 8888 });
setStatus("idle");
setError(null);
setLogs([]);
setDismissed(true);
} catch (e) {
setError(String(e));
setStatus("error");
}
}
function dismiss() {
setDismissed(true);
}
return {
status,
info,
progress,
logs,
dismissed,
error,
isExternalServer,
installUpdate,
retryUpdate,
skipAndRestart,
dismiss,
};
}
function cleanup(fns: (() => void)[]) {
for (const fn of fns) {
try {
fn();
} catch {
// ignore
}
}
}

View file

@ -0,0 +1,24 @@
// Central API base URL for Tauri vs browser mode
let apiBase = ''
const isTauri = typeof window !== 'undefined' && '__TAURI__' in window
const isViteDev = import.meta.env.DEV
if (isTauri && !isViteDev) {
apiBase = 'http://127.0.0.1:8888'
}
export function setApiBase(port: number) {
apiBase = `http://127.0.0.1:${port}`
}
export function getApiBase(): string {
return apiBase
}
export function apiUrl(path: string): string {
if (path.startsWith('http')) return path
return `${apiBase}${path}`
}
export { isTauri }

View file

@ -0,0 +1,31 @@
import { isTauri } from "@/lib/api-base";
/**
* Open a URL in the system browser (Tauri) or new tab (web).
* Handles anchor links and mailto: natively without the opener plugin.
* Returns true when the caller should preventDefault; false when the
* browser's native navigation should proceed (relative URLs, empty, etc.).
*/
export function openLink(url: string): boolean {
if (!url) return false;
// Anchor links — scroll within the page, don't open externally
if (url.startsWith("#")) {
window.location.hash = url;
return true;
}
// Relative URLs — let the browser / router handle them natively
if (!url.includes("://") && !url.startsWith("mailto:")) {
return false;
}
if (isTauri) {
import("@tauri-apps/plugin-opener").then(({ openUrl }) => {
openUrl(url).catch(console.error);
});
} else {
window.open(url, "_blank", "noopener,noreferrer");
}
return true;
}

View file

@ -46,6 +46,28 @@ EXIT_ERROR = 1
EXIT_BUSY = 3
def windows_hidden_subprocess_kwargs() -> dict[str, object]:
"""Return Windows-only subprocess kwargs that suppress console windows."""
if sys.platform != "win32":
return {}
kwargs: dict[str, object] = {}
create_no_window = getattr(subprocess, "CREATE_NO_WINDOW", 0)
if create_no_window:
kwargs["creationflags"] = create_no_window
startupinfo_factory = getattr(subprocess, "STARTUPINFO", None)
startf_use_showwindow = getattr(subprocess, "STARTF_USESHOWWINDOW", 0)
sw_hide = getattr(subprocess, "SW_HIDE", 0)
if startupinfo_factory is not None and startf_use_showwindow:
startupinfo = startupinfo_factory()
startupinfo.dwFlags |= startf_use_showwindow
startupinfo.wShowWindow = sw_hide
kwargs["startupinfo"] = startupinfo
return kwargs
def env_int(name: str, default: int, *, minimum: int | None = None) -> int:
raw = os.environ.get(name)
if raw is None:
@ -2469,6 +2491,7 @@ def run_capture(
text = True,
timeout = timeout,
env = env,
**windows_hidden_subprocess_kwargs(),
)
if check and result.returncode != 0:
raise subprocess.CalledProcessError(
@ -4357,6 +4380,7 @@ def validate_quantize(
text = True,
timeout = 120,
env = binary_env(quantize_path, install_dir, host, runtime_line = runtime_line),
**windows_hidden_subprocess_kwargs(),
)
if (
result.returncode != 0
@ -4444,6 +4468,7 @@ def validate_server(
env = binary_env(
server_path, install_dir, host, runtime_line = runtime_line
),
**windows_hidden_subprocess_kwargs(),
)
deadline = time.time() + 20
startup_started = time.time()

View file

@ -359,6 +359,28 @@ def _ensure_rocm_torch() -> None:
)
def _windows_hidden_subprocess_kwargs() -> dict[str, object]:
"""Return Windows-only subprocess kwargs that suppress console windows."""
if not IS_WINDOWS:
return {}
kwargs: dict[str, object] = {}
create_no_window = getattr(subprocess, "CREATE_NO_WINDOW", 0)
if create_no_window:
kwargs["creationflags"] = create_no_window
startupinfo_factory = getattr(subprocess, "STARTUPINFO", None)
startf_use_showwindow = getattr(subprocess, "STARTF_USESHOWWINDOW", 0)
sw_hide = getattr(subprocess, "SW_HIDE", 0)
if startupinfo_factory is not None and startf_use_showwindow:
startupinfo = startupinfo_factory()
startupinfo.dwFlags |= startf_use_showwindow
startupinfo.wShowWindow = sw_hide
kwargs["startupinfo"] = startupinfo
return kwargs
def _infer_no_torch() -> bool:
"""Determine whether to run in no-torch (GGUF-only) mode.
@ -412,9 +434,11 @@ _UNICODE_TO_ASCII: dict[str, str] = {
def _safe_print(*args: object, **kwargs: object) -> None:
"""Drop-in print() replacement that survives non-UTF-8 consoles."""
"""Drop-in print() replacement that survives non-UTF-8 consoles and detached stdout."""
try:
print(*args, **kwargs)
except OSError:
return
except UnicodeEncodeError:
# Stringify, then swap emoji for ASCII equivalents
text = " ".join(str(a) for a in args)
@ -495,7 +519,7 @@ def _step(label: str, value: str, color_fn = None) -> None:
if color_fn is None:
color_fn = _green
padded = label[:_COL]
print(f" {_dim(padded)}{' ' * (_COL - len(padded))}{color_fn(value)}")
_safe_print(f" {_dim(padded)}{' ' * (_COL - len(padded))}{color_fn(value)}")
def _progress(label: str) -> None:
@ -509,10 +533,13 @@ def _progress(label: str) -> None:
bar = "=" * filled + "-" * (width - filled)
pad = " " * (_COL - len(_LABEL))
end = "\n" if _STEP >= _TOTAL else ""
sys.stdout.write(
f"\r {_dim(_LABEL)}{pad}[{bar}] {_STEP:2}/{_TOTAL} {label:<20}{end}"
)
sys.stdout.flush()
try:
sys.stdout.write(
f"\r {_dim(_LABEL)}{pad}[{bar}] {_STEP:2}/{_TOTAL} {label:<20}{end}"
)
sys.stdout.flush()
except OSError:
pass
def run(
@ -525,6 +552,7 @@ def run(
cmd,
stdout = subprocess.PIPE if quiet else None,
stderr = subprocess.STDOUT if quiet else None,
**_windows_hidden_subprocess_kwargs(),
)
if result.returncode != 0:
_step("error", f"{label} failed (exit code {result.returncode})", _red)
@ -627,6 +655,7 @@ def _bootstrap_uv() -> bool:
["uv", "pip", "install", "--dry-run", "--python", sys.executable, "pip"],
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
**_windows_hidden_subprocess_kwargs(),
)
if probe.returncode != 0:
# Retry with --system (some envs need it when uv can't find a venv)
@ -634,6 +663,7 @@ def _bootstrap_uv() -> bool:
["uv", "pip", "install", "--dry-run", "--system", "pip"],
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
**_windows_hidden_subprocess_kwargs(),
)
if probe_sys.returncode != 0:
return False # uv is broken, fall back to pip
@ -773,6 +803,7 @@ def pip_install(
uv_cmd,
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
**_windows_hidden_subprocess_kwargs(),
)
if result.returncode == 0:
return
@ -798,6 +829,7 @@ def patch_package_file(package_name: str, relative_path: str, url: str) -> None:
[sys.executable, "-m", "pip", "show", package_name],
capture_output = True,
text = True,
**_windows_hidden_subprocess_kwargs(),
)
if result.returncode != 0:
_step(_LABEL, f"package {package_name} not found, skipping patch", _red)
@ -868,6 +900,7 @@ def install_python_stack() -> int:
[sys.executable, "-m", "pip", "--version"],
stdout = subprocess.DEVNULL,
stderr = subprocess.DEVNULL,
**_windows_hidden_subprocess_kwargs(),
).returncode
== 0
)
@ -1140,6 +1173,7 @@ def install_python_stack() -> int:
[sys.executable, "-m", "pip", "check"],
stdout = subprocess.DEVNULL,
stderr = subprocess.DEVNULL,
**_windows_hidden_subprocess_kwargs(),
)
_step(_LABEL, "installed")

View file

@ -1077,10 +1077,13 @@ if (-not $CudaArch) {
}
# ============================================
# 1f. Node.js / npm (skip if pip-installed -- only needed for frontend build)
# 1f. Node.js / npm (skip if pip-installed or Tauri -- only needed for frontend build)
# ============================================
$SkipFrontend = ($env:SKIP_STUDIO_FRONTEND -eq "1")
if ($IsPipInstall) {
step "frontend" "bundled (pip install)"
} elseif ($SkipFrontend) {
step "frontend" "bundled (Tauri)"
} else {
# setup.sh installs Node LTS (v22) via nvm. We enforce the same range here:
# Vite 8 requires Node ^20.19.0 || >=22.12.0, npm >= 11.
@ -1207,6 +1210,9 @@ $NeedFrontendBuild = $true
if ($IsPipInstall) {
$NeedFrontendBuild = $false
step "frontend" "bundled (pip install)"
} elseif ($SkipFrontend) {
$NeedFrontendBuild = $false
step "frontend" "bundled (Tauri)"
} elseif (Test-Path $DistDir) {
$DistTime = (Get-Item $DistDir).LastWriteTime
$NewerFile = $null
@ -1539,7 +1545,7 @@ if (Get-Command uv -ErrorAction SilentlyContinue) {
} else {
substep "installing uv package manager..."
try {
Invoke-SetupCommand { powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" } | Out-Null
Invoke-SetupCommand { Invoke-Expression (Invoke-RestMethod -Uri "https://astral.sh/uv/install.ps1") } | Out-Null
Refresh-Environment
# Re-activate venv since Refresh-Environment rebuilds PATH from
# registry and drops the venv's Scripts directory

View file

@ -183,7 +183,13 @@ if [[ "$keynames" == *$'\nCOLAB_'* ]]; then
fi
if [ "$_LLAMA_ONLY" != "1" ]; then
# ── Frontend ──
# ── Detect whether frontend needs building ──
# Skip if SKIP_STUDIO_FRONTEND=1 (Tauri desktop app bundles its own frontend),
# or if dist/ exists AND no tracked input is newer than dist/.
if [ "${SKIP_STUDIO_FRONTEND:-0}" = "1" ]; then
_NEED_FRONTEND_BUILD=false
step "frontend" "bundled (Tauri)"
else
_NEED_FRONTEND_BUILD=true
if [ -d "$SCRIPT_DIR/frontend/dist" ]; then
_changed=$(find "$SCRIPT_DIR/frontend" -maxdepth 1 -type f \
@ -195,6 +201,7 @@ if [ -d "$SCRIPT_DIR/frontend/dist" ]; then
fi
[ -z "$_changed" ] && _NEED_FRONTEND_BUILD=false
fi
fi # end SKIP_STUDIO_FRONTEND guard
if [ "$_NEED_FRONTEND_BUILD" = false ]; then
step "frontend" "up to date"
@ -484,9 +491,9 @@ _PKG_NAME="${STUDIO_PACKAGE_NAME:-unsloth}"
if [ "$_SKIP_VERSION_CHECK" != true ] && [ "${SKIP_STUDIO_BASE:-0}" != "1" ] && [ "${STUDIO_LOCAL_INSTALL:-0}" != "1" ]; then
# Only check when NOT called from install.sh (which just installed the package)
INSTALLED_VER=$("$VENV_DIR/bin/python" -c "
from importlib.metadata import version
print(version('$_PKG_NAME'))
" 2>/dev/null || echo "")
import sys; from importlib.metadata import version
print(version(sys.argv[1]))
" "$_PKG_NAME" 2>/dev/null || echo "")
LATEST_VER=$(curl -fsSL --max-time 5 "https://pypi.org/pypi/$_PKG_NAME/json" 2>/dev/null \
| "$VENV_DIR/bin/python" -c "import sys,json; print(json.load(sys.stdin)['info']['version'])" 2>/dev/null \

6320
studio/src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,42 @@
[package]
name = "unsloth-studio"
version = "2026.4.7"
description = "Unsloth Studio Desktop App"
authors = ["Unsloth AI"]
edition = "2021"
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-single-instance = "2"
tauri-plugin-process = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["full"] }
log = "0.4"
simplelog = "0.12"
dirs = "6"
regex = "1"
open = "5"
process-wrap = { version = "9", features = ["std"] }
fix-path-env = { git = "https://github.com/tauri-apps/fix-path-env-rs" }
tauri-plugin-opener = "2.5.3"
tauri-plugin-updater = "2"
rand = "0.10.0"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[target.'cfg(target_os = "linux")'.dependencies]
elevated-command = "1.1.2"
[target.'cfg(windows)'.dependencies]
windows = { version = "0.62.2", features = ["Win32_System_Threading"] }
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security", "Win32_System_Console", "Win32_System_JobObjects", "Win32_System_Threading"] }
[build-dependencies]
tauri-build = { version = "2", features = [] }
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Allow spawning child processes (Python backend, install scripts) -->
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<!-- Network access for backend health checks and HuggingFace API -->
<key>com.apple.security.network.client</key>
<true/>
<!-- Allow loading Python/venv libraries not signed by us -->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>

View file

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View file

@ -0,0 +1,24 @@
{
"identifier": "default",
"description": "Default capabilities for Unsloth Studio",
"windows": ["main"],
"permissions": [
"core:default",
"core:window:default",
"core:window:allow-show",
"core:window:allow-hide",
"core:window:allow-set-focus",
"core:window:allow-set-size",
"core:window:allow-set-resizable",
"core:window:allow-set-size-constraints",
"core:window:allow-center",
"core:window:allow-current-monitor",
"core:tray:default",
"process:default",
{
"identifier": "opener:allow-open-url",
"allow": [{ "url": "https://*" }, { "url": "http://*" }, { "url": "mailto:*" }]
},
"updater:default"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View file

@ -0,0 +1,9 @@
#!/bin/sh
# Post-removal script for Unsloth Studio (deb/rpm)
# Runs non-interactively; never deletes user data or touches other users' homes.
case "${1:-}" in
upgrade|1|2) exit 0 ;;
esac
exit 0

View file

@ -0,0 +1,476 @@
use crate::install;
use crate::process::{self, BackendState, ShutdownFlag};
use crate::update;
use log::{error, info, warn};
use tauri::{AppHandle, Emitter};
async fn managed_install_ready_after_repair() -> bool {
crate::preflight::managed_install_ready().await
}
fn should_emit_repair_failed(msg: &str) -> bool {
!msg.contains("NEEDS_ELEVATION")
}
#[tauri::command]
pub async fn desktop_preflight() -> crate::preflight::DesktopPreflightResult {
crate::preflight::desktop_preflight_result().await
}
/// Check if unsloth is installed AND functional.
/// Runs `unsloth -h` to verify the import chain works — a partial install
/// (binary exists but deps missing) will fail on import and return false,
/// which sends the user to the install screen for a clean re-install.
#[tauri::command]
pub async fn check_install_status() -> bool {
let Some(bin) = process::find_unsloth_binary() else {
return false;
};
let mut cmd = tokio::process::Command::new(&bin);
cmd.arg("-h")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
#[cfg(windows)]
{
cmd.creation_flags(crate::process::CREATE_NO_WINDOW);
}
// Match the same AppImage env clearing used in process.rs and install.rs,
// otherwise the probe can fail due to bundled libs even when the install is fine.
#[cfg(target_os = "linux")]
if std::env::var_os("APPIMAGE").is_some() {
cmd.env_remove("LD_LIBRARY_PATH");
cmd.env_remove("PYTHONHOME");
cmd.env_remove("PYTHONPATH");
}
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
warn!("Install check: failed to spawn {:?}: {}", bin, e);
return false;
}
};
match tokio::time::timeout(std::time::Duration::from_secs(10), child.wait()).await {
Ok(Ok(status)) => {
let ok = status.success();
if !ok {
warn!("Install check: `unsloth -h` exited with {}", status);
}
ok
}
Ok(Err(e)) => {
warn!("Install check: wait failed: {}", e);
false
}
Err(_) => {
warn!("Install check: `unsloth -h` timed out after 10s");
let _ = child.kill().await;
false
}
}
}
/// Start the backend server on the given port.
/// Also spawns a health watchdog that monitors the backend and emits
/// `server-crashed` if it becomes unresponsive (deadlock, OOM, etc.).
#[tauri::command]
pub async fn start_server(
app: AppHandle,
state: tauri::State<'_, BackendState>,
shutdown: tauri::State<'_, ShutdownFlag>,
port: u16,
) -> Result<(), String> {
info!("start_server command called with port {}", port);
process::start_backend(&app, &state, port, &shutdown)?;
// Spawn health watchdog for the owned backend — detects
// deadlocks and hangs that stdout-based crash detection misses.
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;
});
Ok(())
}
/// Start the managed backend without reusing an existing backend.
#[tauri::command]
pub async fn start_managed_server(
app: AppHandle,
state: tauri::State<'_, BackendState>,
shutdown: tauri::State<'_, ShutdownFlag>,
port: u16,
) -> Result<(), String> {
info!("start_managed_server command called with port {}", port);
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;
});
Ok(())
}
/// Stop the backend server.
/// Sends SIGTERM to the process group, which triggers uvicorn's graceful
/// shutdown (same codepath as /api/shutdown). Falls back to SIGKILL after 5s.
#[tauri::command]
pub fn stop_server(
state: tauri::State<'_, BackendState>,
shutdown: tauri::State<'_, ShutdownFlag>,
) -> Result<(), String> {
info!("stop_server command called");
process::stop_backend(&state, &shutdown)
}
/// Check if a healthy Unsloth backend is running on the given port.
/// Expects JSON response with status=="healthy" AND service=="Unsloth UI Backend".
#[tauri::command]
pub async fn check_health(port: u16) -> Result<bool, String> {
match check_health_inner(port).await {
Ok(healthy) => Ok(healthy),
Err(e) => {
// Network errors are not command errors — just means not healthy
info!("Health check on port {} failed: {}", port, e);
Ok(false)
}
}
}
async fn check_health_inner(port: u16) -> Result<bool, reqwest::Error> {
let url = format!("http://127.0.0.1:{}/api/health", port);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(2))
.build()?;
let resp = client.get(&url).send().await?;
let json: serde_json::Value = resp.json().await?;
let healthy = json
.get("status")
.and_then(|v| v.as_str())
.map(|s| s == "healthy")
.unwrap_or(false);
let correct_service = json
.get("service")
.and_then(|v| v.as_str())
.map(|s| s == "Unsloth UI Backend")
.unwrap_or(false);
Ok(healthy && correct_service)
}
/// Return buffered server logs.
#[tauri::command]
pub fn get_server_logs(state: tauri::State<'_, BackendState>) -> Vec<String> {
match state.lock() {
Ok(proc) => proc.logs.iter().cloned().collect(),
Err(e) => {
error!("Failed to lock state for logs: {}", e);
vec![]
}
}
}
/// Open the Unsloth Studio directory in the system file manager.
#[tauri::command]
pub fn open_logs_dir() -> Result<(), String> {
let home = dirs::home_dir().ok_or("Could not determine home directory")?;
let dir = home.join(".unsloth").join("studio");
if !dir.exists() {
return Err(format!("Directory does not exist: {}", dir.display()));
}
open::that(&dir).map_err(|e| format!("Failed to open directory: {}", e))
}
/// Start the first-launch installation process.
/// Runs the platform installer script with --tauri flag and streams progress events.
/// Returns "NEEDS_ELEVATION" if system packages need elevated install (Linux only).
#[tauri::command]
pub async fn start_install(
app: AppHandle,
state: tauri::State<'_, install::InstallState>,
) -> Result<(), String> {
let state = state.inner().clone();
tokio::task::spawn_blocking(move || install::run_install(app, state))
.await
.map_err(|e| format!("Install task panicked: {e}"))?
}
/// Install system packages with elevated permissions (Linux only).
/// Called by frontend after user approves the elevation dialog.
/// Only allows packages that the install script reported as needed.
#[cfg(target_os = "linux")]
#[tauri::command]
pub fn install_system_packages(
packages: Vec<String>,
state: tauri::State<'_, install::InstallState>,
) -> Result<(), String> {
// Cross-check against the packages the install script actually reported
let allowed = state
.lock()
.map(|s| s.needed_packages.clone())
.unwrap_or_default();
for pkg in &packages {
if !allowed.contains(pkg) {
return Err(format!(
"Package '{}' was not requested by the install script",
pkg
));
}
}
install::install_system_packages(&packages)
}
/// Stub for non-Linux platforms — elevation is handled by the scripts themselves.
#[cfg(not(target_os = "linux"))]
#[tauri::command]
pub fn install_system_packages(
_packages: Vec<String>,
_state: tauri::State<'_, install::InstallState>,
) -> Result<(), String> {
Err("Elevated package install is only supported on Linux".to_string())
}
/// Run backend update: stop server, run `unsloth studio update`, emit progress.
/// Does NOT restart the backend — the frontend handles shell update + relaunch after.
#[tauri::command]
pub async fn start_backend_update(
app: AppHandle,
backend_state: tauri::State<'_, BackendState>,
shutdown: tauri::State<'_, ShutdownFlag>,
update_state: tauri::State<'_, update::UpdateState>,
install_state: tauri::State<'_, install::InstallState>,
) -> Result<(), String> {
info!("start_backend_update command called");
// Signal the health watchdog to exit immediately, before any guards.
// This closes the race window where the watchdog could emit server-crashed
// between our command being called and stop_backend completing.
shutdown.store(true, std::sync::atomic::Ordering::SeqCst);
// Guard: reject if install is running
if install_state
.lock()
.map(|s| s.child.is_some())
.unwrap_or(false)
{
return Err("Cannot update while installation is in progress.".to_string());
}
// Guard: reject if update is already running
if update_state
.lock()
.map(|s| s.child.is_some())
.unwrap_or(false)
{
return Err("Update is already running.".to_string());
}
// Stop backend if running
if backend_state
.lock()
.map(|s| s.child.is_some())
.unwrap_or(false)
{
info!("Stopping backend before update...");
process::stop_backend(&backend_state, &shutdown)?;
}
// Run update in a blocking thread
let state = update_state.inner().clone();
tokio::task::spawn_blocking(move || update::run_backend_update(app, state))
.await
.map_err(|e| format!("Update task panicked: {e}"))?
}
/// Repair a stale managed Studio install.
#[tauri::command]
pub async fn start_managed_repair(
app: AppHandle,
backend_state: tauri::State<'_, BackendState>,
shutdown: tauri::State<'_, ShutdownFlag>,
update_state: tauri::State<'_, update::UpdateState>,
install_state: tauri::State<'_, install::InstallState>,
) -> Result<(), String> {
info!("start_managed_repair command called");
if install_state
.lock()
.map(|s| s.child.is_some())
.unwrap_or(false)
{
return Err("Cannot repair while installation is in progress.".to_string());
}
if update_state
.lock()
.map(|s| s.child.is_some())
.unwrap_or(false)
{
return Err("Repair is already running.".to_string());
}
shutdown.store(true, std::sync::atomic::Ordering::SeqCst);
if backend_state
.lock()
.map(|s| s.child.is_some())
.unwrap_or(false)
{
info!("Stopping backend before repair...");
process::stop_backend(&backend_state, &shutdown)?;
}
let _ = app.emit("repair-progress", "Updating existing Studio install...");
let update_app = app.clone();
let update_state = update_state.inner().clone();
let update_result = tokio::task::spawn_blocking(move || {
update::run_backend_update_for_repair(update_app, update_state)
})
.await
.map_err(|e| format!("Repair update task panicked: {e}"))?;
match update_result {
Ok(()) if managed_install_ready_after_repair().await => {
info!("Managed repair complete after update");
let _ = app.emit("repair-complete", ());
return Ok(());
}
Ok(()) => {
warn!("Managed repair update finished, but preflight is still not ready; falling back to installer");
let _ = app.emit(
"repair-progress",
"Update finished, but Studio is still not ready. Running bundled installer...",
);
}
Err(msg) => {
if msg.to_ascii_lowercase().contains("already running") {
error!("Managed repair update conflict: {}", msg);
let _ = app.emit("repair-failed", &msg);
return Err(msg);
}
warn!(
"Managed repair update failed, falling back to bundled installer: {}",
msg
);
let _ = app.emit(
"repair-progress",
"Update failed. Running bundled installer...",
);
}
}
let install_app = app.clone();
let install_state = install_state.inner().clone();
let install_result = tokio::task::spawn_blocking(move || {
install::run_install_for_repair(install_app, install_state)
})
.await
.map_err(|e| format!("Repair install task panicked: {e}"))?;
if let Err(msg) = install_result {
if should_emit_repair_failed(&msg) {
error!("Managed repair installer failed: {}", msg);
let _ = app.emit("repair-failed", &msg);
}
return Err(msg);
}
if managed_install_ready_after_repair().await {
info!("Managed repair complete after installer");
let _ = app.emit("repair-complete", ());
return Ok(());
}
let msg = "Repair finished, but Studio install is still not desktop-ready.".to_string();
error!("{}", msg);
let _ = app.emit("repair-failed", &msg);
Err(msg)
}
#[cfg(test)]
mod tests {
#[test]
fn repair_elevation_is_not_a_terminal_repair_failure() {
assert!(!super::should_emit_repair_failed("NEEDS_ELEVATION"));
assert!(super::should_emit_repair_failed(
"Installer exited with code 1"
));
}
}
/// 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) {
use std::sync::atomic::Ordering;
// Give the backend time to start up
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
let mut consecutive_failures: u32 = 0;
loop {
tokio::time::sleep(std::time::Duration::from_secs(15)).await;
if shutdown.load(Ordering::SeqCst) {
info!("Health watchdog: shutdown flag set, exiting");
break;
}
let (port, has_child) = {
let proc = match state.lock() {
Ok(p) => p,
Err(_) => break,
};
(proc.port, proc.child.is_some())
};
// Stop watching if the backend is gone
if !has_child {
info!("Health watchdog: backend stopped, exiting");
break;
}
let Some(port) = port else {
continue; // Port not yet known
};
match check_health_inner(port).await {
Ok(true) => {
consecutive_failures = 0;
}
_ => {
consecutive_failures += 1;
warn!(
"Health watchdog: failure {}/3 on port {}",
consecutive_failures, port
);
if consecutive_failures >= 3 {
error!(
"Health watchdog: backend unresponsive for 45s, killing and declaring dead"
);
// Kill the zombie process so retry can start fresh
let _ = process::stop_backend(&state, &shutdown);
let _ = app.emit("server-crashed", ());
break;
}
}
}
}
}

View file

@ -0,0 +1,388 @@
use crate::preflight::{DesktopPreflightDisposition, DesktopPreflightResult};
use crate::process::BackendState;
use log::info;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
static DESKTOP_AUTH_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
#[derive(Debug, Serialize)]
pub struct DesktopAuthResponse {
pub access_token: String,
pub refresh_token: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct DesktopAuthRequest {
secret: String,
}
#[derive(Debug, Deserialize)]
struct TokenResponse {
access_token: String,
refresh_token: String,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum PortSource {
Cached,
Discovered,
}
#[derive(Clone, Copy, Debug)]
struct BackendPort {
port: u16,
source: PortSource,
}
#[derive(Debug)]
enum AuthError {
Connectivity(String),
Failed(String),
}
impl AuthError {
fn message(self) -> String {
match self {
Self::Connectivity(message) | Self::Failed(message) => message,
}
}
}
fn auth_secret_path(home: &Path, filename: &str) -> PathBuf {
home.join(".unsloth")
.join("studio")
.join("auth")
.join(filename)
}
fn auth_url(port: u16, route: &str) -> String {
format!("http://127.0.0.1:{port}/api/auth/{route}")
}
fn home_dir() -> Result<PathBuf, String> {
dirs::home_dir().ok_or_else(|| "Could not determine home directory".to_string())
}
fn desktop_secret_path() -> Result<PathBuf, String> {
Ok(auth_secret_path(&home_dir()?, ".desktop_secret"))
}
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
)),
}
}
async fn current_backend_port(
state: &tauri::State<'_, BackendState>,
) -> Result<BackendPort, String> {
if let Some(port) = state.lock().map_err(|e| e.to_string())?.port {
return Ok(BackendPort {
port,
source: PortSource::Cached,
});
}
let port = discover_compatible_backend_port()
.await
.ok_or_else(|| "Backend is not ready".to_string())?;
{
let mut proc = state.lock().map_err(|e| e.to_string())?;
if proc.port.is_none() {
proc.port = Some(port);
}
}
Ok(BackendPort {
port,
source: PortSource::Discovered,
})
}
fn attached_ready_port(preflight: DesktopPreflightResult) -> Option<u16> {
if preflight.disposition == DesktopPreflightDisposition::AttachedReady {
preflight.port
} else {
None
}
}
async fn discover_compatible_backend_port() -> Option<u16> {
attached_ready_port(crate::preflight::desktop_preflight_result().await)
}
fn update_backend_port(state: &tauri::State<'_, BackendState>, port: u16) -> Result<(), String> {
let mut proc = state.lock().map_err(|e| e.to_string())?;
proc.port = Some(port);
Ok(())
}
fn classify_auth_send_error(error: reqwest::Error) -> AuthError {
let message = format!("Desktop auth failed: {}", error);
if error.is_connect() || error.is_timeout() {
AuthError::Connectivity(message)
} else {
AuthError::Failed(message)
}
}
fn should_retry_with_discovered_port(source: PortSource, error: &AuthError) -> bool {
matches!(
(source, error),
(PortSource::Cached, AuthError::Connectivity(_))
)
}
async fn exchange_desktop_secret(
client: &Client,
port: u16,
secret: &str,
) -> Result<Option<DesktopAuthResponse>, AuthError> {
let response = client
.post(auth_url(port, "desktop-login"))
.json(&DesktopAuthRequest {
secret: secret.to_string(),
})
.send()
.await
.map_err(classify_auth_send_error)?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Err(AuthError::Failed(
"Running Studio backend is too old for this desktop app. Update that backend and restart."
.to_string(),
));
}
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
return Ok(None);
}
if !response.status().is_success() {
return Err(AuthError::Failed("Desktop auth failed".to_string()));
}
response
.json::<TokenResponse>()
.await
.map(|tokens| {
Some(DesktopAuthResponse {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
})
})
.map_err(|e| AuthError::Failed(format!("Desktop auth failed: {}", e)))
}
async fn provision_desktop_auth() -> Result<(), String> {
let bin = crate::process::resolve_backend_binary()?;
let mut cmd = tokio::process::Command::new(&bin);
cmd.args(["studio", "provision-desktop-auth"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped());
#[cfg(target_os = "linux")]
if std::env::var_os("APPIMAGE").is_some() {
cmd.env_remove("LD_LIBRARY_PATH");
cmd.env_remove("PYTHONHOME");
cmd.env_remove("PYTHONPATH");
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(crate::process::CREATE_NO_WINDOW);
}
let output = tokio::time::timeout(std::time::Duration::from_secs(30), cmd.output())
.await
.map_err(|_| "Desktop auth provisioning timed out after 30s".to_string())?
.map_err(|e| format!("Desktop auth provisioning failed: {}", e))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
Err(format!(
"Desktop auth provisioning failed: {}",
stderr.trim()
))
}
async fn authenticate_with_stale_port_retry(
client: &Client,
state: &tauri::State<'_, BackendState>,
backend: BackendPort,
secret: &str,
) -> Result<(Option<DesktopAuthResponse>, BackendPort), String> {
match exchange_desktop_secret(client, backend.port, secret).await {
Ok(tokens) => Ok((tokens, 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)
}
Err(error) => Err(error.message()),
}
}
#[tauri::command]
pub async fn desktop_auth(
state: tauri::State<'_, BackendState>,
) -> Result<DesktopAuthResponse, String> {
let _auth_guard = DESKTOP_AUTH_LOCK.lock().await;
let mut backend = current_backend_port(&state).await?;
let client = Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.map_err(|e| format!("Desktop auth failed: {}", e))?;
for attempt in 0..2 {
if attempt == 1 {
info!("Desktop auth: provisioning local desktop secret");
provision_desktop_auth().await?;
}
let path = desktop_secret_path()?;
let Some(secret) = read_secret_if_exists(&path)? else {
continue;
};
info!("Desktop auth: exchanging desktop secret");
let (tokens, resolved_backend) =
authenticate_with_stale_port_retry(&client, &state, backend, &secret).await?;
backend = resolved_backend;
if let Some(tokens) = tokens {
return Ok(tokens);
}
}
Err(
"Desktop auth failed. Update or repair the managed Studio install, then restart Studio."
.to_string(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
async fn login_server(status: &str) -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let status = status.to_string();
tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut buffer = [0; 1024];
let _ = stream.read(&mut buffer).await.unwrap();
let response =
format!("HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
stream.write_all(response.as_bytes()).await.unwrap();
});
port
}
#[test]
fn auth_secret_path_joins_expected_location() {
let home = PathBuf::from("/home/alex");
assert_eq!(
auth_secret_path(&home, ".desktop_secret"),
PathBuf::from("/home/alex/.unsloth/studio/auth/.desktop_secret")
);
}
#[test]
fn auth_url_builds_local_endpoint() {
assert_eq!(
auth_url(8890, "desktop-login"),
"http://127.0.0.1:8890/api/auth/desktop-login"
);
}
#[test]
fn retry_discovery_only_for_cached_connectivity_errors() {
assert!(should_retry_with_discovered_port(
PortSource::Cached,
&AuthError::Connectivity("connection refused".to_string())
));
assert!(!should_retry_with_discovered_port(
PortSource::Discovered,
&AuthError::Connectivity("connection refused".to_string())
));
assert!(!should_retry_with_discovered_port(
PortSource::Cached,
&AuthError::Failed("Desktop auth failed".to_string())
));
}
#[test]
fn attached_ready_port_requires_attached_ready_with_port() {
let compatible = DesktopPreflightResult {
disposition: DesktopPreflightDisposition::AttachedReady,
reason: None,
port: Some(8890),
can_auto_repair: false,
managed_bin: None,
};
assert_eq!(attached_ready_port(compatible), Some(8890));
let missing_port = DesktopPreflightResult {
disposition: DesktopPreflightDisposition::AttachedReady,
reason: None,
port: None,
can_auto_repair: false,
managed_bin: None,
};
assert_eq!(attached_ready_port(missing_port), None);
let managed_ready = DesktopPreflightResult {
disposition: DesktopPreflightDisposition::ManagedReady,
reason: None,
port: Some(8890),
can_auto_repair: false,
managed_bin: None,
};
assert_eq!(attached_ready_port(managed_ready), None);
}
#[tokio::test]
async fn exchange_desktop_secret_returns_none_for_unauthorized() {
let port = login_server("401 Unauthorized").await;
let tokens = exchange_desktop_secret(&Client::new(), port, "desktop-stale")
.await
.unwrap();
assert!(tokens.is_none());
}
#[tokio::test]
async fn exchange_desktop_secret_reports_unsupported_backend_on_not_found() {
let port = login_server("404 Not Found").await;
let error = exchange_desktop_secret(&Client::new(), port, "desktop-secret")
.await
.unwrap_err()
.message();
assert_eq!(
error,
"Running Studio backend is too old for this desktop app. Update that backend and restart."
);
}
}

View file

@ -0,0 +1,555 @@
use log::{error, info, warn};
use process_wrap::std::*;
use std::io::BufRead;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus, Stdio};
use std::sync::{Arc, Mutex};
use tauri::{AppHandle, Emitter, Manager};
// ── Types ──
pub struct InstallProcess {
/// Process group handle — killing this kills the entire subprocess tree.
pub child: Option<Box<dyn ChildWrapper + Send>>,
pub intentional_stop: bool,
/// Packages needing elevated install, parsed from [TAURI:NEED_SUDO] output.
pub needed_packages: Vec<String>,
}
impl Default for InstallProcess {
fn default() -> Self {
Self {
child: None,
intentional_stop: false,
needed_packages: Vec::new(),
}
}
}
pub type InstallState = Arc<Mutex<InstallProcess>>;
pub fn new_install_state() -> InstallState {
Arc::new(Mutex::new(InstallProcess::default()))
}
use crate::process::trim_line_endings;
// ── Script Resolution ──
/// Returns (script_path, args) depending on dev vs production mode.
/// Dev mode: repo root script + --tauri --local
/// Production: bundled resource + --tauri
fn resolve_install_script(app: &AppHandle) -> Result<(PathBuf, Vec<String>), String> {
let mut args = vec!["--tauri".to_string()];
if cfg!(debug_assertions) {
let repo_root = Path::new(env!("CARGO_MANIFEST_DIR"))
.parent() // studio/
.and_then(|p| p.parent()) // repo root
.ok_or("Cannot resolve repo root from CARGO_MANIFEST_DIR")?;
let script = if cfg!(unix) {
repo_root.join("install.sh")
} else {
repo_root.join("install.ps1")
};
if !script.exists() {
return Err(format!("Install script not found: {}", script.display()));
}
args.push("--local".to_string());
info!("Dev mode: using repo script at {}", script.display());
Ok((script, args))
} else {
let name = if cfg!(unix) {
"install.sh"
} else {
"install.ps1"
};
let script = app
.path()
.resolve(name, tauri::path::BaseDirectory::Resource)
.map_err(|e| format!("Failed to resolve bundled {}: {}", name, e))?;
info!("Production: using bundled script at {}", script.display());
Ok((script, args))
}
}
// ── Emit Helpers ──
#[derive(Clone, Copy)]
enum InstallEventMode {
Full,
Repair,
}
impl InstallEventMode {
fn progress_event(self) -> &'static str {
match self {
Self::Full => "install-progress",
Self::Repair => "repair-progress",
}
}
fn emit_install_structured_events(self) -> bool {
matches!(self, Self::Full)
}
fn emit_terminal_events(self) -> bool {
matches!(self, Self::Full)
}
fn needs_elevation_event(self) -> &'static str {
match self {
Self::Full => "install-needs-elevation",
Self::Repair => "repair-needs-elevation",
}
}
}
fn emit_mode_progress(app: &AppHandle, mode: InstallEventMode, message: &str) {
info!("[install] {}", message);
let _ = app.emit(mode.progress_event(), message);
}
fn emit_failed(app: &AppHandle, message: &str) {
error!("[install] FAILED: {}", message);
let _ = app.emit("install-failed", message);
}
fn emit_complete(app: &AppHandle) {
info!("[install] Installation complete");
let _ = app.emit("install-complete", ());
}
// ── Spawn ──
/// Spawns the install script in a process group.
/// Returns (stdout, stderr) handles for streaming.
/// The GroupChild is stored in state so stop_install() can kill the entire tree.
fn spawn_script(
script: &Path,
args: &[String],
state: &InstallState,
) -> Result<
(
Option<std::process::ChildStdout>,
Option<std::process::ChildStderr>,
),
String,
> {
let mut install = state.lock().map_err(|e| e.to_string())?;
if install.child.is_some() {
return Err("Installation is already running.".to_string());
}
install.intentional_stop = false;
install.needed_packages.clear();
// Scripts create ~/.unsloth/studio/ themselves, but need a writable cwd.
let home = dirs::home_dir().ok_or("Could not determine home directory")?;
let work_dir = home.join(".unsloth");
if !work_dir.exists() {
std::fs::create_dir_all(&work_dir)
.map_err(|e| format!("Failed to create {}: {}", work_dir.display(), e))?;
}
#[cfg(unix)]
let mut cmd = Command::new("bash");
#[cfg(unix)]
cmd.arg(script)
.args(args)
.current_dir(&work_dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(windows)]
let mut cmd = Command::new("powershell.exe");
#[cfg(windows)]
cmd.args([
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-WindowStyle",
"Hidden",
"-ExecutionPolicy",
"Bypass",
"-File",
])
.arg(script)
.args(args)
.current_dir(&work_dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
// AppImage sets LD_LIBRARY_PATH to its bundled libs, which breaks Python
// spawned by the install script. Only clear inside AppImage — native installs
// may need these for custom CUDA or conda paths.
#[cfg(target_os = "linux")]
if std::env::var_os("APPIMAGE").is_some() {
cmd.env_remove("LD_LIBRARY_PATH");
cmd.env_remove("PYTHONHOME");
cmd.env_remove("PYTHONPATH");
}
// On Windows, launch the installer directly with CREATE_NO_WINDOW.
// The app process is assigned to a KILL_ON_JOB_CLOSE job in main.rs, so
// child cleanup on crash comes from inherited job membership instead.
#[cfg(windows)]
let mut child: Box<dyn ChildWrapper + Send> = {
use std::os::windows::process::CommandExt;
cmd.creation_flags(crate::process::CREATE_NO_WINDOW);
let child = cmd
.spawn()
.map_err(|e| format!("Failed to spawn install script: {}", e))?;
Box::new(child)
};
#[cfg(unix)]
let mut child: Box<dyn ChildWrapper + Send> = {
// Keep the whole installer tree in a process group on Unix.
let mut wrap = CommandWrap::from(cmd);
wrap.wrap(ProcessGroup::leader());
wrap.spawn()
.map_err(|e| format!("Failed to spawn install script: {}", e))?
};
let stdout = child.stdout().take();
let stderr = child.stderr().take();
install.child = Some(child);
Ok((stdout, stderr))
}
// ── Stream ──
/// Spawns reader threads for stdout/stderr.
/// Parses [TAURI:*] lines from stdout for structured events.
fn stream_output(
app: &AppHandle,
state: &InstallState,
event_mode: InstallEventMode,
stdout: Option<std::process::ChildStdout>,
stderr: Option<std::process::ChildStderr>,
) -> Vec<std::thread::JoinHandle<()>> {
let mut threads = Vec::new();
if let Some(out) = stdout {
let app_clone = app.clone();
let state_clone = Arc::clone(state);
threads.push(std::thread::spawn(move || {
let mut reader = std::io::BufReader::new(out);
let mut buf = Vec::new();
loop {
buf.clear();
match reader.read_until(b'\n', &mut buf) {
Ok(0) => break,
Ok(_) => {
let text = String::from_utf8_lossy(trim_line_endings(&buf)).into_owned();
// Parse structured Tauri protocol lines
if let Some(packages) = text.strip_prefix("[TAURI:NEED_SUDO] ") {
let pkgs: Vec<String> =
packages.split_whitespace().map(String::from).collect();
if let Ok(mut install) = state_clone.lock() {
install.needed_packages = pkgs;
}
} else if let Some(step) = text.strip_prefix("[TAURI:STEP] ") {
if !event_mode.emit_install_structured_events() {
info!("[install][stdout] {}", text);
let _ = app_clone.emit(event_mode.progress_event(), &text);
continue;
}
let _ = app_clone.emit("install-step", step);
} else if let Some(detail) = text.strip_prefix("[TAURI:PROGRESS] ") {
if !event_mode.emit_install_structured_events() {
info!("[install][stdout] {}", text);
let _ = app_clone.emit(event_mode.progress_event(), detail);
continue;
}
let _ = app_clone.emit("install-progress-detail", detail);
}
// Always forward the raw line
info!("[install][stdout] {}", text);
let _ = app_clone.emit(event_mode.progress_event(), &text);
}
Err(e) => {
warn!("[install] Error reading stdout: {}", e);
break;
}
}
}
}));
}
if let Some(err) = stderr {
let app_clone = app.clone();
threads.push(std::thread::spawn(move || {
let mut reader = std::io::BufReader::new(err);
let mut buf = Vec::new();
loop {
buf.clear();
match reader.read_until(b'\n', &mut buf) {
Ok(0) => break,
Ok(_) => {
let text = String::from_utf8_lossy(trim_line_endings(&buf)).into_owned();
warn!("[install][stderr] {}", text);
let _ = app_clone.emit(event_mode.progress_event(), &text);
}
Err(e) => {
warn!("[install] Error reading stderr: {}", e);
break;
}
}
}
}));
}
threads
}
// ── Wait & Finalize ──
/// Waits for the install process to exit. Returns (exit_status, was_intentional_stop).
/// Times out after 2 hours to prevent infinite loops if the child hangs.
fn wait_for_exit(state: &InstallState) -> Result<(ExitStatus, bool), String> {
const MAX_WAIT_ITERATIONS: u32 = 72_000; // 2h at 100ms intervals
for _ in 0..MAX_WAIT_ITERATIONS {
let mut install = state.lock().map_err(|e| e.to_string())?;
let intentional = install.intentional_stop;
match install.child.as_mut() {
Some(child) => match child.try_wait() {
Ok(Some(status)) => {
install.child = None;
return Ok((status, intentional));
}
Ok(None) => {}
Err(e) => {
install.child = None;
return Err(format!("Error waiting for installer: {}", e));
}
},
None if intentional => return Err("Installation stopped.".to_string()),
None => return Err("Installer process disappeared unexpectedly.".to_string()),
}
drop(install);
std::thread::sleep(std::time::Duration::from_millis(100));
}
// Timed out — kill and report
let _ = stop_install(state);
Err("Installation timed out after 2 hours".to_string())
}
// ── Public API ──
/// Run the install script. Returns Ok(()) on success.
/// Returns Err("NEEDS_ELEVATION") if system packages need elevated install (Linux only).
/// Returns Err(message) on other failures.
pub fn run_install(app: AppHandle, state: InstallState) -> Result<(), String> {
run_install_with_event_mode(app, state, InstallEventMode::Full)
}
pub(crate) fn run_install_for_repair(app: AppHandle, state: InstallState) -> Result<(), String> {
run_install_with_event_mode(app, state, InstallEventMode::Repair)
}
fn run_install_with_event_mode(
app: AppHandle,
state: InstallState,
event_mode: InstallEventMode,
) -> Result<(), String> {
emit_mode_progress(&app, event_mode, "Starting installation...");
let (script, args) = resolve_install_script(&app)?;
emit_mode_progress(
&app,
event_mode,
&format!("Using script: {}", script.display()),
);
let (stdout, stderr) = spawn_script(&script, &args, &state)?;
let threads = stream_output(&app, &state, event_mode, stdout, stderr);
// Wait for exit, join reader threads
let result = wait_for_exit(&state);
for handle in threads {
let _ = handle.join();
}
match result {
Ok((status, _)) if status.success() => {
if event_mode.emit_terminal_events() {
emit_complete(&app);
}
Ok(())
}
Ok((status, _)) => {
let code = status.code().unwrap_or(-1);
if code == 2 {
// Script needs elevated package install — report to frontend
let packages = state
.lock()
.map(|i| i.needed_packages.clone())
.unwrap_or_default();
info!("[install] Needs elevation for packages: {:?}", packages);
let _ = app.emit(event_mode.needs_elevation_event(), &packages);
Err("NEEDS_ELEVATION".to_string())
} else {
let msg = format!("Installer exited with code {}", code);
if event_mode.emit_terminal_events() {
emit_failed(&app, &msg);
}
Err(msg)
}
}
Err(msg) if msg == "Installation stopped." => {
info!("[install] Installation stopped intentionally");
Err(msg)
}
Err(msg) => {
if event_mode.emit_terminal_events() {
emit_failed(&app, &msg);
}
Err(msg)
}
}
}
/// Stop a running install process gracefully.
/// Unix: SIGTERM to process group -> wait up to 5s -> SIGKILL
/// Windows: hidden taskkill /T /F to terminate the installer tree
pub fn stop_install(state: &InstallState) -> Result<(), String> {
let mut child = {
let mut install = match state.lock() {
Ok(guard) => guard,
Err(poisoned) => {
warn!("Install state mutex poisoned, recovering for cleanup");
poisoned.into_inner()
}
};
install.intentional_stop = true;
install.child.take()
};
let Some(ref mut child) = child else {
return Ok(());
};
let pid = child.id();
info!("Stopping installer process group (pid {})", pid);
// Try graceful SIGTERM first so pip/cmake can clean up temp files
#[cfg(unix)]
{
if pid > i32::MAX as u32 {
// PID too large for i32 negation, fall back to direct kill
warn!("PID {} exceeds i32 range, using direct kill", pid);
let _ = child.kill();
let _ = child.wait();
return Ok(());
}
unsafe {
libc::kill(-(pid as i32), libc::SIGTERM);
}
// Wait up to 5s for graceful exit
for _ in 0..50 {
match child.try_wait() {
Ok(Some(status)) => {
info!("Installer exited gracefully with status: {:?}", status);
return Ok(());
}
Ok(None) => std::thread::sleep(std::time::Duration::from_millis(100)),
Err(_) => break,
}
}
warn!("Installer did not exit gracefully, force killing");
}
#[cfg(windows)]
{
crate::process::force_kill_process_tree(pid, child, "Installer");
return Ok(());
}
#[cfg(unix)]
{
// Force kill (SIGKILL on Unix)
let _ = child.kill();
let _ = child.wait();
info!("Installer process group force stopped");
Ok(())
}
}
/// Install 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> {
use regex::Regex;
use std::path::Path;
use std::process::Command as StdCommand;
// Validate package names to prevent injection via elevated command.
let valid_pkg = Regex::new(r"^[a-zA-Z0-9][a-zA-Z0-9.+\-]*$").unwrap();
for pkg in packages {
if !valid_pkg.is_match(pkg) {
return Err(format!("Invalid package name: {}", pkg));
}
}
// 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 {
return Err(
"No supported system package manager found (apt-get, dnf, zypper, pacman)".to_string(),
);
};
info!(
"[install] Elevated install of packages via {}: {}",
program,
packages.join(", ")
);
let mut cmd = StdCommand::new(program);
cmd.args(base_args).args(packages);
let elevated = elevated_command::Command::new(cmd)
.output()
.map_err(|e| format!("Elevated install failed: {}", e))?;
if !elevated.status.success() {
let stderr = String::from_utf8_lossy(&elevated.stderr);
return Err(format!("Package installation failed: {}", stderr));
}
info!("[install] Elevated package install succeeded");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn repair_install_mode_uses_repair_elevation_event() {
assert_eq!(
InstallEventMode::Full.needs_elevation_event(),
"install-needs-elevation"
);
assert_eq!(
InstallEventMode::Repair.needs_elevation_event(),
"repair-needs-elevation"
);
assert!(!InstallEventMode::Repair.emit_terminal_events());
}
}

View file

@ -0,0 +1,189 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod commands;
mod desktop_auth;
mod install;
mod preflight;
mod process;
mod update;
mod windows_job;
use log::info;
use process::new_backend_state;
use simplelog::{
CombinedLogger, Config, LevelFilter, SharedLogger, TermLogger, TerminalMode, WriteLogger,
};
use std::fs;
use tauri::menu::{MenuBuilder, MenuItemBuilder};
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
use tauri::{Emitter, Manager};
fn setup_logging() {
let mut loggers: Vec<Box<dyn SharedLogger>> = vec![];
// Always log to stderr for development
loggers.push(TermLogger::new(
LevelFilter::Info,
Config::default(),
TerminalMode::Stderr,
simplelog::ColorChoice::Auto,
));
// Try to set up file logging to ~/.unsloth/studio/tauri.log
if let Some(home) = dirs::home_dir() {
let log_dir = home.join(".unsloth").join("studio");
if fs::create_dir_all(&log_dir).is_ok() {
let log_path = log_dir.join("tauri.log");
let rotated_path = log_dir.join("tauri.log.1");
let max_log_bytes = 5 * 1024 * 1024;
if fs::metadata(&log_path)
.map(|meta| meta.len() >= max_log_bytes)
.unwrap_or(false)
{
let _ = fs::remove_file(&rotated_path);
let _ = fs::rename(&log_path, &rotated_path);
}
if let Ok(file) = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
{
loggers.push(WriteLogger::new(LevelFilter::Info, Config::default(), file));
}
}
}
if !loggers.is_empty() {
let _ = CombinedLogger::init(loggers);
}
}
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)?;
let quit = MenuItemBuilder::with_id("quit", "Quit").build(app)?;
let menu = MenuBuilder::new(app)
.items(&[&open, &toggle, &quit])
.build()?;
TrayIconBuilder::new()
.menu(&menu)
.tooltip("Unsloth Studio (Desktop)")
.icon(app.default_window_icon().unwrap().clone())
.on_menu_event(move |app, event| match event.id().as_ref() {
"open" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
"toggle" => {
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);
}
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
if let Some(window) = tray.app_handle().get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
})
.build(app)?;
Ok(())
}
fn main() {
// Fix PATH for GUI apps (macOS .app bundles, Linux AppImage, Windows)
// GUI apps don't inherit shell dotfile PATH — this spawns the user's
// login shell to source .zshrc/.bashrc/.profile and sets PATH properly.
let _ = fix_path_env::fix();
setup_logging();
info!("Unsloth Studio desktop app starting");
windows_job::initialize();
tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}))
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.manage(install::new_install_state())
.manage(new_backend_state())
.manage(process::new_shutdown_flag())
.manage(update::new_update_state())
.invoke_handler(tauri::generate_handler![
commands::check_install_status,
commands::desktop_preflight,
commands::start_install,
commands::start_server,
commands::start_managed_server,
commands::stop_server,
commands::check_health,
commands::get_server_logs,
commands::open_logs_dir,
commands::start_backend_update,
commands::start_managed_repair,
commands::install_system_packages,
desktop_auth::desktop_auth,
])
.setup(|app| {
setup_tray(app)?;
Ok(())
})
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
// Hide window instead of closing — this is a tray app.
// Processes keep running so the backend stays available.
// Full cleanup happens via:
// - Tray "Quit" menu item (explicit user action)
// - RunEvent::Exit handler (OS shutdown, SIGTERM, etc.)
let _ = window.hide();
api.prevent_close();
}
})
.build(tauri::generate_context!())
.expect("error while building tauri application")
.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);
}
}
});
}

View file

@ -0,0 +1,831 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::process::Stdio;
use std::time::Duration;
use tokio::io::AsyncReadExt;
use tokio::process::Command;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DesktopPreflightDisposition {
NotInstalled,
ManagedReady,
ManagedStale,
AttachedReady,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DesktopPreflightResult {
pub disposition: DesktopPreflightDisposition,
pub reason: Option<String>,
pub port: Option<u16>,
pub can_auto_repair: bool,
pub managed_bin: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ManagedProbe {
Missing,
Ready { bin: PathBuf },
Stale { bin: PathBuf, reason: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum BackendProbe {
Missing,
Ready { port: u16 },
Old { port: u16, reason: String },
}
#[derive(Debug, Deserialize)]
struct DesktopCapability {
desktop_protocol_version: Option<u16>,
supports_api_only: Option<bool>,
supports_provision_desktop_auth: Option<bool>,
desktop_auth_stale_reason: Option<String>,
}
#[derive(Debug)]
struct BackendHealth {
desktop_protocol_version: Option<u16>,
supports_desktop_auth: Option<bool>,
stale_reason: Option<String>,
}
fn release_auto_repair() -> bool {
!cfg!(debug_assertions)
}
fn choose_preflight(managed: ManagedProbe, backend: BackendProbe) -> DesktopPreflightResult {
match (backend, managed) {
(BackendProbe::Ready { port }, ManagedProbe::Ready { bin }) => DesktopPreflightResult {
disposition: DesktopPreflightDisposition::AttachedReady,
reason: None,
port: Some(port),
can_auto_repair: false,
managed_bin: Some(bin),
},
(_, managed) => match managed {
ManagedProbe::Ready { bin } => DesktopPreflightResult {
disposition: DesktopPreflightDisposition::ManagedReady,
reason: None,
port: None,
can_auto_repair: false,
managed_bin: Some(bin),
},
ManagedProbe::Stale { bin, reason } => DesktopPreflightResult {
disposition: DesktopPreflightDisposition::ManagedStale,
reason: Some(reason),
port: None,
can_auto_repair: release_auto_repair(),
managed_bin: Some(bin),
},
ManagedProbe::Missing => DesktopPreflightResult {
disposition: DesktopPreflightDisposition::NotInstalled,
reason: None,
port: None,
can_auto_repair: false,
managed_bin: None,
},
},
}
}
async fn run_cli_probe(bin: &std::path::Path, args: &[&str]) -> bool {
let mut cmd = Command::new(bin);
cmd.args(args).stdout(Stdio::null()).stderr(Stdio::null());
#[cfg(target_os = "linux")]
if std::env::var_os("APPIMAGE").is_some() {
cmd.env_remove("LD_LIBRARY_PATH");
cmd.env_remove("PYTHONHOME");
cmd.env_remove("PYTHONPATH");
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(crate::process::CREATE_NO_WINDOW);
}
let Ok(mut child) = cmd.spawn() else {
return false;
};
match tokio::time::timeout(Duration::from_secs(10), child.wait()).await {
Ok(Ok(status)) => status.success(),
_ => {
let _ = child.kill().await;
let _ = child.wait().await;
false
}
}
}
async fn probe_cli_capability(bin: &std::path::Path) -> Option<DesktopCapability> {
let mut cmd = Command::new(bin);
cmd.args(["studio", "desktop-capabilities", "--json"])
.stdout(Stdio::piped())
.stderr(Stdio::null());
#[cfg(target_os = "linux")]
if std::env::var_os("APPIMAGE").is_some() {
cmd.env_remove("LD_LIBRARY_PATH");
cmd.env_remove("PYTHONHOME");
cmd.env_remove("PYTHONPATH");
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(crate::process::CREATE_NO_WINDOW);
}
let Ok(mut child) = cmd.spawn() else {
return None;
};
let Some(mut stdout) = child.stdout.take() else {
return None;
};
match tokio::time::timeout(Duration::from_secs(10), child.wait()).await {
Ok(Ok(status)) if status.success() => {}
Err(_) => {
let _ = child.kill().await;
let _ = child.wait().await;
return None;
}
_ => return None,
}
let mut output = Vec::new();
if stdout.read_to_end(&mut output).await.is_err() {
return None;
}
serde_json::from_slice::<DesktopCapability>(&output).ok()
}
fn desktop_capability_ready(capability: &DesktopCapability) -> bool {
capability.desktop_protocol_version == Some(1)
&& capability.supports_api_only == Some(true)
&& capability.supports_provision_desktop_auth == Some(true)
}
fn desktop_capability_stale_reason(capability: &DesktopCapability) -> String {
capability
.desktop_auth_stale_reason
.clone()
.unwrap_or_else(|| "desktop_capability_incompatible".to_string())
}
async fn probe_managed_bin(bin: PathBuf) -> ManagedProbe {
if !run_cli_probe(&bin, &["-h"]).await {
return ManagedProbe::Stale {
bin,
reason: "cli_unusable".to_string(),
};
}
let capability = probe_cli_capability(&bin).await;
if let Some(capability) = capability {
if desktop_capability_ready(&capability) {
return ManagedProbe::Ready { bin };
}
return ManagedProbe::Stale {
bin,
reason: desktop_capability_stale_reason(&capability),
};
}
ManagedProbe::Stale {
bin,
reason: "desktop_capability_probe_failed".to_string(),
}
}
async fn probe_managed_install() -> ManagedProbe {
match crate::process::find_unsloth_binary() {
Some(bin) => probe_managed_bin(bin).await,
None => ManagedProbe::Missing,
}
}
pub async fn managed_install_ready() -> bool {
matches!(probe_managed_install().await, ManagedProbe::Ready { .. })
}
async fn backend_health(client: &reqwest::Client, port: u16) -> Option<BackendHealth> {
let url = format!("http://127.0.0.1:{port}/api/health");
let response = client.get(url).send().await.ok()?;
if !response.status().is_success() {
return None;
}
let json = response.json::<serde_json::Value>().await.ok()?;
let healthy = json
.get("status")
.and_then(|v| v.as_str())
.map(|s| s == "healthy")
.unwrap_or(false);
let service = json
.get("service")
.and_then(|v| v.as_str())
.map(|s| s == "Unsloth UI Backend")
.unwrap_or(false);
if !healthy || !service {
return None;
}
let desktop_protocol_version = json
.get("desktop_protocol_version")
.and_then(|v| v.as_u64())
.and_then(|v| u16::try_from(v).ok());
let supports_desktop_auth = json.get("supports_desktop_auth").and_then(|v| v.as_bool());
if desktop_protocol_version.is_none() && supports_desktop_auth.is_none() {
return None;
}
let stale_reason = match supports_desktop_auth {
Some(false) => json
.get("desktop_auth_stale_reason")
.and_then(|v| v.as_str())
.map(ToOwned::to_owned),
_ => None,
};
Some(BackendHealth {
desktop_protocol_version,
supports_desktop_auth,
stale_reason,
})
}
fn backend_capability_stale_reason(health: &BackendHealth) -> Option<String> {
if health.desktop_protocol_version != Some(1) {
return health
.stale_reason
.clone()
.or_else(|| Some("desktop_protocol_incompatible".to_string()));
}
if health.supports_desktop_auth != Some(true) {
return health
.stale_reason
.clone()
.or_else(|| Some("desktop_auth_unsupported".to_string()));
}
None
}
#[derive(Serialize)]
struct DesktopLoginProbe<'a> {
secret: &'a str,
}
async fn backend_desktop_auth_status(
client: &reqwest::Client,
port: u16,
health: &BackendHealth,
) -> BackendProbe {
if let Some(reason) = backend_capability_stale_reason(health) {
return BackendProbe::Old { port, reason };
}
let url = format!("http://127.0.0.1:{port}/api/auth/desktop-login");
let response = client
.post(url)
.json(&DesktopLoginProbe {
secret: "desktop-preflight-invalid-secret",
})
.send()
.await;
let Ok(response) = response else {
return BackendProbe::Old {
port,
reason: backend_capability_stale_reason(health)
.unwrap_or_else(|| "desktop_login_probe_failed".to_string()),
};
};
match response.status() {
reqwest::StatusCode::UNAUTHORIZED => BackendProbe::Ready { port },
reqwest::StatusCode::NOT_FOUND => BackendProbe::Old {
port,
reason: "desktop_login_not_found".to_string(),
},
_ => BackendProbe::Old {
port,
reason: backend_capability_stale_reason(health)
.unwrap_or_else(|| "desktop_login_probe_failed".to_string()),
},
}
}
async fn probe_existing_backends() -> BackendProbe {
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()
{
Ok(client) => client,
Err(_) => return BackendProbe::Missing,
};
// Fan out health probes concurrently. The desktop-auth probe is still
// sequential per candidate because it has auth-log side effects.
let ports: Vec<u16> = (8888u16..=8908).collect();
let mut health_futs = Vec::with_capacity(ports.len());
for port in ports {
// why: reqwest::Client is internally Arc-wrapped; clone is a refcount bump
// (documented cheap). tokio::spawn needs 'static, so each task owns its own clone.
let c = client.clone();
health_futs.push(tokio::spawn(async move {
backend_health(&c, port).await.map(|h| (port, h))
}));
}
let mut candidates: Vec<(u16, BackendHealth)> = Vec::new();
for fut in health_futs {
if let Ok(Some(pair)) = fut.await {
candidates.push(pair);
}
}
let mut first_old = None;
for (port, health) in candidates {
match backend_desktop_auth_status(&client, port, &health).await {
ready @ BackendProbe::Ready { .. } => return ready,
old @ BackendProbe::Old { .. } if first_old.is_none() => first_old = Some(old),
_ => {}
}
}
first_old.unwrap_or(BackendProbe::Missing)
}
pub async fn desktop_preflight_result() -> DesktopPreflightResult {
let (managed, backend) = tokio::join!(probe_managed_install(), probe_existing_backends());
choose_preflight(managed, backend)
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
#[test]
fn compatible_backend_does_not_win_over_stale_managed_install() {
let result = choose_preflight(
ManagedProbe::Stale {
bin: PathBuf::from("/managed/unsloth"),
reason: "old cli".to_string(),
},
BackendProbe::Ready { port: 8000 },
);
assert_eq!(
result.disposition,
DesktopPreflightDisposition::ManagedStale
);
assert_eq!(result.port, None);
assert_eq!(result.reason, Some("old cli".to_string()));
assert_eq!(result.can_auto_repair, release_auto_repair());
assert_eq!(result.managed_bin, Some(PathBuf::from("/managed/unsloth")));
}
#[test]
fn compatible_backend_wins_over_ready_managed_install() {
let result = choose_preflight(
ManagedProbe::Ready {
bin: PathBuf::from("/managed/unsloth"),
},
BackendProbe::Ready { port: 8000 },
);
assert_eq!(
result.disposition,
DesktopPreflightDisposition::AttachedReady
);
assert_eq!(result.port, Some(8000));
assert_eq!(result.managed_bin, Some(PathBuf::from("/managed/unsloth")));
assert!(!result.can_auto_repair);
}
#[test]
fn compatible_backend_does_not_win_over_missing_managed_install() {
let result = choose_preflight(ManagedProbe::Missing, BackendProbe::Ready { port: 8000 });
assert_eq!(
result.disposition,
DesktopPreflightDisposition::NotInstalled
);
assert_eq!(result.port, None);
assert_eq!(result.managed_bin, None);
assert!(!result.can_auto_repair);
}
#[test]
fn old_backend_falls_back_to_ready_managed_install() {
let result = choose_preflight(
ManagedProbe::Ready {
bin: PathBuf::from("/managed/unsloth"),
},
BackendProbe::Old {
port: 8001,
reason: "missing endpoint".to_string(),
},
);
assert_eq!(
result.disposition,
DesktopPreflightDisposition::ManagedReady
);
assert_eq!(result.reason, None);
assert_eq!(result.port, None);
assert_eq!(result.managed_bin, Some(PathBuf::from("/managed/unsloth")));
assert!(!result.can_auto_repair);
}
#[test]
fn old_backend_falls_back_to_stale_managed_install() {
let result = choose_preflight(
ManagedProbe::Stale {
bin: PathBuf::from("/managed/unsloth"),
reason: "old cli".to_string(),
},
BackendProbe::Old {
port: 8001,
reason: "missing endpoint".to_string(),
},
);
assert_eq!(
result.disposition,
DesktopPreflightDisposition::ManagedStale
);
assert_eq!(result.reason, Some("old cli".to_string()));
assert_eq!(result.port, None);
assert_eq!(result.can_auto_repair, release_auto_repair());
assert_eq!(result.managed_bin, Some(PathBuf::from("/managed/unsloth")));
}
#[test]
fn managed_ready_when_no_backend() {
let result = choose_preflight(
ManagedProbe::Ready {
bin: PathBuf::from("/managed/unsloth"),
},
BackendProbe::Missing,
);
assert_eq!(
result.disposition,
DesktopPreflightDisposition::ManagedReady
);
assert_eq!(result.managed_bin, Some(PathBuf::from("/managed/unsloth")));
assert!(!result.can_auto_repair);
}
#[test]
fn managed_stale_when_no_backend() {
let result = choose_preflight(
ManagedProbe::Stale {
bin: PathBuf::from("/managed/unsloth"),
reason: "old cli".to_string(),
},
BackendProbe::Missing,
);
assert_eq!(
result.disposition,
DesktopPreflightDisposition::ManagedStale
);
assert_eq!(result.reason, Some("old cli".to_string()));
assert_eq!(result.can_auto_repair, release_auto_repair());
}
#[test]
fn not_installed_when_no_backend_no_managed_binary() {
let result = choose_preflight(ManagedProbe::Missing, BackendProbe::Missing);
assert_eq!(
result.disposition,
DesktopPreflightDisposition::NotInstalled
);
assert_eq!(result.managed_bin, None);
assert!(!result.can_auto_repair);
}
#[test]
fn old_backend_with_no_managed_install_uses_install_flow() {
let result = choose_preflight(
ManagedProbe::Missing,
BackendProbe::Old {
port: 8002,
reason: "old version".to_string(),
},
);
assert_eq!(
result.disposition,
DesktopPreflightDisposition::NotInstalled
);
assert_eq!(result.reason, None);
assert_eq!(result.port, None);
assert!(!result.can_auto_repair);
}
#[cfg(unix)]
struct FakeCli {
bin: PathBuf,
dir: PathBuf,
}
#[cfg(unix)]
impl Drop for FakeCli {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
#[cfg(unix)]
fn fake_cli(test_name: &str, script: &str) -> FakeCli {
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!(
"unsloth-preflight-{test_name}-{}-{nanos}",
std::process::id()
));
fs::create_dir_all(&dir).unwrap();
let bin = dir.join("unsloth");
fs::write(&bin, script).unwrap();
let mut perms = fs::metadata(&bin).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&bin, perms).unwrap();
FakeCli { bin, dir }
}
#[cfg(unix)]
#[tokio::test]
async fn managed_cli_stale_when_desktop_capabilities_missing() {
let fake = fake_cli(
"cap-missing",
r#"#!/bin/sh
if [ "$1" = "-h" ]; then exit 0; fi
if [ "$1" = "studio" ] && [ "$2" = "provision-desktop-auth" ] && [ "$3" = "--help" ]; then exit 0; fi
exit 1
"#,
);
let bin = fake.bin.clone();
assert!(matches!(
probe_managed_bin(bin.clone()).await,
ManagedProbe::Stale { bin: actual_bin, .. } if actual_bin == bin
));
}
#[cfg(unix)]
#[tokio::test]
async fn managed_cli_stale_when_desktop_capabilities_command_missing() {
let fake = fake_cli(
"missing-helper",
r#"#!/bin/sh
if [ "$1" = "-h" ]; then exit 0; fi
exit 1
"#,
);
let bin = fake.bin.clone();
assert!(matches!(
probe_managed_bin(bin.clone()).await,
ManagedProbe::Stale { bin: actual_bin, .. } if actual_bin == bin
));
}
#[cfg(unix)]
#[tokio::test]
async fn managed_cli_stale_when_help_broken() {
let fake = fake_cli(
"broken-help",
r#"#!/bin/sh
exit 1
"#,
);
let bin = fake.bin.clone();
assert_eq!(
probe_managed_bin(bin.clone()).await,
ManagedProbe::Stale {
bin,
reason: "cli_unusable".to_string()
}
);
}
#[cfg(unix)]
#[tokio::test]
async fn managed_cli_ready_when_desktop_capabilities_compatible() {
let fake = fake_cli(
"cap-true-helper-missing",
r#"#!/bin/sh
if [ "$1" = "-h" ]; then exit 0; fi
if [ "$1" = "studio" ] && [ "$2" = "desktop-capabilities" ] && [ "$3" = "--json" ]; then
printf '{"desktop_protocol_version":1,"supports_api_only":true,"supports_provision_desktop_auth":true}'
exit 0
fi
exit 1
"#,
);
let bin = fake.bin.clone();
assert_eq!(
probe_managed_bin(bin.clone()).await,
ManagedProbe::Ready { bin }
);
}
#[cfg(unix)]
#[tokio::test]
async fn capability_false_reason_used_when_legacy_helper_missing() {
let fake = fake_cli(
"cap-false-helper-missing",
r#"#!/bin/sh
if [ "$1" = "-h" ]; then exit 0; fi
if [ "$1" = "studio" ] && [ "$2" = "desktop-capabilities" ] && [ "$3" = "--json" ]; then
printf '{"desktop_protocol_version":1,"supports_api_only":true,"supports_provision_desktop_auth":false,"desktop_auth_stale_reason":"cap_false"}'
exit 0
fi
exit 1
"#,
);
let bin = fake.bin.clone();
assert_eq!(
probe_managed_bin(bin.clone()).await,
ManagedProbe::Stale {
bin,
reason: "cap_false".to_string()
}
);
}
#[cfg(unix)]
#[tokio::test]
async fn capability_false_overrides_working_legacy_helper() {
let fake = fake_cli(
"cap-false-helper-ready",
r#"#!/bin/sh
if [ "$1" = "-h" ]; then exit 0; fi
if [ "$1" = "studio" ] && [ "$2" = "desktop-capabilities" ] && [ "$3" = "--json" ]; then
printf '{"desktop_protocol_version":1,"supports_api_only":true,"supports_provision_desktop_auth":false,"desktop_auth_stale_reason":"cap_false"}'
exit 0
fi
if [ "$1" = "studio" ] && [ "$2" = "provision-desktop-auth" ] && [ "$3" = "--help" ]; then exit 0; fi
exit 1
"#,
);
let bin = fake.bin.clone();
assert_eq!(
probe_managed_bin(bin.clone()).await,
ManagedProbe::Stale {
bin,
reason: "cap_false".to_string()
}
);
}
async fn backend_server(health_body: &'static str, route_status: &'static str) -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
for _ in 0..2 {
let (mut stream, _) = listener.accept().await.unwrap();
let mut buffer = [0; 2048];
let n = stream.read(&mut buffer).await.unwrap();
let request = String::from_utf8_lossy(&buffer[..n]);
let (status, body) = if request.starts_with("GET /api/health ") {
("200 OK", health_body)
} else if request.starts_with("POST /api/auth/desktop-login ") {
(route_status, "")
} else {
("404 Not Found", "")
};
let response = format!(
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
stream.write_all(response.as_bytes()).await.unwrap();
}
});
port
}
async fn probe_test_backend(
health_body: &'static str,
route_status: &'static str,
) -> BackendProbe {
let port = backend_server(health_body, route_status).await;
let client = reqwest::Client::new();
let health = backend_health(&client, port).await.unwrap();
backend_desktop_auth_status(&client, port, &health).await
}
#[tokio::test]
async fn backend_health_without_desktop_capability_fields_is_not_compatible() {
let port = backend_server(
r#"{"status":"healthy","service":"Unsloth UI Backend"}"#,
"401 Unauthorized",
)
.await;
let client = reqwest::Client::new();
assert!(backend_health(&client, port).await.is_none());
}
#[tokio::test]
async fn backend_with_auth_support_but_missing_protocol_is_old() {
let probe = probe_test_backend(
r#"{"status":"healthy","service":"Unsloth UI Backend","supports_desktop_auth":true}"#,
"401 Unauthorized",
)
.await;
assert!(matches!(probe, BackendProbe::Old { .. }));
}
#[tokio::test]
async fn backend_with_auth_support_but_unsupported_protocol_is_old() {
let probe = probe_test_backend(
r#"{"status":"healthy","service":"Unsloth UI Backend","desktop_protocol_version":2,"supports_desktop_auth":true}"#,
"401 Unauthorized",
)
.await;
assert!(matches!(probe, BackendProbe::Old { .. }));
}
#[tokio::test]
async fn backend_health_with_desktop_capability_fields_and_401_is_ready() {
let probe = probe_test_backend(
r#"{"status":"healthy","service":"Unsloth UI Backend","desktop_protocol_version":1,"supports_desktop_auth":true}"#,
"401 Unauthorized",
)
.await;
assert!(matches!(probe, BackendProbe::Ready { .. }));
}
#[tokio::test]
async fn backend_route_404_is_old() {
let probe = probe_test_backend(
r#"{"status":"healthy","service":"Unsloth UI Backend","desktop_protocol_version":1,"supports_desktop_auth":true}"#,
"404 Not Found",
)
.await;
assert!(matches!(
probe,
BackendProbe::Old {
reason,
..
} if reason == "desktop_login_not_found"
));
}
#[tokio::test]
async fn backend_route_500_is_old() {
let probe = probe_test_backend(
r#"{"status":"healthy","service":"Unsloth UI Backend","desktop_protocol_version":1,"supports_desktop_auth":true}"#,
"500 Internal Server Error",
)
.await;
assert!(matches!(probe, BackendProbe::Old { .. }));
}
#[tokio::test]
async fn backend_capability_false_is_old_even_when_route_401() {
let port = backend_server(
r#"{"status":"healthy","service":"Unsloth UI Backend","desktop_protocol_version":1,"supports_desktop_auth":false,"desktop_auth_stale_reason":"cap_false"}"#,
"401 Unauthorized",
)
.await;
let client = reqwest::Client::new();
let health = backend_health(&client, port).await.unwrap();
assert!(matches!(
backend_desktop_auth_status(&client, port, &health).await,
BackendProbe::Old {
reason,
..
} if reason == "cap_false"
));
}
}

View file

@ -0,0 +1,537 @@
use log::{error, info, warn};
use process_wrap::std::*;
use regex::Regex;
use std::collections::VecDeque;
use std::io::BufRead;
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use tauri::{AppHandle, Emitter};
const MAX_LOG_LINES: usize = 1000;
pub struct BackendProcess {
pub child: Option<Box<dyn ChildWrapper + Send>>,
pub port: Option<u16>,
pub logs: VecDeque<String>,
pub intentional_stop: bool,
}
impl Default for BackendProcess {
fn default() -> Self {
Self {
child: None,
port: None,
logs: VecDeque::with_capacity(MAX_LOG_LINES),
intentional_stop: false,
}
}
}
pub type BackendState = Arc<Mutex<BackendProcess>>;
pub type ShutdownFlag = Arc<AtomicBool>;
pub fn new_backend_state() -> BackendState {
Arc::new(Mutex::new(BackendProcess::default()))
}
pub fn new_shutdown_flag() -> ShutdownFlag {
Arc::new(AtomicBool::new(false))
}
pub(crate) fn trim_line_endings(bytes: &[u8]) -> &[u8] {
let mut end = bytes.len();
while end > 0 && matches!(bytes[end - 1], b'\n' | b'\r') {
end -= 1;
}
&bytes[..end]
}
/// Windows `CREATE_NO_WINDOW` flag — suppresses console windows for child processes.
#[cfg(windows)]
pub(crate) const CREATE_NO_WINDOW: u32 = 0x08000000;
/// Force-kill a Windows process tree via hidden `taskkill /T /F`, falling
/// back to `child.kill()` if taskkill itself fails. Reaps the child afterward.
#[cfg(windows)]
pub(crate) fn force_kill_process_tree(
pid: u32,
child: &mut Box<dyn ChildWrapper + Send>,
label: &str,
) {
use std::os::windows::process::CommandExt;
let taskkill_status = Command::new("taskkill.exe")
.creation_flags(CREATE_NO_WINDOW)
.args(["/PID", &pid.to_string(), "/T", "/F"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
match taskkill_status {
Ok(status) if status.success() => {}
Ok(status) => {
warn!(
"taskkill returned non-zero status for {} pid {}: {}",
label, pid, status
);
let _ = child.kill();
}
Err(e) => {
warn!("taskkill failed for {} pid {}: {}", label, pid, e);
let _ = child.kill();
}
}
let _ = child.wait();
info!("{} process tree force stopped", label);
}
/// Returns the path to the unsloth binary inside the managed venv, if it exists.
/// Checks the new layout (~/.unsloth/studio/unsloth_studio/) first,
/// then falls back to the old layout (~/.unsloth/studio/.venv/) for compat.
fn find_unsloth_binary_in_studio_dir(studio: &std::path::Path) -> Option<std::path::PathBuf> {
// New layout (upstream scripts >= March 2026)
let new_base = studio.join("unsloth_studio");
// Old layout (bundled scripts, older upstream)
let old_base = studio.join(".venv");
for base in [new_base, old_base] {
#[cfg(unix)]
let bin = base.join("bin").join("unsloth");
#[cfg(windows)]
let bin = base.join("Scripts").join("unsloth.exe");
if bin.exists() {
return Some(bin);
}
}
None
}
pub fn find_unsloth_binary() -> Option<std::path::PathBuf> {
let home = dirs::home_dir()?;
let studio = home.join(".unsloth").join("studio");
find_unsloth_binary_in_studio_dir(&studio)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_studio_dir(test_name: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!(
"unsloth-{test_name}-{}-{nanos}",
std::process::id()
));
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn finds_new_layout_before_legacy_layout() {
let temp = temp_studio_dir("new-before-legacy");
#[cfg(unix)]
let new_bin = temp.join("unsloth_studio/bin/unsloth");
#[cfg(unix)]
let old_bin = temp.join(".venv/bin/unsloth");
#[cfg(windows)]
let new_bin = temp.join("unsloth_studio/Scripts/unsloth.exe");
#[cfg(windows)]
let old_bin = temp.join(".venv/Scripts/unsloth.exe");
fs::create_dir_all(new_bin.parent().unwrap()).unwrap();
fs::create_dir_all(old_bin.parent().unwrap()).unwrap();
fs::write(&new_bin, "").unwrap();
fs::write(&old_bin, "").unwrap();
assert_eq!(find_unsloth_binary_in_studio_dir(&temp), Some(new_bin));
fs::remove_dir_all(temp).unwrap();
}
#[test]
fn finds_legacy_layout_when_new_missing() {
let temp = temp_studio_dir("legacy");
#[cfg(unix)]
let old_bin = temp.join(".venv/bin/unsloth");
#[cfg(windows)]
let old_bin = temp.join(".venv/Scripts/unsloth.exe");
fs::create_dir_all(old_bin.parent().unwrap()).unwrap();
fs::write(&old_bin, "").unwrap();
assert_eq!(find_unsloth_binary_in_studio_dir(&temp), Some(old_bin));
fs::remove_dir_all(temp).unwrap();
}
#[test]
fn returns_none_when_no_managed_layout_exists() {
let temp = temp_studio_dir("none");
assert_eq!(find_unsloth_binary_in_studio_dir(&temp), None);
fs::remove_dir_all(temp).unwrap();
}
#[test]
fn backend_args_always_enable_api_only() {
assert_eq!(
backend_args(8888),
vec!["studio", "--api-only", "-H", "127.0.0.1", "-p", "8888"]
);
}
}
/// Find the unsloth binary, preferring the dev repo if available.
/// In dev mode (debug builds), checks for a local .venv in the repo first.
/// Falls back to find_unsloth_binary() which checks ~/.unsloth/studio/unsloth_studio/
/// (new layout) then ~/.unsloth/studio/.venv/ (old layout).
pub(crate) fn resolve_backend_binary() -> Result<std::path::PathBuf, String> {
// In dev mode, check for local repo venv first
#[cfg(debug_assertions)]
{
// CARGO_MANIFEST_DIR is set at compile time to studio/src-tauri/
// Repo root is 2 levels up: studio/src-tauri -> studio -> repo_root
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let repo_root = std::path::Path::new(manifest_dir)
.parent() // studio/
.and_then(|p| p.parent()); // repo_root/
if let Some(root) = repo_root {
#[cfg(unix)]
let dev_bin = root.join(".venv/bin/unsloth");
#[cfg(windows)]
let dev_bin = root.join(".venv/Scripts/unsloth.exe");
if dev_bin.exists() {
info!("Dev mode: using local repo backend at {:?}", dev_bin);
return Ok(dev_bin.to_path_buf());
}
}
info!("Dev mode: no local .venv found, falling back to installed backend");
}
find_unsloth_binary()
.ok_or_else(|| "Unsloth binary not found. Please install Unsloth Studio first.".to_string())
}
fn backend_args(port: u16) -> Vec<String> {
[
"studio",
"--api-only",
"-H",
"127.0.0.1",
"-p",
&port.to_string(),
]
.into_iter()
.map(String::from)
.collect()
}
/// Spawn the backend process and wire up stdout/stderr reader threads.
pub fn start_backend(
app: &AppHandle,
state: &BackendState,
port: u16,
shutdown: &ShutdownFlag,
) -> Result<(), String> {
let bin = resolve_backend_binary()?;
shutdown.store(false, Ordering::SeqCst);
// Reset state
{
let mut proc = state.lock().map_err(|e| e.to_string())?;
if proc.child.is_some() {
return Err("Backend is already running.".to_string());
}
proc.port = None;
proc.logs.clear();
proc.intentional_stop = false;
}
let args = backend_args(port);
info!("Starting backend: {:?} {}", bin, args.join(" "));
let mut cmd = Command::new(&bin);
cmd.args(&args)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
// AppImage sets LD_LIBRARY_PATH to its bundled libs, which breaks the spawned
// Python process (wrong libpython/libz → "No module named encodings").
// Only clear when running inside an AppImage — native .deb/.rpm installs may
// need these env vars for custom CUDA or conda paths.
#[cfg(target_os = "linux")]
if std::env::var_os("APPIMAGE").is_some() {
cmd.env_remove("LD_LIBRARY_PATH");
cmd.env_remove("PYTHONHOME");
cmd.env_remove("PYTHONPATH");
}
// On Windows, launch the backend directly with hidden-window flags.
// The app process is assigned to a KILL_ON_JOB_CLOSE job in main.rs, so
// children inherit crash-safe cleanup without the buggy per-child JobObject wrapper.
#[cfg(windows)]
let mut child: Box<dyn ChildWrapper + Send> = {
use std::os::windows::process::CommandExt;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW);
let child = cmd
.spawn()
.map_err(|e| format!("Failed to spawn backend: {}", e))?;
Box::new(child)
};
#[cfg(unix)]
let mut child: Box<dyn ChildWrapper + Send> = {
// Keep the backend tree in a process group on Unix for cleanup.
let mut wrap = CommandWrap::from(cmd);
wrap.wrap(ProcessGroup::leader());
wrap.spawn()
.map_err(|e| format!("Failed to spawn backend: {}", e))?
};
let stdout = child.stdout().take();
let stderr = child.stderr().take();
// Store child in state
{
let mut proc = state.lock().map_err(|e| e.to_string())?;
proc.child = Some(child);
}
// 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);
});
}
// Spawn stderr reader thread
if let Some(stderr) = stderr {
let app_handle = app.clone();
let state_clone = Arc::clone(state);
std::thread::spawn(move || {
read_output_stream(stderr, &app_handle, &state_clone, true);
});
}
Ok(())
}
/// Read lines from a child process stream (stdout or stderr).
/// For stdout, parse TAURI_PORT=(\d+) to detect the actual port.
/// When stdout closes and the stop was not intentional, emit server-crashed.
fn read_output_stream<R: std::io::Read>(
stream: R,
app: &AppHandle,
state: &BackendState,
is_stderr: bool,
) {
let mut reader = std::io::BufReader::new(stream);
let port_re = Regex::new(r"TAURI_PORT=(\d+)").unwrap();
let mut buf = Vec::new();
loop {
buf.clear();
match reader.read_until(b'\n', &mut buf) {
Ok(0) => break,
Ok(_) => {
let text = String::from_utf8_lossy(trim_line_endings(&buf)).into_owned();
let log_line = if is_stderr {
format!("[stderr] {}", text)
} else {
text.clone()
};
// 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);
}
}
}
}
// 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());
}
info!("[backend] {}", log_line);
// Emit to frontend
let _ = app.emit("server-log", &log_line);
}
Err(e) => {
warn!(
"Error reading backend {}: {}",
if is_stderr { "stderr" } else { "stdout" },
e
);
break;
}
}
}
// Stream closed. Only the stdout reader checks for crashes.
if !is_stderr {
if let Ok(mut proc) = state.lock() {
let intentional = proc.intentional_stop;
let exited = if let Some(ref mut child) = proc.child {
match child.try_wait() {
Ok(Some(status)) => {
info!("Backend stdout stream ended with status: {}", status);
true
}
Ok(None) => {
warn!("Backend stdout stream ended, but process is still running");
false
}
Err(e) => {
warn!("Failed to query backend status after stdout closed: {}", e);
false
}
}
} else {
false
};
if exited {
proc.child = None;
if !intentional {
error!("Backend process stdout closed unexpectedly (crash detected)");
let _ = app.emit("server-crashed", ());
}
}
}
}
}
/// Graceful shutdown of the backend process and its entire subprocess tree.
/// Unix: SIGTERM to process group -> wait up to 5s -> SIGKILL to group
/// Windows: CTRL_BREAK_EVENT -> wait up to 5s -> hidden taskkill /T /F
pub fn stop_backend(state: &BackendState, shutdown: &ShutdownFlag) -> Result<(), String> {
shutdown.store(true, Ordering::SeqCst);
// Extract the child and mark intentional stop.
// We take the child OUT of the mutex so we don't hold the lock during the wait loop.
let mut child = {
let mut proc = match state.lock() {
Ok(guard) => guard,
Err(poisoned) => {
warn!("Backend state mutex poisoned, recovering for cleanup");
poisoned.into_inner()
}
};
proc.intentional_stop = true;
proc.child.take()
};
let Some(ref mut child) = child else {
return Ok(()); // Nothing running
};
let pid = child.id();
info!("Stopping backend process group (pid {})", pid);
// Send SIGTERM to the entire process group (negative PID = group signal).
// This gives Python and any workers a chance to shut down gracefully.
#[cfg(unix)]
{
if pid > i32::MAX as u32 {
// PID too large for i32 negation, fall back to direct kill
warn!("PID {} exceeds i32 range, using direct kill", pid);
let _ = child.kill();
let _ = child.wait();
return Ok(());
}
unsafe {
libc::kill(-(pid as i32), libc::SIGTERM);
}
}
#[cfg(windows)]
{
unsafe {
windows_sys::Win32::System::Console::GenerateConsoleCtrlEvent(
windows_sys::Win32::System::Console::CTRL_BREAK_EVENT,
pid,
);
}
}
// Poll for up to 5 seconds (50 iterations * 100ms)
for _ in 0..50 {
match child.try_wait() {
Ok(Some(status)) => {
info!("Backend exited with status: {}", status);
return Ok(());
}
Ok(None) => {
std::thread::sleep(std::time::Duration::from_millis(100));
}
Err(e) => {
warn!("Error polling backend process: {}", e);
break;
}
}
}
#[cfg(windows)]
{
warn!(
"Backend did not exit gracefully, force killing process tree (pid {})",
pid
);
force_kill_process_tree(pid, child, "Backend");
return Ok(());
}
#[cfg(unix)]
{
// Force kill the process group on Unix
warn!(
"Backend did not exit gracefully, force killing group (pid {})",
pid
);
let _ = child.kill();
// Reap the process
let _ = child.wait();
info!("Backend process group forcefully stopped");
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

@ -0,0 +1,293 @@
use log::{error, info, warn};
use process_wrap::std::*;
use std::io::BufRead;
use std::process::{Command, ExitStatus, Stdio};
use std::sync::{Arc, Mutex};
use tauri::{AppHandle, Emitter};
// ── Types ──
pub struct UpdateProcess {
pub child: Option<Box<dyn ChildWrapper + Send>>,
pub intentional_stop: bool,
}
impl Default for UpdateProcess {
fn default() -> Self {
Self {
child: None,
intentional_stop: false,
}
}
}
pub type UpdateState = Arc<Mutex<UpdateProcess>>;
pub fn new_update_state() -> UpdateState {
Arc::new(Mutex::new(UpdateProcess::default()))
}
// ── Spawn ──
fn spawn_update(
bin: &std::path::Path,
state: &UpdateState,
) -> Result<
(
Option<std::process::ChildStdout>,
Option<std::process::ChildStderr>,
),
String,
> {
let mut update = state.lock().map_err(|e| e.to_string())?;
if update.child.is_some() {
return Err("Update is already running.".to_string());
}
update.intentional_stop = false;
let mut cmd = Command::new(bin);
cmd.args(["studio", "update"])
.stdout(Stdio::piped())
.stderr(Stdio::piped());
// AppImage sets LD_LIBRARY_PATH to its bundled libs, which breaks Python
#[cfg(target_os = "linux")]
if std::env::var_os("APPIMAGE").is_some() {
cmd.env_remove("LD_LIBRARY_PATH");
cmd.env_remove("PYTHONHOME");
cmd.env_remove("PYTHONPATH");
}
#[cfg(windows)]
let mut child: Box<dyn ChildWrapper + Send> = {
use std::os::windows::process::CommandExt;
cmd.creation_flags(crate::process::CREATE_NO_WINDOW);
let child = cmd
.spawn()
.map_err(|e| format!("Failed to spawn update: {}", e))?;
Box::new(child)
};
#[cfg(unix)]
let mut child: Box<dyn ChildWrapper + Send> = {
let mut wrap = CommandWrap::from(cmd);
wrap.wrap(ProcessGroup::leader());
wrap.spawn()
.map_err(|e| format!("Failed to spawn update: {}", e))?
};
let stdout = child.stdout().take();
let stderr = child.stderr().take();
update.child = Some(child);
Ok((stdout, stderr))
}
// ── Stream ──
fn stream_output(
app: &AppHandle,
progress_event: &'static str,
stdout: Option<std::process::ChildStdout>,
stderr: Option<std::process::ChildStderr>,
) -> Vec<std::thread::JoinHandle<()>> {
let mut threads = Vec::new();
if let Some(out) = stdout {
let app_clone = app.clone();
threads.push(std::thread::spawn(move || {
let reader = std::io::BufReader::new(out);
for line in reader.lines() {
match line {
Ok(text) => {
info!("[update][stdout] {}", text);
let _ = app_clone.emit(progress_event, &text);
}
Err(e) => {
warn!("[update] Error reading stdout: {}", e);
break;
}
}
}
}));
}
if let Some(err) = stderr {
let app_clone = app.clone();
threads.push(std::thread::spawn(move || {
let reader = std::io::BufReader::new(err);
for line in reader.lines() {
match line {
Ok(text) => {
warn!("[update][stderr] {}", text);
let _ = app_clone.emit(progress_event, &text);
}
Err(e) => {
warn!("[update] Error reading stderr: {}", e);
break;
}
}
}
}));
}
threads
}
// ── Wait ──
fn wait_for_exit(state: &UpdateState) -> Result<(ExitStatus, bool), String> {
const MAX_WAIT_ITERATIONS: u32 = 72_000; // 2h at 100ms intervals
for _ in 0..MAX_WAIT_ITERATIONS {
let mut update = state.lock().map_err(|e| e.to_string())?;
let intentional = update.intentional_stop;
match update.child.as_mut() {
Some(child) => match child.try_wait() {
Ok(Some(status)) => {
update.child = None;
return Ok((status, intentional));
}
Ok(None) => {}
Err(e) => {
update.child = None;
return Err(format!("Error waiting for update: {}", e));
}
},
None if intentional => return Err("Update stopped.".to_string()),
None => return Err("Update process disappeared unexpectedly.".to_string()),
}
drop(update);
std::thread::sleep(std::time::Duration::from_millis(100));
}
let _ = stop_update(state);
Err("Update timed out after 2 hours".to_string())
}
// ── Public API ──
pub fn run_backend_update(app: AppHandle, state: UpdateState) -> Result<(), String> {
run_backend_update_with_terminal_events(app, state, true)
}
pub(crate) fn run_backend_update_for_repair(
app: AppHandle,
state: UpdateState,
) -> Result<(), String> {
run_backend_update_with_terminal_events(app, state, false)
}
fn run_backend_update_with_terminal_events(
app: AppHandle,
state: UpdateState,
terminal_events: bool,
) -> Result<(), String> {
let bin = crate::process::find_unsloth_binary()
.ok_or("Unsloth binary not found. Cannot run update.")?;
info!("[update] Starting backend update via {:?}", bin);
let progress_event = if terminal_events {
"update-progress"
} else {
"repair-progress"
};
let _ = app.emit(progress_event, "Starting backend update...");
let (stdout, stderr) = spawn_update(&bin, &state)?;
let threads = stream_output(&app, progress_event, stdout, stderr);
let result = wait_for_exit(&state);
for handle in threads {
let _ = handle.join();
}
match result {
Ok((status, _)) if status.success() => {
info!("[update] Backend update complete");
if terminal_events {
let _ = app.emit("update-complete", ());
}
Ok(())
}
Ok((_status, intentional)) if intentional => {
info!("[update] Update stopped intentionally");
Err("Update stopped.".to_string())
}
Ok((status, _)) => {
let code = status.code().unwrap_or(-1);
let msg = format!("Update exited with code {}", code);
error!("[update] {}", msg);
if terminal_events {
let _ = app.emit("update-failed", &msg);
}
Err(msg)
}
Err(msg) => {
error!("[update] {}", msg);
if terminal_events {
let _ = app.emit("update-failed", &msg);
}
Err(msg)
}
}
}
pub fn stop_update(state: &UpdateState) -> Result<(), String> {
let mut child = {
let mut update = match state.lock() {
Ok(guard) => guard,
Err(poisoned) => {
warn!("Update state mutex poisoned, recovering for cleanup");
poisoned.into_inner()
}
};
update.intentional_stop = true;
update.child.take()
};
let Some(ref mut child) = child else {
return Ok(());
};
let pid = child.id();
info!("Stopping update process group (pid {})", pid);
#[cfg(unix)]
{
if pid > i32::MAX as u32 {
warn!("PID {} exceeds i32 range, using direct kill", pid);
let _ = child.kill();
let _ = child.wait();
return Ok(());
}
unsafe {
libc::kill(-(pid as i32), libc::SIGTERM);
}
for _ in 0..50 {
match child.try_wait() {
Ok(Some(status)) => {
info!("Update exited gracefully with status: {:?}", status);
return Ok(());
}
Ok(None) => std::thread::sleep(std::time::Duration::from_millis(100)),
Err(_) => break,
}
}
warn!("Update did not exit gracefully, force killing");
}
#[cfg(windows)]
{
crate::process::force_kill_process_tree(pid, child, "Update");
return Ok(());
}
#[cfg(unix)]
{
let _ = child.kill();
let _ = child.wait();
info!("Update process group force stopped");
Ok(())
}
}

View file

@ -0,0 +1,72 @@
#[cfg(windows)]
use log::{info, warn};
#[cfg(windows)]
use std::mem::size_of;
#[cfg(windows)]
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
#[cfg(windows)]
use std::sync::OnceLock;
#[cfg(windows)]
use windows_sys::Win32::System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
};
#[cfg(windows)]
use windows_sys::Win32::System::Threading::GetCurrentProcess;
#[cfg(windows)]
static APP_JOB_INITIALIZED: OnceLock<()> = OnceLock::new();
pub fn initialize() {
#[cfg(windows)]
{
if APP_JOB_INITIALIZED.get().is_some() {
return;
}
match unsafe { create_app_job_object() } {
Ok(()) => {
let _ = APP_JOB_INITIALIZED.set(());
info!("Windows app job object initialized for crash-safe child cleanup");
}
Err(err) => {
warn!(
"Failed to initialize Windows app job object; crash cleanup will rely on explicit stop paths: {}",
err
);
}
}
}
}
#[cfg(windows)]
unsafe fn create_app_job_object() -> std::io::Result<()> {
let job = CreateJobObjectW(std::ptr::null(), std::ptr::null());
if job.is_null() {
return Err(std::io::Error::last_os_error());
}
let job = OwnedHandle::from_raw_handle(job);
let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed();
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
if SetInformationJobObject(
job.as_raw_handle(),
JobObjectExtendedLimitInformation,
&limits as *const _ as *const _,
size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
) == 0
{
return Err(std::io::Error::last_os_error());
}
if AssignProcessToJobObject(job.as_raw_handle(), GetCurrentProcess()) == 0 {
return Err(std::io::Error::last_os_error());
}
// Keep the job handle alive for the full app lifetime.
std::mem::forget(job);
Ok(())
}

View file

@ -0,0 +1,66 @@
{
"productName": "Unsloth Studio (Desktop)",
"identifier": "ai.unsloth.studio",
"build": {
"beforeDevCommand": "npm --prefix ../frontend run dev -- --port 5173 --strictPort",
"beforeBuildCommand": "npm --prefix ../frontend run build",
"frontendDist": "../frontend/dist",
"devUrl": "http://localhost:5173"
},
"app": {
"withGlobalTauri": true,
"security": {
"csp": "default-src 'self'; connect-src 'self' http://localhost:* ws://localhost:* ws://127.0.0.1:* http://127.0.0.1:* https://huggingface.co https://*.huggingface.co https://datasets-server.huggingface.co; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; font-src 'self' data:"
},
"windows": [
{
"label": "main",
"title": "Unsloth Studio (Desktop)",
"width": 690,
"height": 480,
"visible": false,
"resizable": false
}
],
"trayIcon": {
"iconPath": "icons/icon.png",
"id": "main",
"tooltip": "Unsloth Studio (Desktop)"
}
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDJENjJENDYxMTQ1QTYyOEIKUldTTFlsb1VZZFJpTFY1aHgyRkRqZUdjWGg0Sm1BVEZoMDVWME5PdE42bjZiekFRc1Fsb0JNbmIK",
"endpoints": [
"https://github.com/danielhanchen/unsloth-staging-2/releases/latest/download/latest.json"
],
"windows": {
"installMode": "passive"
}
}
},
"bundle": {
"active": true,
"createUpdaterArtifacts": true,
"targets": ["app", "appimage", "deb", "dmg", "nsis"],
"resources": {
"../../install.sh": "install.sh",
"../../install.ps1": "install.ps1",
"../install_python_stack.py": "install_python_stack.py"
},
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/icon.ico",
"icons/icon.icns"
],
"linux": {
"deb": {
"postRemoveScript": "./linux/postremove.sh"
},
"rpm": {
"postRemoveScript": "./linux/postremove.sh"
}
}
}
}

View file

@ -0,0 +1,11 @@
{
"bundle": {
"macOS": {
"entitlements": "./Entitlements.plist",
"dmg": {
"appPosition": { "x": 180, "y": 220 },
"applicationFolderPosition": { "x": 480, "y": 220 }
}
}
}
}

View file

@ -0,0 +1,16 @@
{
"bundle": {
"windows": {
"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.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

View file

@ -0,0 +1,8 @@
; 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:
!macroend

View file

@ -0,0 +1,95 @@
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
INSTALL_SH="$SCRIPT_DIR/../../install.sh"
echo "=== test_tauri_install_exit_order ==="
tauri_marker_line=$(grep -n "Tauri mode: done, skip shortcuts and auto-launch" "$INSTALL_SH" | head -n1 | cut -d: -f1)
tauri_exit_line=$(awk -v start="$tauri_marker_line" 'NR > start && /exit 0/ { print NR; exit }' "$INSTALL_SH")
tauri_done_line=$(awk -v start="$tauri_marker_line" 'NR > start && /tauri_log "DONE" ""/ { print NR; exit }' "$INSTALL_SH")
first_setup_check_line=$(grep -n 'if \[ "$_SETUP_EXIT" -ne 0 \]; then' "$INSTALL_SH" | head -n1 | cut -d: -f1)
setup_exit_line=$(awk -v start="$first_setup_check_line" 'NR > start && /exit "\$_SETUP_EXIT"/ { print NR; exit }' "$INSTALL_SH")
shortcut_line=$(grep -n 'create_studio_shortcuts "$VENV_ABS_BIN/unsloth" "$OS"' "$INSTALL_SH" | head -n1 | cut -d: -f1)
shortcut_guard_line=$(
awk -v stop="$first_setup_check_line" '
NR >= stop { exit }
/\[ "\$TAURI_MODE" != true \]/ {
print NR
exit
}
' "$INSTALL_SH"
)
shortcut_guard_end_line=$(
awk -v start="$shortcut_guard_line" -v shortcut="$shortcut_line" '
NR <= start { next }
NR <= shortcut { next }
/^[[:space:]]*fi[[:space:]]*$/ {
print NR
exit
}
' "$INSTALL_SH"
)
early_tauri_exit_line=$(
awk -v setup="$first_setup_check_line" '
NR >= setup { exit }
/\[ "\$TAURI_MODE" = true \]/ {
in_tauri = 1
depth = 1
next
}
in_tauri && /^[[:space:]]*if[[:space:]].*;[[:space:]]*then[[:space:]]*$/ {
depth++
}
in_tauri && /exit[[:space:]]+0/ {
print NR
exit
}
in_tauri && /^[[:space:]]*fi[[:space:]]*$/ {
depth--
if (depth == 0) {
in_tauri = 0
}
}
' "$INSTALL_SH"
)
if [ -z "$tauri_marker_line" ] || [ -z "$tauri_exit_line" ] || [ -z "$tauri_done_line" ] || [ -z "$first_setup_check_line" ] || [ -z "$setup_exit_line" ] || [ -z "$shortcut_line" ] || [ -z "$shortcut_guard_line" ] || [ -z "$shortcut_guard_end_line" ]; then
echo " FAIL: required install.sh markers not found"
exit 1
fi
if [ -n "$early_tauri_exit_line" ]; then
echo " FAIL: Tauri success exit before setup failure check at line $early_tauri_exit_line"
exit 1
fi
if [ "$shortcut_guard_line" -ge "$shortcut_line" ] || [ "$shortcut_line" -ge "$shortcut_guard_end_line" ]; then
echo " FAIL: shortcuts are not guarded by non-Tauri check"
exit 1
fi
if [ "$shortcut_line" -ge "$first_setup_check_line" ]; then
echo " FAIL: shortcut line $shortcut_line is after setup failure check line $first_setup_check_line"
exit 1
fi
if [ "$first_setup_check_line" -ge "$setup_exit_line" ]; then
echo " FAIL: setup failure check line $first_setup_check_line is after setup exit line $setup_exit_line"
exit 1
fi
if [ "$setup_exit_line" -ge "$tauri_done_line" ]; then
echo " FAIL: setup failure exit line $setup_exit_line is after Tauri DONE line $tauri_done_line"
exit 1
fi
if [ "$setup_exit_line" -ge "$tauri_exit_line" ]; then
echo " FAIL: setup failure check line $first_setup_check_line is after Tauri exit line $tauri_exit_line"
exit 1
fi
echo " PASS: non-Tauri shortcuts run before setup failure exit"
echo " PASS: setup failure exits before Tauri success"
echo " PASS: Tauri skips shortcuts"

View file

@ -1,11 +1,19 @@
# 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 importlib.util
import hashlib
import json
import os
import platform
import secrets
import sqlite3
import subprocess
import sys
import tempfile
import time
import types
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
import typer
@ -13,12 +21,52 @@ import typer
studio_app = typer.Typer(help = "Unsloth Studio commands.")
STUDIO_HOME = Path.home() / ".unsloth" / "studio"
BOOTSTRAP_PASSWORD_FILE = ".bootstrap_password"
DESKTOP_SECRET_FILE = ".desktop_secret"
DEFAULT_ADMIN_USERNAME = "unsloth"
DESKTOP_SECRET_PREFIX = "desktop-"
API_KEY_PBKDF2_SALT_KEY = "api_key_pbkdf2_salt"
DESKTOP_SECRET_HASH_KEY = "desktop_secret_hash"
DESKTOP_SECRET_CREATED_AT_KEY = "desktop_secret_created_at"
PBKDF2_ITERATIONS = 100_000
# __file__ is unsloth_cli/commands/studio.py -- two parents up is the package root
# (either site-packages or the repo root for editable installs).
_PACKAGE_ROOT = Path(__file__).resolve().parent.parent.parent
def _should_hide_windows_subprocesses() -> bool:
"""Hide child console windows only for non-interactive Windows launches."""
if platform.system() != "Windows":
return False
try:
return not sys.stdout.isatty()
except (AttributeError, OSError, ValueError):
return True
def _windows_hidden_subprocess_kwargs() -> dict[str, object]:
"""Return Windows-only Popen kwargs that suppress transient console windows."""
if not _should_hide_windows_subprocesses():
return {}
kwargs: dict[str, object] = {}
create_no_window = getattr(subprocess, "CREATE_NO_WINDOW", 0)
if create_no_window:
kwargs["creationflags"] = create_no_window
startupinfo_factory = getattr(subprocess, "STARTUPINFO", None)
startf_use_showwindow = getattr(subprocess, "STARTF_USESHOWWINDOW", 0)
sw_hide = getattr(subprocess, "SW_HIDE", 0)
if startupinfo_factory is not None and startf_use_showwindow:
startupinfo = startupinfo_factory()
startupinfo.dwFlags |= startf_use_showwindow
startupinfo.wShowWindow = sw_hide
kwargs["startupinfo"] = startupinfo
return kwargs
def _studio_venv_python() -> Optional[Path]:
"""Return the studio venv Python binary, or None if not set up."""
if platform.system() == "Windows":
@ -97,12 +145,228 @@ def _create_api_key_inprocess(name: str) -> str:
``POST /api/auth/api-keys`` on fresh installs. Safe because the
CLI already has filesystem access to ``~/.unsloth/studio``.
"""
from auth.storage import create_api_key, DEFAULT_ADMIN_USERNAME
storage = _load_backend_auth_storage()
raw_key, _row = create_api_key(username = DEFAULT_ADMIN_USERNAME, name = name)
raw_key, _row = storage.create_api_key(
username = storage.DEFAULT_ADMIN_USERNAME,
name = name,
)
return raw_key
def _load_backend_auth_storage():
run_py = _find_run_py()
backend_dir = (
run_py.parent if run_py is not None else _PACKAGE_ROOT / "studio" / "backend"
)
if backend_dir.is_dir() and str(backend_dir) not in sys.path:
sys.path.insert(0, str(backend_dir))
auth_dir = backend_dir / "auth"
storage_py = auth_dir / "storage.py"
loaded = sys.modules.get("auth.storage")
loaded_path = Path(getattr(loaded, "__file__", "")).resolve()
if loaded is not None and loaded_path == storage_py:
return loaded
package = sys.modules.get("auth")
package_paths = [Path(path).resolve() for path in getattr(package, "__path__", [])]
if package is None or auth_dir.resolve() not in package_paths:
package = types.ModuleType("auth")
package.__path__ = [str(auth_dir)]
package.__package__ = "auth"
package.__file__ = str(auth_dir / "__init__.py")
sys.modules["auth"] = package
spec = importlib.util.spec_from_file_location("auth.storage", storage_py)
if spec is None or spec.loader is None:
raise ImportError(f"Could not load backend auth storage from {storage_py}")
storage = importlib.util.module_from_spec(spec)
sys.modules["auth.storage"] = storage
spec.loader.exec_module(storage)
return storage
def _write_auth_secret(path: Path, secret: str) -> None:
path.parent.mkdir(parents = True, exist_ok = True)
fd, tmp_name = tempfile.mkstemp(prefix = f".{path.name}.", dir = path.parent)
tmp_path = Path(tmp_name)
try:
try:
os.chmod(tmp_path, 0o600)
except OSError:
pass
with os.fdopen(fd, "w") as f:
fd = -1
f.write(secret)
os.replace(tmp_path, path)
except Exception:
if fd >= 0:
os.close(fd)
tmp_path.unlink(missing_ok = True)
raise
try:
os.chmod(path, 0o600)
except OSError:
pass
def _connect_auth_db() -> sqlite3.Connection:
auth_dir = STUDIO_HOME / "auth"
auth_dir.mkdir(parents = True, exist_ok = True)
conn = sqlite3.connect(auth_dir / "auth.db")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS auth_user (
id INTEGER PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password_salt TEXT NOT NULL,
password_hash TEXT NOT NULL,
jwt_secret TEXT NOT NULL,
must_change_password INTEGER NOT NULL DEFAULT 0
);
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS refresh_tokens (
id INTEGER PRIMARY KEY,
token_hash TEXT NOT NULL,
username TEXT NOT NULL,
expires_at TEXT NOT NULL,
is_desktop INTEGER NOT NULL DEFAULT 0
);
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
key_prefix TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
name TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
last_used_at TEXT,
expires_at TEXT,
is_active INTEGER NOT NULL DEFAULT 1
);
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS app_secrets (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
"""
)
auth_columns = {row[1] for row in conn.execute("PRAGMA table_info(auth_user)")}
if "must_change_password" not in auth_columns:
conn.execute(
"ALTER TABLE auth_user ADD COLUMN must_change_password INTEGER NOT NULL DEFAULT 0"
)
refresh_columns = {
row[1] for row in conn.execute("PRAGMA table_info(refresh_tokens)")
}
if "is_desktop" not in refresh_columns:
conn.execute(
"ALTER TABLE refresh_tokens ADD COLUMN is_desktop INTEGER NOT NULL DEFAULT 0"
)
conn.commit()
return conn
def _pbkdf2_hex(value: str, salt: bytes) -> str:
return hashlib.pbkdf2_hmac(
"sha256",
value.encode("utf-8"),
salt,
PBKDF2_ITERATIONS,
).hex()
def _hash_password(password: str) -> tuple[str, str]:
salt = secrets.token_hex(16)
pwd_hash = _pbkdf2_hex(password, salt.encode("utf-8"))
return salt, pwd_hash
def _get_or_create_api_key_pbkdf2_salt(conn: sqlite3.Connection) -> bytes:
row = conn.execute(
"SELECT value FROM app_secrets WHERE key = ?",
(API_KEY_PBKDF2_SALT_KEY,),
).fetchone()
if row is None:
salt_hex = secrets.token_hex(32)
conn.execute(
"INSERT OR IGNORE INTO app_secrets (key, value) VALUES (?, ?)",
(API_KEY_PBKDF2_SALT_KEY, salt_hex),
)
row = conn.execute(
"SELECT value FROM app_secrets WHERE key = ?",
(API_KEY_PBKDF2_SALT_KEY,),
).fetchone()
return bytes.fromhex(row[0])
def _ensure_cli_default_admin(conn: sqlite3.Connection) -> None:
row = conn.execute(
"SELECT 1 FROM auth_user WHERE username = ?",
(DEFAULT_ADMIN_USERNAME,),
).fetchone()
if row is not None:
return
bootstrap_password = secrets.token_urlsafe(32)
password_salt, password_hash = _hash_password(bootstrap_password)
conn.execute(
"""
INSERT INTO auth_user (
username,
password_salt,
password_hash,
jwt_secret,
must_change_password
)
VALUES (?, ?, ?, ?, ?)
""",
(
DEFAULT_ADMIN_USERNAME,
password_salt,
password_hash,
secrets.token_urlsafe(64),
1,
),
)
_write_auth_secret(
STUDIO_HOME / "auth" / BOOTSTRAP_PASSWORD_FILE,
bootstrap_password,
)
def _create_desktop_secret_in_cli() -> str:
raw_secret = DESKTOP_SECRET_PREFIX + secrets.token_urlsafe(48)
now = datetime.now(timezone.utc).isoformat()
conn = _connect_auth_db()
try:
_ensure_cli_default_admin(conn)
secret_hash = _pbkdf2_hex(raw_secret, _get_or_create_api_key_pbkdf2_salt(conn))
conn.execute(
"INSERT OR REPLACE INTO app_secrets (key, value) VALUES (?, ?)",
(DESKTOP_SECRET_HASH_KEY, secret_hash),
)
conn.execute(
"INSERT OR REPLACE INTO app_secrets (key, value) VALUES (?, ?)",
(DESKTOP_SECRET_CREATED_AT_KEY, now),
)
conn.commit()
return raw_secret
finally:
conn.close()
def _load_model_via_http(
port: int,
api_key: str,
@ -153,6 +417,11 @@ def studio_default(
host: str = typer.Option("0.0.0.0", "--host", "-H"),
frontend: Optional[Path] = typer.Option(None, "--frontend", "-f"),
silent: bool = typer.Option(False, "--silent", "-q"),
api_only: bool = typer.Option(
False,
"--api-only",
help = "Run API server only, no frontend serving (for Tauri desktop app)",
),
):
"""Launch the Unsloth Studio server."""
if ctx.invoked_subcommand is not None:
@ -180,13 +449,15 @@ def studio_default(
args.extend(["--frontend", str(frontend)])
if silent:
args.append("--silent")
if api_only:
args.append("--api-only")
# On Windows, os.execvp() spawns a child but the parent lingers,
# so Ctrl+C only kills the parent leaving the child orphaned.
# Use subprocess.run() on Windows so the parent waits for the child.
if sys.platform == "win32":
import subprocess as _sp
proc = _sp.Popen(args)
proc = _sp.Popen(args, **_windows_hidden_subprocess_kwargs())
try:
rc = proc.wait()
except KeyboardInterrupt:
@ -217,7 +488,7 @@ def studio_default(
display_host = _resolve_external_ip() if host == "0.0.0.0" else host
typer.echo(f"Starting Unsloth Studio on http://{display_host}:{port}")
run_kwargs = dict(host = host, port = port, silent = silent)
run_kwargs = dict(host = host, port = port, silent = silent, api_only = api_only)
if frontend is not None:
run_kwargs["frontend_path"] = frontend
run_server(**run_kwargs)
@ -489,9 +760,16 @@ def _run_setup_script(*, verbose: bool = False) -> None:
env = {**os.environ, "UNSLOTH_VERBOSE": "1"} if verbose else None
if platform.system() == "Windows":
powershell_args = ["powershell.exe"]
if _should_hide_windows_subprocesses():
powershell_args.extend(
["-NoLogo", "-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden"]
)
powershell_args.extend(["-ExecutionPolicy", "Bypass", "-File", str(script)])
result = subprocess.run(
["powershell", "-ExecutionPolicy", "Bypass", "-File", str(script)],
powershell_args,
env = env,
**_windows_hidden_subprocess_kwargs(),
)
else:
result = subprocess.run(["bash", str(script)], env = env)
@ -547,6 +825,44 @@ def update(
# ── unsloth studio reset-password ────────────────────────────────────
@studio_app.command("desktop-capabilities", hidden = True)
def desktop_capabilities(
json_output: bool = typer.Option(
False,
"--json",
help = "Emit machine-readable JSON.",
),
):
payload = {
"desktop_protocol_version": 1,
"supports_provision_desktop_auth": True,
"supports_api_only": True,
"version": "unknown",
}
try:
from importlib.metadata import version as package_version
payload["version"] = package_version("unsloth")
except Exception:
pass
if json_output:
typer.echo(json.dumps(payload, sort_keys = True))
return
for key, value in payload.items():
typer.echo(f"{key}: {value}")
@studio_app.command("provision-desktop-auth", hidden = True)
def provision_desktop_auth():
"""Create/repair desktop auth state for the local machine."""
auth_dir = STUDIO_HOME / "auth"
secret = _create_desktop_secret_in_cli()
_write_auth_secret(auth_dir / DESKTOP_SECRET_FILE, secret)
typer.echo("Desktop auth ready.")
@studio_app.command("reset-password")
def reset_password():
"""Reset the Studio admin password.
@ -557,13 +873,18 @@ def reset_password():
"""
auth_dir = STUDIO_HOME / "auth"
db_file = auth_dir / "auth.db"
pw_file = auth_dir / ".bootstrap_password"
stale_files = [
auth_dir / BOOTSTRAP_PASSWORD_FILE,
auth_dir / DESKTOP_SECRET_FILE,
]
had_db = db_file.exists()
if not db_file.exists():
db_file.unlink(missing_ok = True)
for path in stale_files:
path.unlink(missing_ok = True)
if not had_db:
typer.echo("No auth database found -- nothing to reset.")
raise typer.Exit(0)
db_file.unlink(missing_ok = True)
pw_file.unlink(missing_ok = True)
typer.echo("Auth database deleted. Restart Unsloth Studio to get a new password.")