diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml new file mode 100644 index 0000000000..2d1c6f51f2 --- /dev/null +++ b/.github/workflows/release-desktop.yml @@ -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 }} diff --git a/.gitignore b/.gitignore index 7a24d07c6f..b6786ee655 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/install.ps1 b/install.ps1 index 7f98fc5cb8..44464101f3 100644 --- a/install.ps1 +++ b/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) diff --git a/install.sh b/install.sh index b1663ba0ff..07473e441d 100755 --- a/install.sh +++ b/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" diff --git a/studio/backend/auth/authentication.py b/studio/backend/auth/authentication.py index da59ba9a1a..6ddcbc8e0b 100644 --- a/studio/backend/auth/authentication.py +++ b/studio/backend/auth/authentication.py @@ -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", diff --git a/studio/backend/auth/storage.py b/studio/backend/auth/storage.py index 7d55a2dc59..2b0e359d39 100644 --- a/studio/backend/auth/storage.py +++ b/studio/backend/auth/storage.py @@ -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 # --------------------------------------------------------------------------- diff --git a/studio/backend/core/data_recipe/local_callable_validators.py b/studio/backend/core/data_recipe/local_callable_validators.py index c32b2fccaf..afd10b02d1 100644 --- a/studio/backend/core/data_recipe/local_callable_validators.py +++ b/studio/backend/core/data_recipe/local_callable_validators.py @@ -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) diff --git a/studio/backend/core/inference/audio_codecs.py b/studio/backend/core/inference/audio_codecs.py index bcf3ec2937..895b112e85 100644 --- a/studio/backend/core/inference/audio_codecs.py +++ b/studio/backend/core/inference/audio_codecs.py @@ -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/) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 2e26995309..6ad05ac52d 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -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 diff --git a/studio/backend/core/training/trainer.py b/studio/backend/core/training/trainer.py index 77cbda6b45..c1e2ac4a85 100644 --- a/studio/backend/core/training/trainer.py +++ b/studio/backend/core/training/trainer.py @@ -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"), diff --git a/studio/backend/loggers/config.py b/studio/backend/loggers/config.py index 0d32a64657..4c0d8ade28 100644 --- a/studio/backend/loggers/config.py +++ b/studio/backend/loggers/config.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 diff --git a/studio/backend/main.py b/studio/backend/main.py index d146a8ef12..05adcaa2ea 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -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, } diff --git a/studio/backend/models/auth.py b/studio/backend/models/auth.py index c55e646508..23eb0ac4c0 100644 --- a/studio/backend/models/auth.py +++ b/studio/backend/models/auth.py @@ -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.""" diff --git a/studio/backend/routes/auth.py b/studio/backend/routes/auth.py index 5cd23bd450..3deeb6793b 100644 --- a/studio/backend/routes/auth.py +++ b/studio/backend/routes/auth.py @@ -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), ) diff --git a/studio/backend/routes/data_recipe/jobs.py b/studio/backend/routes/data_recipe/jobs.py index 00546b47a4..606ef1832c 100644 --- a/studio/backend/routes/data_recipe/jobs.py +++ b/studio/backend/routes/data_recipe/jobs.py @@ -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 diff --git a/studio/backend/run.py b/studio/backend/run.py index 9675b9ea4c..7590ef1067 100644 --- a/studio/backend/run.py +++ b/studio/backend/run.py @@ -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) diff --git a/studio/backend/tests/test_desktop_auth.py b/studio/backend/tests/test_desktop_auth.py new file mode 100644 index 0000000000..c8cf1c7081 --- /dev/null +++ b/studio/backend/tests/test_desktop_auth.py @@ -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 diff --git a/studio/backend/utils/hardware/nvidia.py b/studio/backend/utils/hardware/nvidia.py index dc5295c302..274d9beb48 100644 --- a/studio/backend/utils/hardware/nvidia.py +++ b/studio/backend/utils/hardware/nvidia.py @@ -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) diff --git a/studio/backend/utils/models/model_config.py b/studio/backend/utils/models/model_config.py index a2d48cf009..a2b0c90e59 100644 --- a/studio/backend/utils/models/model_config.py +++ b/studio/backend/utils/models/model_config.py @@ -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: diff --git a/studio/backend/utils/subprocess_compat.py b/studio/backend/utils/subprocess_compat.py new file mode 100644 index 0000000000..bedf8cf2e6 --- /dev/null +++ b/studio/backend/utils/subprocess_compat.py @@ -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 diff --git a/studio/backend/utils/transformers_version.py b/studio/backend/utils/transformers_version.py index 36c3a4c22d..f36bdcd6e8 100644 --- a/studio/backend/utils/transformers_version.py +++ b/studio/backend/utils/transformers_version.py @@ -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) diff --git a/studio/frontend/package.json b/studio/frontend/package.json index a2eebd5cb5..c5cb949ccd 100644 --- a/studio/frontend/package.json +++ b/studio/frontend/package.json @@ -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", diff --git a/studio/frontend/public/studio.png b/studio/frontend/public/studio.png new file mode 100644 index 0000000000..4e531499b7 Binary files /dev/null and b/studio/frontend/public/studio.png differ diff --git a/studio/frontend/src/app/auth-guards.ts b/studio/frontend/src/app/auth-guards.ts index 1dcdfcb143..509b8f61af 100644 --- a/studio/frontend/src/app/auth-guards.ts +++ b/studio/frontend/src/app/auth-guards.ts @@ -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 { @@ -16,55 +18,64 @@ async function hasActiveSession(): Promise { return refreshSession(); } -async function checkAuthInitialized(): Promise { +interface AuthStatus { + initialized: boolean; + requires_password_change: boolean; +} + +async function fetchAuthStatus(): Promise { 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 { - 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 { + 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 { + if (isTauri) { + await tauriAutoAuth(); + throw redirect({ to: "/chat" }); + } if (!(await hasActiveSession())) return; throw redirect({ to: getPostAuthRoute() }); } export async function requirePasswordChangeFlow(): Promise { - 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"); } diff --git a/studio/frontend/src/app/provider.tsx b/studio/frontend/src/app/provider.tsx index 68ce3061bd..b75998a169 100644 --- a/studio/frontend/src/app/provider.tsx +++ b/studio/frontend/src/app/provider.tsx @@ -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 { + 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 { + 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 ( + + ); + } + + return ( + + ); +} + +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 <>{children}; + + return ( + + ); +} + export function AppProvider({ children }: AppProviderProps) { return ( - {children} + + {children} + ); diff --git a/studio/frontend/src/app/routes/__root.tsx b/studio/frontend/src/app/routes/__root.tsx index 69aeb11748..19b5763557 100644 --- a/studio/frontend/src/app/routes/__root.tsx +++ b/studio/frontend/src/app/routes/__root.tsx @@ -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" }); diff --git a/studio/frontend/src/components/assistant-ui/markdown-text.tsx b/studio/frontend/src/components/assistant-ui/markdown-text.tsx index 2a0517a44a..7eb4b21ba7 100644 --- a/studio/frontend/src/components/assistant-ui/markdown-text.tsx +++ b/studio/frontend/src/components/assistant-ui/markdown-text.tsx @@ -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">) => ( { + if (href && openLink(href)) { + e.preventDefault(); + } + }} {...props} > {children} diff --git a/studio/frontend/src/components/assistant-ui/sources.tsx b/studio/frontend/src/components/assistant-ui/sources.tsx index da97ff66b5..81c8b0c213 100644 --- a/studio/frontend/src/components/assistant-ui/sources.tsx +++ b/studio/frontend/src/components/assistant-ui/sources.tsx @@ -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({ > { + if (href && openLink(href)) { + e.preventDefault(); + } + onClick?.(e); + }} {...(props as ComponentProps<"a">)} /> diff --git a/studio/frontend/src/components/tauri/startup-screen.tsx b/studio/frontend/src/components/tauri/startup-screen.tsx new file mode 100644 index 0000000000..af5f241416 --- /dev/null +++ b/studio/frontend/src/components/tauri/startup-screen.tsx @@ -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 ( + + ); +} + +function Logo() { + return ( +
+ Unsloth + Unsloth Studio +
+ ); +} + +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 ( + + ); +} + +// --------------------------------------------------------------------------- +// Per-status renderers +// --------------------------------------------------------------------------- + +function CheckingContent() { + return ( +
+
+ +
+
+ +

Checking...

+
+
+ ); +} + +function NotInstalledContent({ onInstall }: { onInstall: () => void }) { + return ( +
+
+ +

+ To install Unsloth, click Get Started. +

+
+
+ + Get Started + +
+
+ ); +} + +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 ( +
+
+ +
+
+ +

Installing...

+

+ Please wait a few mins, then you can start training. +

+ {currentStepIndex >= 0 && ( +

+ Step {stepNum} of {INSTALL_STEPS.length}: {stepLabel} +

+ )} + {progressDetail && ( +

{progressDetail}

+ )} +
+
+ ); +} + +function RepairingContent({ + logs, + progressDetail, +}: { + logs: string[]; + progressDetail: string | null; +}) { + const latest = progressDetail ?? logs.at(-1); + + return ( +
+
+ +
+
+ +

Updating existing Studio install...

+ {latest && ( +

{latest}

+ )} +
+
+ ); +} + +function InstallErrorContent({ + error, + logs, + onRetryInstall, +}: { + error: string | null; + logs: string[]; + onRetryInstall: () => void; +}) { + return ( + <> + +
+

Setup ran into a problem

+ {error && ( +

{error}

+ )} +
+ void navigator.clipboard.writeText(logs.join("\n"))} + > + Copy Logs + + Try Again +
+
+ + ); +} + +function RepairErrorContent({ + error, + logs, + onRetry, +}: { + error: string | null; + logs: string[]; + onRetry: () => void; +}) { + return ( + <> + +
+

Update failed

+ {error && ( +

{error}

+ )} +
+ void navigator.clipboard.writeText(logs.join("\n"))} + > + Copy Logs + + Retry +
+
+ + ); +} + +function NeedsElevationContent({ + elevationPackages, + onApproveElevation, + onRetryInstall, +}: { + elevationPackages: string[]; + onApproveElevation: () => void; + onRetryInstall: () => void; +}) { + return ( + <> + +
+

Permission needed

+

+ The following system packages need to be installed: +

+
+ {elevationPackages.map((pkg) => ( +
{pkg}
+ ))} +
+
+ Cancel + Allow +
+
+ + ); +} + +function StartingContent() { + return ( +
+
+ +
+
+ +

Starting server...

+
+
+ ); +} + +function StoppedContent({ onStartServer }: { onStartServer: () => void }) { + return ( + <> + +
+

Server stopped

+
+ Start Server +
+
+ + ); +} + +function ErrorContent({ + error, + logs, + onRetry, +}: { + error: string | null; + logs: string[]; + onRetry: () => void; +}) { + return ( + <> + +
+

Something went wrong

+ {error && ( +

{error}

+ )} +
+ void navigator.clipboard.writeText(logs.join("\n"))} + > + Copy Logs + + Retry +
+
+ + ); +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +export function StartupScreen({ + status, + logs, + error, + currentStepIndex, + progressDetail, + elevationPackages, + onInstall, + onRetry, + onRetryInstall, + onApproveElevation, + onStartServer, +}: StartupScreenProps) { + function renderContent() { + switch (status) { + case "checking": + return ; + case "not-installed": + return ; + case "installing": + return ; + case "install-error": + return ; + case "repairing": + return ; + case "repair-error": + return ; + case "needs-elevation": + return ( + + ); + case "starting": + return ; + case "running": + return null; + case "stopped": + return ; + case "error": + return ; + } + } + + return ( +
+
+ + + {renderContent()} + + +
+
+ ); +} diff --git a/studio/frontend/src/components/tauri/update-banner.tsx b/studio/frontend/src/components/tauri/update-banner.tsx new file mode 100644 index 0000000000..98652c4db1 --- /dev/null +++ b/studio/frontend/src/components/tauri/update-banner.tsx @@ -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 ( + + {show && info && ( + +
+ {/* Close button */} + + + {/* Header */} +
+ 🦥 +
+

+ New version: v{info.version} +

+

+ {isExternalServer + ? "Run `unsloth studio update` from your terminal" + : "A new app update is available"} +

+
+
+ + {/* Actions */} +
+ + + +
+
+
+ )} +
+ ); +} diff --git a/studio/frontend/src/components/tauri/update-screen.tsx b/studio/frontend/src/components/tauri/update-screen.tsx new file mode 100644 index 0000000000..a8b77ab1fa --- /dev/null +++ b/studio/frontend/src/components/tauri/update-screen.tsx @@ -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 ( + + ); +} + +function Logo() { + return ( +
+ Unsloth + Unsloth Studio +
+ ); +} + +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(null); + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [logs]); + + if (logs.length === 0) return null; + + return ( +
+ {logs.map((line, i) => ( +
+ {line} +
+ ))} +
+ ); +} + +export function UpdateScreen({ + status, + logs, + progress, + error, + onRetry, + onSkipRestart, +}: UpdateScreenProps) { + const isError = status === "error"; + + return ( +
+ + + +
+ {!isError && } +

+ {statusLabel(status)} +

+

+ {statusSubtext(status, progress)} +

+
+ + {/* Download progress bar */} + {status === "downloading" && ( +
+ +
+ )} + + {/* Error display */} + + {isError && error && ( + +

{error}

+
+ )} +
+ + {/* Error actions */} + {isError && ( +
+ + +
+ )} + + {/* Log viewer */} + +
+
+ ); +} diff --git a/studio/frontend/src/components/ui/shimmer-button.tsx b/studio/frontend/src/components/ui/shimmer-button.tsx new file mode 100644 index 0000000000..d675cc0979 --- /dev/null +++ b/studio/frontend/src/components/ui/shimmer-button.tsx @@ -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 ( + + ) + } +) + +ShimmerButton.displayName = "ShimmerButton" diff --git a/studio/frontend/src/config/env.ts b/studio/frontend/src/config/env.ts index 91e17f6bb9..72bb3fa815 100644 --- a/studio/frontend/src/config/env.ts +++ b/studio/frontend/src/config/env.ts @@ -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()((_, get) => ({ - deviceType: "linux", - chatOnly: false, + deviceType: localDeviceType, + chatOnly: localDeviceType === "mac", fetched: false, isChatOnly: () => get().chatOnly, })); @@ -33,16 +46,22 @@ export async function fetchDeviceType(): Promise { 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; diff --git a/studio/frontend/src/features/auth/api.ts b/studio/frontend/src/features/auth/api.ts index 3bb2c9139c..97d815950d 100644 --- a/studio/frontend/src/features/auth/api.ts +++ b/studio/frontend/src/features/auth/api.ts @@ -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 { 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 { window.location.href = target; } +async function retryWithCurrentToken( + input: RequestInfo | URL, + init?: RequestInit, +): Promise { + 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 { + clearAuthTokens(); + const { tauriAutoAuth } = await import("./tauri-auto-auth"); + if (await tauriAutoAuth()) return retryWithCurrentToken(input, init); + return null; +} + export async function refreshSession(): Promise { 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 { + 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 { diff --git a/studio/frontend/src/features/auth/components/auth-form.tsx b/studio/frontend/src/features/auth/components/auth-form.tsx index d9190429bd..090a3081a4 100644 --- a/studio/frontend/src/features/auth/components/auth-form.tsx +++ b/studio/frontend/src/features/auth/components/auth-form.tsx @@ -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 { - 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", diff --git a/studio/frontend/src/features/auth/index.ts b/studio/frontend/src/features/auth/index.ts index af629cfb9a..9cc1599195 100644 --- a/studio/frontend/src/features/auth/index.ts +++ b/studio/frontend/src/features/auth/index.ts @@ -15,3 +15,8 @@ export { resetOnboardingDone, setMustChangePassword, } from "./session"; +export { + clearTauriAuthFailure, + getTauriAuthFailure, + tauriAutoAuth, +} from "./tauri-auto-auth"; diff --git a/studio/frontend/src/features/auth/session.ts b/studio/frontend/src/features/auth/session.ts index 6012174077..49a2722bdf 100644 --- a/studio/frontend/src/features/auth/session.ts +++ b/studio/frontend/src/features/auth/session.ts @@ -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"; diff --git a/studio/frontend/src/features/auth/tauri-auto-auth.ts b/studio/frontend/src/features/auth/tauri-auto-auth.ts new file mode 100644 index 0000000000..d67730d2d6 --- /dev/null +++ b/studio/frontend/src/features/auth/tauri-auto-auth.ts @@ -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 | 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 { + // 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("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 { + if (!isTauri) return Promise.resolve(false); + if (!pending) { + pending = doTauriAutoAuth().finally(() => { pending = null; }); + } + return pending; +} diff --git a/studio/frontend/src/features/settings/tabs/about-tab.tsx b/studio/frontend/src/features/settings/tabs/about-tab.tsx index 417922139c..bb4454aa43 100644 --- a/studio/frontend/src/features/settings/tabs/about-tab.tsx +++ b/studio/frontend/src/features/settings/tabs/about-tab.tsx @@ -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) { diff --git a/studio/frontend/src/hooks/index.ts b/studio/frontend/src/hooks/index.ts index f889dc7445..cfbd8e5115 100644 --- a/studio/frontend/src/hooks/index.ts +++ b/studio/frontend/src/hooks/index.ts @@ -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"; diff --git a/studio/frontend/src/hooks/use-gpu-info.ts b/studio/frontend/src/hooks/use-gpu-info.ts index 3eb81c35e9..64caf06b50 100644 --- a/studio/frontend/src/hooks/use-gpu-info.ts +++ b/studio/frontend/src/hooks/use-gpu-info.ts @@ -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 { 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; diff --git a/studio/frontend/src/hooks/use-tauri-backend.ts b/studio/frontend/src/hooks/use-tauri-backend.ts new file mode 100644 index 0000000000..ed697fe31e --- /dev/null +++ b/studio/frontend/src/hooks/use-tauri-backend.ts @@ -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("checking"); + const statusRef = useRef(status); + const [logs, setLogs] = useState([]); + const [error, setError] = useState(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(null); + const [currentStepIndex, setCurrentStepIndex] = useState(-1); + const [elevationPackages, setElevationPackages] = useState([]); + const [progressDetail, setProgressDetail] = useState(null); + // Track seen step names to deduplicate (Strict Mode, event replay, etc.) + const seenStepsRef = useRef(new Set()); + // True when we attached to a server we didn't spawn (can't stop it) + const [isExternalServer, setIsExternalServer] = useState(false); + const externalPollRef = useRef | null>(null); + const externalPollAbortedRef = useRef(false); + const authFailureRef = useRef(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("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("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("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( + event: string, + handler: Parameters>[1], + ) { + listen(event, handler).then((unlisten) => { + if (disposed) { + unlisten(); + } else { + cleanup.push(unlisten); + } + }); + } + + register("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("install-complete", () => { + setCurrentStepIndex(999); // all steps done + }); + + register("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("install-needs-elevation", (e) => { + elevationResumeRef.current = "install"; + setElevationPackages(e.payload); + setBackendStatus("needs-elevation"); + }); + + register("install-progress-detail", (e) => { + setProgressDetail(e.payload); + }); + + register("install-failed", (e) => { + setBackendError(e.payload, "install-error"); + }); + + register("repair-progress", (e) => { + setLogs((prev) => [...prev.slice(-499), e.payload]); + }); + + register("repair-needs-elevation", (e) => { + elevationResumeRef.current = "repair"; + setElevationPackages(e.payload); + setBackendStatus("needs-elevation"); + }); + + register("repair-complete", () => { + if (statusRef.current !== "repairing") return; + setProgressDetail("Repair complete"); + }); + + register("repair-failed", (e) => { + if (statusRef.current !== "repairing") return; + setBackendError(e.payload, "repair-error"); + }); + + register("server-port", (e) => { + portRef.current = e.payload; + setApiBase(e.payload); + }); + + register("server-crashed", () => { + startingRef.current = false; + setBackendError("Server stopped unexpectedly"); + }); + + register("server-log", (e) => { + setLogs((prev) => [...prev.slice(-499), e.payload]); + }); + + register("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, + }; +} diff --git a/studio/frontend/src/hooks/use-tauri-update.ts b/studio/frontend/src/hooks/use-tauri-update.ts new file mode 100644 index 0000000000..5863ef7aa5 --- /dev/null +++ b/studio/frontend/src/hooks/use-tauri-update.ts @@ -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("idle"); + const [info, setInfo] = useState(null); + const [progress, setProgress] = useState(0); + const [logs, setLogs] = useState([]); + const [dismissed, setDismissed] = useState(false); + const [error, setError] = useState(null); + const updateRef = useRef + > | 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( + "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("update-complete", () => resolve("complete")).then( + (u) => cleanups.push(u), + ); + listen("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 + } + } +} diff --git a/studio/frontend/src/lib/api-base.ts b/studio/frontend/src/lib/api-base.ts new file mode 100644 index 0000000000..664b9e58bd --- /dev/null +++ b/studio/frontend/src/lib/api-base.ts @@ -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 } diff --git a/studio/frontend/src/lib/open-link.ts b/studio/frontend/src/lib/open-link.ts new file mode 100644 index 0000000000..b7e6a0fa6e --- /dev/null +++ b/studio/frontend/src/lib/open-link.ts @@ -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; +} diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py index 1ad1ccc851..23e6b7c0e4 100755 --- a/studio/install_llama_prebuilt.py +++ b/studio/install_llama_prebuilt.py @@ -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() diff --git a/studio/install_python_stack.py b/studio/install_python_stack.py index 2b9fd084d4..b56b737cfa 100644 --- a/studio/install_python_stack.py +++ b/studio/install_python_stack.py @@ -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") diff --git a/studio/setup.ps1 b/studio/setup.ps1 index 218be7e045..24ef3ef1eb 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -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 diff --git a/studio/setup.sh b/studio/setup.sh index c2044b5141..142d253554 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -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 \ diff --git a/studio/src-tauri/Cargo.lock b/studio/src-tauri/Cargo.lock new file mode 100644 index 0000000000..62d520437c --- /dev/null +++ b/studio/src-tauri/Cargo.lock @@ -0,0 +1,6320 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.11.0", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.2.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.0", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.1", + "core-graphics-types", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.29.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "matches", + "phf 0.10.1", + "proc-macro2", + "quote", + "smallvec", + "syn 1.0.109", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.13.1", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.0", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser 0.36.0", + "foldhash 0.2.0", + "html5ever 0.38.0", + "precomputed-hash", + "selectors 0.36.1", + "tendril 0.5.0", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "elevated-command" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54c410eccdcc5b759704fdb6a792afe6b01ab8a062e2c003ff2567e2697a94aa" +dependencies = [ + "anyhow", + "base64 0.21.7", + "libc", + "log", + "winapi", + "windows 0.52.0", +] + +[[package]] +name = "embed-resource" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ec73ddcf6b7f23173d5c3c5a32b5507dc0a734de7730aa14abc5d5e296bb5f" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 0.9.12+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fix-path-env" +version = "0.0.0" +source = "git+https://github.com/tauri-apps/fix-path-env-rs#c4c45d503ea115a839aae718d02f79e7c7f0f673" +dependencies = [ + "home", + "strip-ansi-escapes", + "thiserror 1.0.69", +] + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.11.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.13.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "html5ever" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" +dependencies = [ + "log", + "mac", + "markup5ever 0.14.1", + "match_token", +] + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever 0.38.0", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.11.0", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "kuchikiki" +version = "0.8.8-speedreader" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" +dependencies = [ + "cssparser 0.29.6", + "html5ever 0.29.1", + "indexmap 2.13.0", + "selectors 0.24.0", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +dependencies = [ + "bitflags 2.11.0", + "libc", + "plain", + "redox_syscall 0.7.3", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "markup5ever" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" +dependencies = [ + "log", + "phf 0.11.3", + "phf_codegen 0.11.3", + "string_cache 0.8.9", + "string_cache_codegen 0.5.4", + "tendril 0.4.3", +] + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril 0.5.0", + "web_atoms", +] + +[[package]] +name = "match_token" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png", + "serde", + "thiserror 2.0.18", + "windows-sys 0.60.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.11.0", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nodrop" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.0", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "dunce", + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "openssl" +version = "0.10.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" +dependencies = [ + "phf_shared 0.8.0", +] + +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_macros 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros 0.13.1", + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" +dependencies = [ + "phf_generator 0.8.0", + "phf_shared 0.8.0", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" +dependencies = [ + "phf_shared 0.8.0", + "rand 0.7.3", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand 0.8.5", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.5", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher 1.0.2", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher 1.0.2", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "plist" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +dependencies = [ + "base64 0.22.1", + "indexmap 2.13.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.5+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "process-wrap" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e842efad9119158434d193c6682e2ebee4b44d6ad801d7b349623b3f57cdf55" +dependencies = [ + "indexmap 2.13.0", + "nix", + "tracing", + "windows 0.62.2", +] + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", + "rand_pcg", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.0", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rand_pcg" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" +dependencies = [ + "bitflags 1.3.2", + "cssparser 0.29.6", + "derive_more 0.99.20", + "fxhash", + "log", + "phf 0.8.0", + "phf_codegen 0.8.0", + "precomputed-hash", + "servo_arc 0.2.0", + "smallvec", +] + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.11.0", + "cssparser 0.36.0", + "derive_more 2.1.1", + "log", + "new_debug_unreachable", + "phf 0.13.1", + "phf_codegen 0.13.1", + "precomputed-hash", + "rustc-hash", + "servo_arc 0.4.3", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.13.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "servo_arc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" +dependencies = [ + "nodrop", + "stable_deref_trait", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "simplelog" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16257adbfaef1ee58b1363bdc0664c9b8e1e30aed86049635fb5f147d065a9c0" +dependencies = [ + "log", + "termcolor", + "time", +] + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall 0.5.18", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.13.1", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", +] + +[[package]] +name = "strip-ansi-escapes" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" +dependencies = [ + "vte", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.34.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20" +dependencies = [ + "bitflags 2.11.0", + "block2", + "core-foundation 0.10.1", + "core-graphics", + "crossbeam-channel", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "once_cell", + "parking_lot", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tar" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da77cc00fb9028caf5b5d4650f75e31f1ef3693459dfca7f7e506d1ecef0ba2d" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest 0.13.2", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.18", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows 0.61.3", +] + +[[package]] +name = "tauri-build" +version = "2.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "toml 0.9.12+spec-1.1.0", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a24476afd977c5d5d169f72425868613d82747916dd29e0a357c84c4bd6d29" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.117", + "tauri-utils", + "thiserror 2.0.18", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d39b349a98dadaffebb73f0a40dcd1f23c999211e5a2e744403db384d0c33de7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddde7d51c907b940fb573006cdda9a642d6a7c8153657e88f8a5c3c9290cd4aa" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "toml 0.9.12+spec-1.1.0", + "walkdir", +] + +[[package]] +name = "tauri-plugin-opener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc624469b06f59f5a29f874bbc61a2ed737c0f9c23ef09855a292c389c42e83f" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "url", + "windows 0.61.3", + "zbus", +] + +[[package]] +name = "tauri-plugin-process" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a" +dependencies = [ + "tauri", + "tauri-plugin", +] + +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc61e4822b8f74d68278e09161d3e3fdd1b14b9eb781e24edccaabf10c420e8c" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.18", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + +[[package]] +name = "tauri-plugin-updater" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest 0.13.2", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.18", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip", +] + +[[package]] +name = "tauri-runtime" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2826d79a3297ed08cd6ea7f412644ef58e32969504bc4fbd8d7dbeabc4445ea2" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webview2-com", + "windows 0.61.3", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11ea2e6f801d275fdd890d6c9603736012742a1c33b96d0db788c9cdebf7f9e" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows 0.61.3", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219a1f983a2af3653f75b5747f76733b0da7ff03069c7a41901a5eb3ace4557d" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dunce", + "glob", + "html5ever 0.29.1", + "http", + "infer", + "json-patch", + "kuchikiki", + "log", + "memchr", + "phf 0.11.3", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.18", + "toml 0.9.12+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0" +dependencies = [ + "dunce", + "embed-resource", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "tendril" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +dependencies = [ + "new_debug_unreachable", + "utf-8", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.13.0", + "serde_core", + "serde_spanned 1.0.4", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.0.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.13.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.13.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca1a40644a28bce036923f6a431df0b34236949d111cc07cb6dca830c9ef2e1" +dependencies = [ + "indexmap 2.13.0", + "toml_datetime 1.0.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.0", +] + +[[package]] +name = "toml_parser" +version = "1.0.10+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420" +dependencies = [ + "winnow 1.0.0", +] + +[[package]] +name = "toml_writer" +version = "1.0.7+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17aaa1c6e3dc22b1da4b6bba97d066e354c7945cac2f7852d4e4e7ca7a6b56d" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.11.0", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png", + "serde", + "thiserror 2.0.18", + "windows-sys 0.60.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsloth-studio" +version = "2026.4.7" +dependencies = [ + "dirs", + "elevated-command", + "fix-path-env", + "libc", + "log", + "open", + "process-wrap", + "rand 0.10.0", + "regex", + "reqwest 0.12.28", + "serde", + "serde_json", + "simplelog", + "tauri", + "tauri-build", + "tauri-plugin-opener", + "tauri-plugin-process", + "tauri-plugin-single-instance", + "tauri-plugin-updater", + "tokio", + "windows 0.62.2", + "windows-sys 0.59.0", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "vte" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" +dependencies = [ + "memchr", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.13.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576" +dependencies = [ + "phf 0.13.1", + "phf_codegen 0.13.1", + "string_cache 0.9.0", + "string_cache_codegen 0.6.1", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.18", + "windows 0.61.3", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core 0.52.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections 0.2.0", + "windows-core 0.61.2", + "windows-future 0.2.1", + "windows-link 0.1.3", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.13.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wry" +version = "0.54.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a8135d8676225e5744de000d4dff5a082501bf7db6a1c1495034f8c314edbc" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 0.7.15", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +dependencies = [ + "serde", + "winnow 0.7.15", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.13.0", + "memchr", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 0.7.15", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", + "winnow 0.7.15", +] diff --git a/studio/src-tauri/Cargo.toml b/studio/src-tauri/Cargo.toml new file mode 100644 index 0000000000..ccb07cb9e2 --- /dev/null +++ b/studio/src-tauri/Cargo.toml @@ -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"] diff --git a/studio/src-tauri/Entitlements.plist b/studio/src-tauri/Entitlements.plist new file mode 100644 index 0000000000..10e6df3427 --- /dev/null +++ b/studio/src-tauri/Entitlements.plist @@ -0,0 +1,15 @@ + + + + + + com.apple.security.cs.allow-unsigned-executable-memory + + + com.apple.security.network.client + + + com.apple.security.cs.disable-library-validation + + + diff --git a/studio/src-tauri/build.rs b/studio/src-tauri/build.rs new file mode 100644 index 0000000000..d860e1e6a7 --- /dev/null +++ b/studio/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/studio/src-tauri/capabilities/default.json b/studio/src-tauri/capabilities/default.json new file mode 100644 index 0000000000..7b6033c721 --- /dev/null +++ b/studio/src-tauri/capabilities/default.json @@ -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" + ] +} diff --git a/studio/src-tauri/icons/128x128.png b/studio/src-tauri/icons/128x128.png new file mode 100644 index 0000000000..c1dae04e6d Binary files /dev/null and b/studio/src-tauri/icons/128x128.png differ diff --git a/studio/src-tauri/icons/32x32.png b/studio/src-tauri/icons/32x32.png new file mode 100644 index 0000000000..bd08d7bcbb Binary files /dev/null and b/studio/src-tauri/icons/32x32.png differ diff --git a/studio/src-tauri/icons/icon.icns b/studio/src-tauri/icons/icon.icns new file mode 100644 index 0000000000..e67a5e6474 Binary files /dev/null and b/studio/src-tauri/icons/icon.icns differ diff --git a/studio/src-tauri/icons/icon.ico b/studio/src-tauri/icons/icon.ico new file mode 100644 index 0000000000..acc6eb82b9 Binary files /dev/null and b/studio/src-tauri/icons/icon.ico differ diff --git a/studio/src-tauri/icons/icon.png b/studio/src-tauri/icons/icon.png new file mode 100644 index 0000000000..635900d8a7 Binary files /dev/null and b/studio/src-tauri/icons/icon.png differ diff --git a/studio/src-tauri/linux/postremove.sh b/studio/src-tauri/linux/postremove.sh new file mode 100755 index 0000000000..6314c0f3dd --- /dev/null +++ b/studio/src-tauri/linux/postremove.sh @@ -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 diff --git a/studio/src-tauri/src/commands.rs b/studio/src-tauri/src/commands.rs new file mode 100644 index 0000000000..f2acb92e09 --- /dev/null +++ b/studio/src-tauri/src/commands.rs @@ -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 { + 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 { + 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 { + 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, + 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, + _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; + } + } + } + } +} diff --git a/studio/src-tauri/src/desktop_auth.rs b/studio/src-tauri/src/desktop_auth.rs new file mode 100644 index 0000000000..df09df7a14 --- /dev/null +++ b/studio/src-tauri/src/desktop_auth.rs @@ -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 { + dirs::home_dir().ok_or_else(|| "Could not determine home directory".to_string()) +} + +fn desktop_secret_path() -> Result { + Ok(auth_secret_path(&home_dir()?, ".desktop_secret")) +} + +fn read_secret_if_exists(path: &Path) -> Result, 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 { + 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 { + if preflight.disposition == DesktopPreflightDisposition::AttachedReady { + preflight.port + } else { + None + } +} + +async fn discover_compatible_backend_port() -> Option { + 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, 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::() + .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, 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 { + 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." + ); + } +} diff --git a/studio/src-tauri/src/install.rs b/studio/src-tauri/src/install.rs new file mode 100644 index 0000000000..5760989353 --- /dev/null +++ b/studio/src-tauri/src/install.rs @@ -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>, + pub intentional_stop: bool, + /// Packages needing elevated install, parsed from [TAURI:NEED_SUDO] output. + pub needed_packages: Vec, +} + +impl Default for InstallProcess { + fn default() -> Self { + Self { + child: None, + intentional_stop: false, + needed_packages: Vec::new(), + } + } +} + +pub type InstallState = Arc>; + +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> { + 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, + Option, + ), + 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 = { + 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 = { + // 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, + stderr: Option, +) -> Vec> { + 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 = + 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()); + } +} diff --git a/studio/src-tauri/src/main.rs b/studio/src-tauri/src/main.rs new file mode 100644 index 0000000000..f7d49cce60 --- /dev/null +++ b/studio/src-tauri/src/main.rs @@ -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> = 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> { + 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::(); + let _ = crate::install::stop_install(&install_state); + let update_state = app.state::(); + 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::().inner().clone(); + let backend_state = app.state::().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::() { + let _ = install::stop_install(&install_state); + } + if let Some(update_state) = app.try_state::() { + let _ = update::stop_update(&update_state); + } + if let Some(backend_state) = app.try_state::() { + let shutdown = app + .try_state::() + .expect("ShutdownFlag must be managed"); + let _ = process::stop_backend(&backend_state, &shutdown); + } + } + }); +} diff --git a/studio/src-tauri/src/preflight.rs b/studio/src-tauri/src/preflight.rs new file mode 100644 index 0000000000..d3df06d057 --- /dev/null +++ b/studio/src-tauri/src/preflight.rs @@ -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, + pub port: Option, + pub can_auto_repair: bool, + pub managed_bin: Option, +} + +#[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, + supports_api_only: Option, + supports_provision_desktop_auth: Option, + desktop_auth_stale_reason: Option, +} + +#[derive(Debug)] +struct BackendHealth { + desktop_protocol_version: Option, + supports_desktop_auth: Option, + stale_reason: Option, +} + +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 { + 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::(&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 { + 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::().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 { + 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 = (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" + )); + } +} diff --git a/studio/src-tauri/src/process.rs b/studio/src-tauri/src/process.rs new file mode 100644 index 0000000000..beba8c1208 --- /dev/null +++ b/studio/src-tauri/src/process.rs @@ -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>, + pub port: Option, + pub logs: VecDeque, + 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>; +pub type ShutdownFlag = Arc; + +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, + 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 { + // 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 { + 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 { + // 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 { + [ + "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 = { + 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 = { + // 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( + 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::() { + 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); + }); +} diff --git a/studio/src-tauri/src/update.rs b/studio/src-tauri/src/update.rs new file mode 100644 index 0000000000..477f18ce28 --- /dev/null +++ b/studio/src-tauri/src/update.rs @@ -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>, + pub intentional_stop: bool, +} + +impl Default for UpdateProcess { + fn default() -> Self { + Self { + child: None, + intentional_stop: false, + } + } +} + +pub type UpdateState = Arc>; + +pub fn new_update_state() -> UpdateState { + Arc::new(Mutex::new(UpdateProcess::default())) +} + +// ── Spawn ── + +fn spawn_update( + bin: &std::path::Path, + state: &UpdateState, +) -> Result< + ( + Option, + Option, + ), + 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 = { + 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 = { + 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, + stderr: Option, +) -> Vec> { + 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(()) + } +} diff --git a/studio/src-tauri/src/windows_job.rs b/studio/src-tauri/src/windows_job.rs new file mode 100644 index 0000000000..411449fb83 --- /dev/null +++ b/studio/src-tauri/src/windows_job.rs @@ -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::() 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(()) +} diff --git a/studio/src-tauri/tauri.conf.json b/studio/src-tauri/tauri.conf.json new file mode 100644 index 0000000000..49819f8a25 --- /dev/null +++ b/studio/src-tauri/tauri.conf.json @@ -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" + } + } + } +} diff --git a/studio/src-tauri/tauri.macos.conf.json b/studio/src-tauri/tauri.macos.conf.json new file mode 100644 index 0000000000..202c9fb505 --- /dev/null +++ b/studio/src-tauri/tauri.macos.conf.json @@ -0,0 +1,11 @@ +{ + "bundle": { + "macOS": { + "entitlements": "./Entitlements.plist", + "dmg": { + "appPosition": { "x": 180, "y": 220 }, + "applicationFolderPosition": { "x": 480, "y": 220 } + } + } + } +} diff --git a/studio/src-tauri/tauri.windows.conf.json b/studio/src-tauri/tauri.windows.conf.json new file mode 100644 index 0000000000..a68cf6351f --- /dev/null +++ b/studio/src-tauri/tauri.windows.conf.json @@ -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" + } + } + } +} diff --git a/studio/src-tauri/windows/branding/nsis-header.bmp b/studio/src-tauri/windows/branding/nsis-header.bmp new file mode 100644 index 0000000000..7eacdce7db Binary files /dev/null and b/studio/src-tauri/windows/branding/nsis-header.bmp differ diff --git a/studio/src-tauri/windows/branding/nsis-sidebar.bmp b/studio/src-tauri/windows/branding/nsis-sidebar.bmp new file mode 100644 index 0000000000..c3bd480fc8 Binary files /dev/null and b/studio/src-tauri/windows/branding/nsis-sidebar.bmp differ diff --git a/studio/src-tauri/windows/hooks.nsh b/studio/src-tauri/windows/hooks.nsh new file mode 100644 index 0000000000..64655e8f6e --- /dev/null +++ b/studio/src-tauri/windows/hooks.nsh @@ -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 diff --git a/tests/sh/test_tauri_install_exit_order.sh b/tests/sh/test_tauri_install_exit_order.sh new file mode 100644 index 0000000000..49d7c247c8 --- /dev/null +++ b/tests/sh/test_tauri_install_exit_order.sh @@ -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" diff --git a/unsloth_cli/commands/studio.py b/unsloth_cli/commands/studio.py index a3c0840be1..b74f42674d 100644 --- a/unsloth_cli/commands/studio.py +++ b/unsloth_cli/commands/studio.py @@ -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.")