Add tauri (#5144)
* 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>
187
.github/workflows/release-desktop.yml
vendored
Normal 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
|
|
@ -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
|
||||
|
|
|
|||
78
install.ps1
|
|
@ -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)
|
||||
|
|
|
|||
69
install.sh
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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/)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
597
studio/backend/tests/test_desktop_auth.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
34
studio/backend/utils/subprocess_compat.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
BIN
studio/frontend/public/studio.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
401
studio/frontend/src/components/tauri/startup-screen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
studio/frontend/src/components/tauri/update-banner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
173
studio/frontend/src/components/tauri/update-screen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
96
studio/frontend/src/components/ui/shimmer-button.tsx
Normal 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"
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -15,3 +15,8 @@ export {
|
|||
resetOnboardingDone,
|
||||
setMustChangePassword,
|
||||
} from "./session";
|
||||
export {
|
||||
clearTauriAuthFailure,
|
||||
getTauriAuthFailure,
|
||||
tauriAutoAuth,
|
||||
} from "./tauri-auto-auth";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
95
studio/frontend/src/features/auth/tauri-auto-auth.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
487
studio/frontend/src/hooks/use-tauri-backend.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
219
studio/frontend/src/hooks/use-tauri-update.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
24
studio/frontend/src/lib/api-base.ts
Normal 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 }
|
||||
31
studio/frontend/src/lib/open-link.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
42
studio/src-tauri/Cargo.toml
Normal 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"]
|
||||
15
studio/src-tauri/Entitlements.plist
Normal 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>
|
||||
3
studio/src-tauri/build.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
24
studio/src-tauri/capabilities/default.json
Normal 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"
|
||||
]
|
||||
}
|
||||
BIN
studio/src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 9 KiB |
BIN
studio/src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
studio/src-tauri/icons/icon.icns
Normal file
BIN
studio/src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
studio/src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
9
studio/src-tauri/linux/postremove.sh
Executable 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
|
||||
476
studio/src-tauri/src/commands.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
388
studio/src-tauri/src/desktop_auth.rs
Normal 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."
|
||||
);
|
||||
}
|
||||
}
|
||||
555
studio/src-tauri/src/install.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
189
studio/src-tauri/src/main.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
831
studio/src-tauri/src/preflight.rs
Normal 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"
|
||||
));
|
||||
}
|
||||
}
|
||||
537
studio/src-tauri/src/process.rs
Normal 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);
|
||||
});
|
||||
}
|
||||
293
studio/src-tauri/src/update.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
72
studio/src-tauri/src/windows_job.rs
Normal 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(())
|
||||
}
|
||||
66
studio/src-tauri/tauri.conf.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
studio/src-tauri/tauri.macos.conf.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"bundle": {
|
||||
"macOS": {
|
||||
"entitlements": "./Entitlements.plist",
|
||||
"dmg": {
|
||||
"appPosition": { "x": 180, "y": 220 },
|
||||
"applicationFolderPosition": { "x": 480, "y": 220 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
studio/src-tauri/tauri.windows.conf.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
studio/src-tauri/windows/branding/nsis-header.bmp
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
studio/src-tauri/windows/branding/nsis-sidebar.bmp
Normal file
|
After Width: | Height: | Size: 151 KiB |
8
studio/src-tauri/windows/hooks.nsh
Normal 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
|
||||
95
tests/sh/test_tauri_install_exit_order.sh
Normal 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"
|
||||
|
|
@ -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.")
|
||||
|
|
|
|||