Harden Tauri release flow (#5341)

* Harden Tauri backend preflight and startup

Require managed Studio root IDs to match before attaching to existing backends, close the concurrent backend-start window, and tighten frontend Tauri detection to Tauri-specific signals.

* Add Tauri backend manageability guards

Gate desktop backend compatibility on explicit manageability fields, add external-conflict handling for unsafe backend states, and protect update/repair paths from mutating active non-owned Studio backends. Track Tauri-owned backends with local owner metadata for verified orphan cleanup only.

* Split Tauri preflight probes into modules

Move preflight types, version checks, managed install probing, and backend probing into focused submodules while preserving behavior and keeping implementation files under the release-readiness size target.

* Use desktop-specific Tauri updater channel

Point the desktop updater at a same-repo desktop-latest manifest and publish that channel from non-draft desktop releases after validating the Tauri-generated latest.json.

* Add Linux desktop update policy

* Add owned backend lifecycle guards

* Adopt verified desktop-owned backends

* Validate desktop backend readiness

* Trim Tauri release hardening code

* Require desktop backend 2026.5.3

* Handle desktop backend edge cases

* Fail stalled desktop backend startup

* Fix desktop update edge cases

* Avoid secret-gating adopted watchdog

* Fix desktop update comparison guards

* Automate desktop release versioning

* Serialize desktop release workflow

* tests: follow preflight.rs split into preflight/{backend,managed,types,version}.rs

PR #5341 splits studio/src-tauri/src/preflight.rs into a directory of
submodules. The cmd.env_remove("UNSLOTH_STUDIO_HOME") + STUDIO_HOME
calls now live in preflight/managed.rs instead of preflight.rs, so
test_tauri_preflight_scrubs_studio_home_env counted zero matches in
the old single-file location and failed with "assert 0 >= 2".

Read whichever shape is on disk: preflight.rs at the old path plus
every *.rs under preflight/ (current PR has 2 occurrences in
preflight/managed.rs). The guard intent is unchanged: at least 2
env_remove calls covering run_cli_probe and probe_cli_capability,
plus the single commands.rs scrub in check_install_status. Verified
locally: pytest tests/test_studio_install_workspace_guard.py::test_tauri_preflight_scrubs_studio_home_env passes.

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

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

* Avoid browser Tauri hostname detection

* Restore shutdown flag after failed stop

---------

Co-authored-by: Daniel Han <danielhanchen@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Wasim Yousef Said 2026-05-13 05:30:20 +02:00 committed by GitHub
commit 0a54d001ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 4877 additions and 1066 deletions

View file

@ -3,15 +3,295 @@ name: Release Desktop App
on:
workflow_dispatch:
inputs:
studio_version:
description: 'Studio version tag to release (for example, v0.1.39-beta)'
type: string
required: true
pypi_version:
description: 'Exact PyPI unsloth version just published/stamped (for example, 2026.5.3); leave blank to use MIN_DESKTOP_BACKEND_VERSION'
type: string
required: false
draft:
description: 'Create as draft release'
description: 'Create as draft release; draft runs do not advance desktop-latest updater channel'
type: boolean
default: true
permissions:
contents: write
concurrency:
group: release-desktop-${{ github.repository }}
cancel-in-progress: false
jobs:
prepare-version:
name: Prepare release versions
runs-on: ubuntu-latest
outputs:
studio_version: ${{ steps.prepare.outputs.studio_version }}
app_version: ${{ steps.prepare.outputs.app_version }}
desktop_release_tag: ${{ steps.prepare.outputs.desktop_release_tag }}
prerelease: ${{ steps.prepare.outputs.prerelease }}
pypi_version: ${{ steps.prepare.outputs.pypi_version }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
- name: Validate release versions
id: prepare
shell: bash
env:
INPUT_STUDIO_VERSION: ${{ inputs.studio_version }}
INPUT_PYPI_VERSION: ${{ inputs.pypi_version }}
run: |
python3 <<'PY'
import os
import pathlib
import re
import sys
studio_version = os.environ['INPUT_STUDIO_VERSION'].strip()
if not studio_version:
sys.exit('studio_version is required, for example v0.1.39-beta')
if re.fullmatch(r'v?20\d{2}\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?', studio_version):
sys.exit(f'studio_version must be a Studio SemVer tag, not a date-style backend version: {studio_version}')
semver_tag = re.compile(
r'^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)'
r'(?:-[0-9A-Za-z.][0-9A-Za-z.-]*)?$'
)
if not semver_tag.fullmatch(studio_version):
sys.exit(f'studio_version must be a SemVer tag with leading v, for example v0.1.39-beta: {studio_version}')
app_version = studio_version.removeprefix('v')
desktop_release_tag = f'desktop-v{app_version}'
prerelease = 'true' if '-' in app_version.split('+', 1)[0] else 'false'
def parse_backend_version(version):
match = re.fullmatch(
r'(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)'
r'(?:([a-zA-Z]|\.dev|dev|\.rc|rc|\.post|post)(\d*))?'
r'(?:[-+]([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?',
version,
)
if not match:
return None
major, minor, patch, suffix_name, suffix_number, suffix_text = match.groups()
if suffix_name:
normalized = suffix_name.lower().lstrip('.')
order = {'dev': 0, 'a': 1, 'b': 2, 'rc': 3, 'post': 5}.get(normalized)
if order is None:
return None
number = int(suffix_number or '0')
elif suffix_text:
order = 3 if version[version.find(suffix_text) - 1] == '-' else 4
number = 0
else:
order = 4
number = 0
return (int(major), int(minor), int(patch), order, number)
preflight = pathlib.Path('studio/src-tauri/src/preflight/version.rs').read_text()
match = re.search(r'MIN_DESKTOP_BACKEND_VERSION:\s*&str\s*=\s*"([^"]+)"', preflight)
if not match:
sys.exit('Could not read MIN_DESKTOP_BACKEND_VERSION')
min_backend_version = match.group(1)
input_pypi_version = os.environ.get('INPUT_PYPI_VERSION', '').strip()
parsed_min_backend = parse_backend_version(min_backend_version)
if parsed_min_backend is None:
sys.exit(f'MIN_DESKTOP_BACKEND_VERSION is not a supported backend package version: {min_backend_version}')
pypi_version = input_pypi_version or min_backend_version
parsed_pypi = parse_backend_version(pypi_version)
if parsed_pypi is None:
sys.exit(f'pypi_version is not a supported backend package version: {pypi_version}')
if parsed_pypi < parsed_min_backend:
sys.exit(
f'pypi_version {pypi_version} is lower than desktop minimum '
f'MIN_DESKTOP_BACKEND_VERSION {min_backend_version}'
)
if input_pypi_version:
print(
'Using exact PyPI unsloth version from pypi_version input: '
f'{pypi_version} (desktop minimum: {min_backend_version})'
)
else:
print(
'Using exact PyPI unsloth version from MIN_DESKTOP_BACKEND_VERSION: '
f'{pypi_version}'
)
with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as output:
print(f'studio_version={studio_version}', file=output)
print(f'app_version={app_version}', file=output)
print(f'desktop_release_tag={desktop_release_tag}', file=output)
print(f'prerelease={prerelease}', file=output)
print(f'pypi_version={pypi_version}', file=output)
PY
- name: Verify PyPI package and Studio stamp
shell: bash
env:
STUDIO_VERSION: ${{ steps.prepare.outputs.studio_version }}
PYPI_VERSION: ${{ steps.prepare.outputs.pypi_version }}
run: |
set -euo pipefail
python3 <<'PY'
import json
import os
import pathlib
import sys
import time
import urllib.error
import urllib.request
pypi_version = os.environ['PYPI_VERSION']
dist_dir = pathlib.Path(os.environ['RUNNER_TEMP'], 'pypi-unsloth-dist')
dist_dir.mkdir(parents=True, exist_ok=True)
metadata_url = f'https://pypi.org/pypi/unsloth/{pypi_version}/json'
last_error = None
for attempt in range(1, 6):
try:
with urllib.request.urlopen(metadata_url, timeout=30) as response:
metadata = json.load(response)
break
except Exception as exc:
last_error = exc
if attempt < 5:
time.sleep(10 * attempt)
else:
sys.exit(f'Publish unsloth=={pypi_version} to PyPI before the desktop release ({last_error})')
files = metadata.get('urls') or []
if not files:
sys.exit(f'PyPI returned no distribution files for unsloth=={pypi_version}')
for file_info in files:
filename = file_info.get('filename')
url = file_info.get('url')
if not filename or '/' in filename or not url:
sys.exit(f'Unexpected PyPI file entry for unsloth=={pypi_version}: {file_info!r}')
target = dist_dir / filename
for attempt in range(1, 4):
try:
with urllib.request.urlopen(url, timeout=60) as response:
target.write_bytes(response.read())
break
except Exception as exc:
last_error = exc
if attempt < 3:
time.sleep(5 * attempt)
else:
sys.exit(f'Could not download {filename} from PyPI ({last_error})')
PY
if [ -f scripts/stamp_studio_release.py ]; then
mapfile -t dists < <(find "$RUNNER_TEMP/pypi-unsloth-dist" -type f \( -name '*.whl' -o -name '*.tar.gz' \) | sort)
if [ "${#dists[@]}" -eq 0 ]; then
echo "No PyPI wheel/sdist artifacts downloaded for unsloth==$PYPI_VERSION" >&2
exit 1
fi
python3 scripts/stamp_studio_release.py --verify-dist "$RUNNER_TEMP/pypi-unsloth-dist" --expected "$STUDIO_VERSION"
else
echo "scripts/stamp_studio_release.py not found; release-desktop requires #5308 to verify the PyPI Studio stamp." >&2
exit 1
fi
- name: Guard public updater channel version
if: ${{ !inputs.draft }}
shell: bash
env:
GH_REPO: ${{ github.repository }}
GH_TOKEN: ${{ github.token }}
APP_VERSION: ${{ steps.prepare.outputs.app_version }}
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/desktop-current"
if ! gh release download desktop-latest --pattern latest.json --dir "$RUNNER_TEMP/desktop-current" --clobber 2>/dev/null; then
echo "No existing desktop-latest latest.json found; allowing first channel publish."
exit 0
fi
python3 <<'PY'
import json
import os
import pathlib
import re
import sys
def parse(value: str):
value = value.removeprefix('v')
match = re.fullmatch(
r'(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)'
r'(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?'
r'(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?',
value,
)
if not match:
sys.exit(f'desktop-latest latest.json has invalid version: {value}')
major, minor, patch, prerelease = match.groups()
return (int(major), int(minor), int(patch), prerelease)
def numeric_tail(identifier: str) -> tuple[str, int] | None:
match = re.fullmatch(r'([A-Za-z-]+)(\d+)', identifier)
if not match:
return None
return (match.group(1).lower(), int(match.group(2)))
def compare_identifier(left: str, right: str) -> int:
left_num = left.isdigit()
right_num = right.isdigit()
if left_num and right_num:
return (int(left) > int(right)) - (int(left) < int(right))
if left_num:
return -1
if right_num:
return 1
left_tail = numeric_tail(left)
right_tail = numeric_tail(right)
if left_tail and right_tail and left_tail[0] == right_tail[0]:
return (left_tail[1] > right_tail[1]) - (left_tail[1] < right_tail[1])
return (left > right) - (left < right)
def compare_prerelease(left: str | None, right: str | None) -> int:
if left == right:
return 0
if left is None:
return 1
if right is None:
return -1
left_parts = left.split('.')
right_parts = right.split('.')
for left_part, right_part in zip(left_parts, right_parts):
order = compare_identifier(left_part, right_part)
if order:
return order
return (len(left_parts) > len(right_parts)) - (len(left_parts) < len(right_parts))
def compare(left: str, right: str) -> int:
left_major, left_minor, left_patch, left_pre = parse(left)
right_major, right_minor, right_patch, right_pre = parse(right)
left_core = (left_major, left_minor, left_patch)
right_core = (right_major, right_minor, right_patch)
if left_core != right_core:
return (left_core > right_core) - (left_core < right_core)
return compare_prerelease(left_pre, right_pre)
current_path = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-current', 'latest.json')
current = json.loads(current_path.read_text()).get('version')
next_version = os.environ['APP_VERSION']
if not isinstance(current, str):
sys.exit('desktop-latest latest.json has missing version')
if compare(next_version, current) < 0:
sys.exit(
f'Refusing to publish {next_version}; desktop-latest currently points at newer version {current}.'
)
PY
build:
strategy:
fail-fast: false
@ -32,11 +312,15 @@ jobs:
label: Windows (x64)
name: Build ${{ matrix.label }}
needs: prepare-version
runs-on: ${{ matrix.platform }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
APP_VERSION: ${{ needs.prepare-version.outputs.app_version }}
STUDIO_VERSION: ${{ needs.prepare-version.outputs.studio_version }}
DESKTOP_RELEASE_TAG: ${{ needs.prepare-version.outputs.desktop_release_tag }}
DESKTOP_PRERELEASE: ${{ needs.prepare-version.outputs.prerelease }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
@ -67,36 +351,144 @@ jobs:
exit 1
fi
- name: Install frontend dependencies
working-directory: studio/frontend
run: npm install
- name: Verify backend package is published
- name: Verify desktop updater and Linux package config
shell: bash
run: |
node <<'JS'
const { readFileSync } = require('node:fs');
(async () => {
const cargo = readFileSync('studio/src-tauri/Cargo.toml', 'utf8');
const match = cargo.match(/^version\s*=\s*"([^"]+)"/m);
if (!match) throw new Error('Could not read desktop app version');
const expected = 'https://github.com/unslothai/unsloth/releases/download/desktop-latest/latest.json';
const config = JSON.parse(readFileSync('studio/src-tauri/tauri.conf.json', 'utf8'));
const endpoints = config.plugins?.updater?.endpoints;
if (!Array.isArray(endpoints) || endpoints.length !== 1) {
throw new Error('Expected exactly one desktop updater endpoint');
}
if (endpoints[0] !== expected) {
throw new Error('Desktop updater endpoint must be ' + expected + ', got ' + endpoints[0]);
}
if (endpoints.some((endpoint) => endpoint.includes('/releases/latest/'))) {
throw new Error('Desktop updater endpoint must not use repo-wide /releases/latest/');
}
const appVersion = match[1];
const response = await fetch(`https://pypi.org/pypi/unsloth/${appVersion}/json`);
if (!response.ok) {
const message = 'Publish unsloth=={app_version} to PyPI before the desktop release';
throw new Error(`${message.replace('{app_version}', appVersion)} (HTTP ${response.status})`);
const targets = config.bundle?.targets;
if (Array.isArray(targets) && targets.some((target) => String(target).toLowerCase() === 'rpm')) {
throw new Error('Desktop release must not target RPM packages');
}
if (config.bundle?.linux?.rpm) {
throw new Error('bundle.linux.rpm must not be configured');
}
const workflow = readFileSync('.github/workflows/release-desktop.yml', 'utf8');
const lines = workflow.split(/\r?\n/);
const releaseBodies = [];
for (let i = 0; i < lines.length; i += 1) {
const match = lines[i].match(/^(\s*)releaseBody:\s*\|\s*$/);
if (!match) continue;
const baseIndent = match[1].length;
const bodyLines = [];
i += 1;
for (; i < lines.length; i += 1) {
const line = lines[i];
if (line.trim() === '') {
bodyLines.push('');
continue;
}
const indent = line.match(/^\s*/)[0].length;
if (indent <= baseIndent) {
i -= 1;
break;
}
bodyLines.push(line.slice(baseIndent + 2));
}
})();
releaseBodies.push(bodyLines.join('\n'));
}
if (releaseBodies.length === 0) {
throw new Error('Expected at least one desktop release body');
}
for (const body of releaseBodies) {
if (/\brpm\b|\.rpm/i.test(body)) {
throw new Error('Desktop release body must not advertise RPM packages');
}
}
JS
- 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: Patch desktop app version
shell: bash
working-directory: studio/src-tauri
run: |
set -euo pipefail
if command -v python3 >/dev/null 2>&1; then
PYTHON=python3
else
PYTHON=python
fi
"$PYTHON" <<'PY'
import os
import pathlib
import re
import sys
app_version = os.environ['APP_VERSION']
if not app_version:
sys.exit('APP_VERSION is required')
cargo_toml = pathlib.Path('Cargo.toml')
lines = cargo_toml.read_text().splitlines(keepends=True)
in_package = False
patched = False
for index, line in enumerate(lines):
stripped = line.strip()
if stripped == '[package]':
in_package = True
continue
if stripped.startswith('[') and stripped.endswith(']'):
in_package = False
if in_package and re.fullmatch(r'version\s*=\s*"[^"]+"\s*', stripped):
lines[index] = f'version = "{app_version}"\n'
patched = True
break
if not patched:
sys.exit('Could not patch [package] version in Cargo.toml')
cargo_toml.write_text(''.join(lines))
cargo_lock = pathlib.Path('Cargo.lock')
lock_text = cargo_lock.read_text()
lock_text, count = re.subn(
r'(?m)(^\[\[package\]\]\nname = "unsloth-studio"\nversion = ")[^"]+(")',
lambda match: f'{match.group(1)}{app_version}{match.group(2)}',
lock_text,
)
if count != 1:
sys.exit(f'Could not patch unsloth-studio version in Cargo.lock (matches={count})')
cargo_lock.write_text(lock_text)
PY
cargo metadata --locked --no-deps --format-version 1 > "$RUNNER_TEMP/cargo-metadata.json"
"$PYTHON" <<'PY'
import json
import os
import pathlib
import sys
app_version = os.environ['APP_VERSION']
metadata = json.loads(pathlib.Path(os.environ['RUNNER_TEMP'], 'cargo-metadata.json').read_text())
versions = [package['version'] for package in metadata.get('packages', []) if package.get('name') == 'unsloth-studio']
if versions != [app_version]:
sys.exit(f'cargo metadata unsloth-studio version mismatch: expected {app_version}, got {versions}')
PY
git diff -- Cargo.toml Cargo.lock
- name: Rust cache
uses: swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae
with:
@ -146,8 +538,8 @@ jobs:
with:
projectPath: studio
tauriScript: npx --prefix . tauri
tagName: desktop-v__VERSION__
releaseName: 'Unsloth Studio (Desktop) v__VERSION__'
tagName: ${{ needs.prepare-version.outputs.desktop_release_tag }}
releaseName: 'Unsloth Studio (Desktop) ${{ needs.prepare-version.outputs.studio_version }}'
releaseBody: |
Desktop app for Unsloth Studio.
@ -159,7 +551,7 @@ jobs:
> Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
> First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually.
releaseDraft: ${{ inputs.draft }}
prerelease: false
prerelease: ${{ needs.prepare-version.outputs.prerelease }}
args: -v ${{ matrix.args }}
# ── macOS: build + sign + notarize + upload ──
@ -177,8 +569,8 @@ jobs:
with:
projectPath: studio
tauriScript: npx --prefix . tauri
tagName: desktop-v__VERSION__
releaseName: 'Unsloth Studio (Desktop) v__VERSION__'
tagName: ${{ needs.prepare-version.outputs.desktop_release_tag }}
releaseName: 'Unsloth Studio (Desktop) ${{ needs.prepare-version.outputs.studio_version }}'
releaseBody: |
Desktop app for Unsloth Studio.
@ -190,7 +582,7 @@ jobs:
> Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
> First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually.
releaseDraft: ${{ inputs.draft }}
prerelease: false
prerelease: ${{ needs.prepare-version.outputs.prerelease }}
args: -v ${{ matrix.args }}
# ── Windows: build + sign + upload ──
@ -209,8 +601,8 @@ jobs:
with:
projectPath: studio
tauriScript: npx --prefix . tauri
tagName: desktop-v__VERSION__
releaseName: 'Unsloth Studio (Desktop) v__VERSION__'
tagName: ${{ needs.prepare-version.outputs.desktop_release_tag }}
releaseName: 'Unsloth Studio (Desktop) ${{ needs.prepare-version.outputs.studio_version }}'
releaseBody: |
Desktop app for Unsloth Studio.
@ -222,5 +614,254 @@ jobs:
> Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
> First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually.
releaseDraft: ${{ inputs.draft }}
prerelease: false
prerelease: ${{ needs.prepare-version.outputs.prerelease }}
args: -v ${{ matrix.args }}
# Release process note: only non-draft workflow runs advance the public
# desktop-latest updater channel. Draft builds are for private review; if a
# draft is manually published later, this channel intentionally remains
# unchanged until a narrow manual channel-publish flow is added or a public
# desktop release is created by running this workflow with draft=false.
publish-updater-channel:
name: Publish desktop updater channel
needs: [prepare-version, build]
if: ${{ !inputs.draft }}
runs-on: ubuntu-latest
permissions:
contents: write
env:
GH_REPO: ${{ github.repository }}
APP_VERSION: ${{ needs.prepare-version.outputs.app_version }}
STUDIO_VERSION: ${{ needs.prepare-version.outputs.studio_version }}
DESKTOP_RELEASE_TAG: ${{ needs.prepare-version.outputs.desktop_release_tag }}
DESKTOP_PRERELEASE: ${{ needs.prepare-version.outputs.prerelease }}
steps:
- name: Download versioned updater metadata
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/desktop-updater"
gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${DESKTOP_RELEASE_TAG}" > "$RUNNER_TEMP/source-release.json"
python3 <<'PY'
import json
import os
import pathlib
import sys
source = json.loads(pathlib.Path(os.environ['RUNNER_TEMP'], 'source-release.json').read_text())
expected_tag = os.environ['DESKTOP_RELEASE_TAG']
if source.get('tag_name') != expected_tag:
sys.exit(f'Expected source release {expected_tag}, got {source.get("tag_name")}')
if source.get('draft'):
sys.exit(f'Source desktop release {expected_tag} is draft; refusing to publish public updater channel')
PY
gh release download "$DESKTOP_RELEASE_TAG" --pattern latest.json --dir "$RUNNER_TEMP/desktop-updater" --clobber
test -s "$RUNNER_TEMP/desktop-updater/latest.json"
- name: Validate versioned updater metadata
shell: bash
run: |
python3 <<'PY'
import json
import os
import pathlib
import re
import sys
app_version = os.environ['APP_VERSION']
release_tag = os.environ['DESKTOP_RELEASE_TAG']
latest_path = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-updater', 'latest.json')
data = json.loads(latest_path.read_text())
if not isinstance(data, dict):
sys.exit('latest.json must be a JSON object')
version = data.get('version')
if not isinstance(version, str) or not version:
sys.exit('latest.json missing version')
if not re.fullmatch(r'v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?', version):
sys.exit(f'latest.json version is not SemVer-like: {version}')
if version.removeprefix('v') != app_version:
sys.exit(f'latest.json version {version} does not match desktop app version {app_version}')
platforms = data.get('platforms')
if not isinstance(platforms, dict) or not platforms:
sys.exit('latest.json missing platforms')
required_families = {
'darwin-aarch64': False,
'linux-x86_64': False,
'windows-x86_64': False,
}
expected_prefix = f'https://github.com/unslothai/unsloth/releases/download/{release_tag}/'
forbidden_fragments = ('/releases/latest/', '/releases/download/desktop-latest/')
for platform, entry in platforms.items():
if not isinstance(entry, dict):
sys.exit(f'Platform {platform} must be an object')
url = entry.get('url')
signature = entry.get('signature')
if not isinstance(url, str) or not url.strip():
sys.exit(f'Platform {platform} missing url')
if not isinstance(signature, str) or not signature.strip():
sys.exit(f'Platform {platform} missing signature')
if any(fragment in url for fragment in forbidden_fragments):
sys.exit(f'Platform {platform} points at a moving updater channel: {url}')
if not url.startswith(expected_prefix):
sys.exit(f'Platform {platform} URL must point at {release_tag}: {url}')
for family in required_families:
if platform == family or platform.startswith(family + '-'):
required_families[family] = True
missing = [family for family, found in required_families.items() if not found]
if missing:
sys.exit('latest.json missing required platform families: ' + ', '.join(missing))
PY
- name: Ensure desktop updater channel release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
channel_json="$RUNNER_TEMP/desktop-latest-release.json"
if ! gh api "repos/${GITHUB_REPOSITORY}/releases/tags/desktop-latest" > "$channel_json" 2>/dev/null; then
gh release create desktop-latest \
--title "Unsloth Studio Desktop updater channel" \
--notes "Machine-managed desktop updater channel; latest.json is replaced by release-desktop.yml." \
--prerelease \
--latest=false \
--target "$GITHUB_SHA"
gh api "repos/${GITHUB_REPOSITORY}/releases/tags/desktop-latest" > "$channel_json"
fi
python3 <<'PY'
import json
import os
import pathlib
import sys
channel = json.loads(pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-latest-release.json').read_text())
if channel.get('draft'):
sys.exit('desktop-latest release is draft; refusing to publish updater channel')
if channel.get('immutable'):
sys.exit('desktop-latest release is immutable; cannot replace latest.json')
if not channel.get('prerelease'):
sys.exit('desktop-latest release must be a prerelease so it cannot compete with repo-wide latest')
PY
- name: Prevent updater channel downgrade
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/desktop-current"
if ! gh release download desktop-latest --pattern latest.json --dir "$RUNNER_TEMP/desktop-current" --clobber 2>/dev/null; then
echo "No existing desktop-latest latest.json found; allowing first channel publish."
exit 0
fi
python3 <<'PY'
import json
import os
import pathlib
import re
import sys
def parse(value: str):
value = value.removeprefix('v')
match = re.fullmatch(
r'(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)'
r'(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?'
r'(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?',
value,
)
if not match:
sys.exit(f'desktop-latest latest.json has invalid version: {value}')
major, minor, patch, prerelease = match.groups()
return (int(major), int(minor), int(patch), prerelease)
def numeric_tail(identifier: str) -> tuple[str, int] | None:
match = re.fullmatch(r'([A-Za-z-]+)(\d+)', identifier)
if not match:
return None
return (match.group(1).lower(), int(match.group(2)))
def compare_identifier(left: str, right: str) -> int:
left_num = left.isdigit()
right_num = right.isdigit()
if left_num and right_num:
return (int(left) > int(right)) - (int(left) < int(right))
if left_num:
return -1
if right_num:
return 1
left_tail = numeric_tail(left)
right_tail = numeric_tail(right)
if left_tail and right_tail and left_tail[0] == right_tail[0]:
return (left_tail[1] > right_tail[1]) - (left_tail[1] < right_tail[1])
return (left > right) - (left < right)
def compare_prerelease(left: str | None, right: str | None) -> int:
if left == right:
return 0
if left is None:
return 1
if right is None:
return -1
left_parts = left.split('.')
right_parts = right.split('.')
for left_part, right_part in zip(left_parts, right_parts):
order = compare_identifier(left_part, right_part)
if order:
return order
return (len(left_parts) > len(right_parts)) - (len(left_parts) < len(right_parts))
def compare(left: str, right: str) -> int:
left_major, left_minor, left_patch, left_pre = parse(left)
right_major, right_minor, right_patch, right_pre = parse(right)
left_core = (left_major, left_minor, left_patch)
right_core = (right_major, right_minor, right_patch)
if left_core != right_core:
return (left_core > right_core) - (left_core < right_core)
return compare_prerelease(left_pre, right_pre)
current_path = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-current', 'latest.json')
next_path = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-updater', 'latest.json')
current = json.loads(current_path.read_text()).get('version')
next_version = json.loads(next_path.read_text()).get('version')
if not isinstance(current, str) or not isinstance(next_version, str):
sys.exit('Could not compare desktop-latest channel versions')
if compare(next_version, current) < 0:
sys.exit(
f'Refusing to move desktop-latest from {current} to older version {next_version}.'
)
PY
- name: Publish desktop updater channel metadata
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
gh release upload desktop-latest "$RUNNER_TEMP/desktop-updater/latest.json" --clobber
gh api "repos/${GITHUB_REPOSITORY}/releases/tags/desktop-latest" > "$RUNNER_TEMP/desktop-latest-release.json"
python3 <<'PY'
import json
import os
import pathlib
import sys
channel = json.loads(pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-latest-release.json').read_text())
assets = [asset for asset in channel.get('assets', []) if asset.get('name') == 'latest.json']
if len(assets) != 1:
sys.exit(f'Expected exactly one desktop-latest latest.json asset, found {len(assets)}')
expected_url = f'https://github.com/{os.environ["GITHUB_REPOSITORY"]}/releases/download/desktop-latest/latest.json'
actual_url = assets[0].get('browser_download_url')
if actual_url != expected_url:
sys.exit(f'desktop-latest latest.json URL mismatch: expected {expected_url}, got {actual_url}')
PY

View file

@ -42,6 +42,7 @@ if _STUDIO_ROOT_RESOLVED != _LEGACY_STUDIO_ROOT:
if not os.environ.get("UNSLOTH_LLAMA_CPP_PATH"):
os.environ["UNSLOTH_LLAMA_CPP_PATH"] = str(_STUDIO_ROOT_RESOLVED / "llama.cpp")
import hashlib
import mimetypes
import re as _re
import shutil
@ -163,6 +164,24 @@ UNSLOTH_VERSION = get_unsloth_version()
STUDIO_VERSION = get_studio_version()
def _load_desktop_owner() -> dict[str, str] | None:
token = os.environ.pop("UNSLOTH_STUDIO_DESKTOP_OWNER_TOKEN", "")
kind = os.environ.pop("UNSLOTH_STUDIO_DESKTOP_OWNER_KIND", "")
if kind != "tauri" or not token:
return None
return {
"kind": "tauri",
"token_sha256": hashlib.sha256(token.encode("utf-8")).hexdigest(),
}
_DESKTOP_OWNER = _load_desktop_owner()
def _desktop_owner() -> dict[str, str] | None:
return _DESKTOP_OWNER
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup: detect hardware, seed default admin if needed. Shutdown: clean up compiled cache."""
@ -306,12 +325,15 @@ async def health_check():
"device_type": device_type,
"chat_only": _hw_module.CHAT_ONLY,
"desktop_protocol_version": 1,
"desktop_manageability_version": 1,
"supports_desktop_auth": True,
"supports_desktop_backend_ownership": True,
# why: launchers compare against an install-time hash so a sibling
# Studio on the same port is rejected; hex digest avoids leaking the
# raw install path on -H 0.0.0.0.
"studio_root_id": _studio_root_id(),
"native_path_leases_supported": native_path_leases_supported(),
**({"desktop_owner": owner} if (owner := _desktop_owner()) else {}),
}

View file

@ -307,7 +307,6 @@ def run_server(
import asyncio
from threading import Thread, Event
import time
import uvicorn
from main import app, setup_frontend
@ -336,10 +335,6 @@ def run_server(
print("=" * 50)
print("")
# 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):
@ -349,11 +344,21 @@ def run_server(
if not silent:
print(f"[WARNING] Frontend not found at {frontend_path}")
ready_event = Event()
startup_failed = Event()
startup_errors = []
class _ReadyServer(uvicorn.Server):
async def startup(self, *args, **kwargs):
await super().startup(*args, **kwargs)
if getattr(self, "started", False) and not self.should_exit:
ready_event.set()
# Create the uvicorn server and expose it for signal handlers
config = uvicorn.Config(
app, host = host, port = port, log_level = "info", access_log = False
)
_server = uvicorn.Server(config)
_server = _ReadyServer(config)
_shutdown_event = Event()
# Expose the actual bound port so request-handling code can build
@ -365,21 +370,8 @@ def run_server(
app.state.server_port = port if port and port > 0 else None
app.state.llama_parallel_slots = llama_parallel_slots
# Run server in a daemon thread
def _run():
asyncio.run(_server.serve())
thread = Thread(target = _run, daemon = True)
thread.start()
time.sleep(3)
_write_pid_file()
import atexit
atexit.register(_remove_pid_file)
# Expose a shutdown callable via app.state so the /api/shutdown endpoint
# can trigger graceful shutdown without circular imports.
# Expose a shutdown callable via app.state before the server can accept
# requests so /api/shutdown is available as soon as readiness is published.
def _trigger_shutdown():
_graceful_shutdown(_server)
if _shutdown_event is not None:
@ -387,6 +379,47 @@ def run_server(
app.state.trigger_shutdown = _trigger_shutdown
# Run server in a daemon thread
def _run():
try:
asyncio.run(_server.serve())
except BaseException as exc:
startup_errors.append(exc)
startup_failed.set()
finally:
if not ready_event.is_set():
startup_failed.set()
thread = Thread(target = _run, daemon = True)
thread.start()
# Wait until uvicorn has completed lifespan startup and bound sockets, or
# until the server exits/fails before startup. This intentionally has no
# correctness deadline: a slow but live startup should remain in progress.
try:
while not ready_event.is_set():
if startup_failed.is_set() or not thread.is_alive():
if startup_errors:
raise RuntimeError(
"Uvicorn server failed before startup completed"
) from startup_errors[0]
raise RuntimeError("Uvicorn server exited before startup completed")
ready_event.wait(timeout = 0.1)
except KeyboardInterrupt:
_graceful_shutdown(_server)
_shutdown_event.set()
raise
_write_pid_file()
import atexit
atexit.register(_remove_pid_file)
# Output port for Tauri to parse when in api-only mode. Emit only after
# uvicorn sockets are bound and FastAPI lifespan/startup has completed.
if api_only:
print(f"TAURI_PORT={port}", flush = True)
if not silent:
display_host = _resolve_external_ip() if host == "0.0.0.0" else host
print_studio_access_banner(

View file

@ -23,10 +23,6 @@ interface AppProviderProps {
children: ReactNode;
}
// ---------------------------------------------------------------------------
// Tauri window helpers (only imported in Tauri mode)
// ---------------------------------------------------------------------------
type TauriWindowMode = "setup" | "app";
type WindowLayoutGuard = () => boolean;
@ -53,19 +49,15 @@ async function applyAppWindowLayout(isCurrent: WindowLayoutGuard): Promise<void>
let finalH = 600;
if (monitor) {
// 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
finalW = Math.max(900, Math.round(screenW * 0.75));
const targetH = Math.max(600, Math.round(finalW / 1.618));
// Don't exceed screen height
finalH = Math.min(targetH, Math.round(screenH * 0.85));
}
// Apply constraints and finalize without animating through intermediate sizes
if (!isCurrent()) return;
await win.setSize(new LogicalSize(finalW, finalH));
if (!isCurrent()) return;
@ -108,10 +100,6 @@ function getTauriWindowMode(
}
}
// ---------------------------------------------------------------------------
// TauriWrapper
// ---------------------------------------------------------------------------
function TauriUpdateLayer({ isExternalServer }: { isExternalServer: boolean }) {
const update = useTauriUpdate(isExternalServer);
const isUpdating =
@ -141,6 +129,8 @@ function TauriUpdateLayer({ isExternalServer }: { isExternalServer: boolean }) {
dismissed={update.dismissed}
lastFailure={update.lastFailure}
isExternalServer={isExternalServer}
updatePolicyMode={update.updatePolicyMode}
manualReleaseUrl={update.manualReleaseUrl}
onInstall={update.installUpdate}
onDismiss={update.dismiss}
onCopyDiagnostics={update.copyDiagnostics}
@ -184,8 +174,7 @@ function TauriWrapper({ children }: { children: ReactNode }) {
};
}, []);
// Keep the Tauri window hidden during preflight, then show it centered in setup
// mode or apply the final app layout in one instant step.
// Keep the Tauri window hidden until setup or app layout is ready.
useEffect(() => {
if (!isTauri) return;

View file

@ -2,7 +2,12 @@
// 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 { RetainedUpdateFailure, UpdateInfo, UpdateStatus } from "@/hooks/use-tauri-update";
import type {
DesktopUpdatePolicyMode,
RetainedUpdateFailure,
UpdateInfo,
UpdateStatus,
} from "@/hooks/use-tauri-update";
import type { CopySupportDiagnosticsResult } from "@/lib/tauri-diagnostics";
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";
@ -13,6 +18,8 @@ interface UpdateBannerProps {
dismissed: boolean;
lastFailure: RetainedUpdateFailure | null;
isExternalServer?: boolean;
updatePolicyMode: DesktopUpdatePolicyMode;
manualReleaseUrl: string | null;
onInstall: () => void;
onDismiss: () => void;
onCopyDiagnostics: () => Promise<CopySupportDiagnosticsResult>;
@ -26,6 +33,8 @@ export function UpdateBanner({
dismissed,
lastFailure,
isExternalServer = false,
updatePolicyMode,
manualReleaseUrl,
onInstall,
onDismiss,
onCopyDiagnostics,
@ -36,6 +45,10 @@ export function UpdateBanner({
const showFailure = Boolean(lastFailure) && !dismissed;
const showAvailable = status === "available" && !dismissed && !showFailure;
const show = showFailure || (showAvailable && Boolean(info));
const isManualLinuxPackage = updatePolicyMode === "manual_linux_package";
const installDisabled = isManualLinuxPackage
? manualReleaseUrl === null
: isExternalServer;
async function handleCopyDiagnostics() {
setCopying(true);
@ -67,18 +80,16 @@ export function UpdateBanner({
className="fixed top-4 right-4 z-[9999] w-[380px]"
>
<div className="corner-squircle relative overflow-hidden border border-border/60 bg-background/95 px-5 py-4 shadow-lg backdrop-blur-md">
{/* Close button */}
<button
type="button"
onClick={onDismiss}
className="absolute top-3 right-3 flex size-6 items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-muted hover:text-foreground"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11 3L3 11M3 3l8 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
{/* Header */}
<div className="flex items-center gap-2">
<span className="text-lg">🦥</span>
<div>
@ -88,37 +99,39 @@ export function UpdateBanner({
<p className="text-xs text-muted-foreground">
{showFailure
? "Backend recovered. Diagnostics are still available."
: isExternalServer
? "Run `unsloth studio update` from your terminal"
: "A new app update is available"}
: isManualLinuxPackage
? "Open the GitHub release page to install the Linux package"
: isExternalServer
? "Run `unsloth studio update` from your terminal"
: "A new app update is available"}
</p>
</div>
</div>
{/* Retained failure */}
{showFailure && lastFailure && (
<p className="mt-3 line-clamp-2 text-xs text-destructive">
{lastFailure.error}
</p>
)}
{/* Actions */}
<div className="mt-3 flex items-center gap-2">
{showFailure ? (
<>
<Button size="sm" variant="outline" className="corner-squircle" onClick={() => void handleCopyDiagnostics()}>
<Button size="sm" variant="outline" className="corner-squircle" onClick={() => {
handleCopyDiagnostics().catch(console.error);
}}>
{copying ? "Copying..." : "Copy Diagnostics"}
</Button>
<Button size="sm" className="corner-squircle" onClick={onInstall} disabled={isExternalServer}>
Retry Update
<Button size="sm" className="corner-squircle" onClick={onInstall} disabled={installDisabled}>
{isManualLinuxPackage ? "Open Release Page" : "Retry Update"}
</Button>
</>
) : (
<>
<Button size="sm" className="corner-squircle" onClick={onInstall} disabled={isExternalServer}>
Update Now
<Button size="sm" className="corner-squircle" onClick={onInstall} disabled={installDisabled}>
{isManualLinuxPackage ? "Open Release Page" : "Update Now"}
</Button>
<Button size="sm" variant="outline" className="corner-squircle" disabled>
<Button size="sm" variant="outline" className="corner-squircle" disabled={true}>
Release Notes
</Button>
</>
@ -132,7 +145,7 @@ export function UpdateBanner({
)}
{manualReport && (
<textarea
readOnly
readOnly={true}
value={manualReport}
onFocus={(event) => event.currentTarget.select()}
className="mt-2 h-28 w-full resize-none rounded-lg border border-border/50 bg-muted/30 p-2 font-mono text-[10px] text-muted-foreground"

View file

@ -29,7 +29,10 @@ type DesktopPreflightDisposition =
| "not_installed"
| "managed_ready"
| "managed_stale"
| "attached_ready";
| "owned_ready"
| "owned_stale"
| "attached_ready"
| "external_conflict";
interface DesktopPreflightResult {
disposition: DesktopPreflightDisposition;
@ -39,28 +42,45 @@ interface DesktopPreflightResult {
managed_bin: string | null;
}
const MANAGED_STARTUP_TIMEOUT_MS = 5 * 60_000;
const MANAGED_STARTUP_POLL_MS = 500;
type TauriInvoke = typeof import("@tauri-apps/api/core").invoke;
type ManagedStartupResult =
| { status: "ready"; port: number }
| { status: "aborted" }
| { status: "missing-port" }
| { status: "unhealthy" };
| { status: "aborted" };
function wait(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function externalConflictMessage(preflight: DesktopPreflightResult) {
if (preflight.reason === "desktop_owned_backend_active") {
return preflight.port
? `A desktop-owned Studio server for this install is already running on port ${preflight.port}. Quit the other desktop app instance, then try again.`
: "A desktop-owned Studio server for this install is already running. Quit the other desktop app instance, then try again.";
}
if (preflight.reason === "desktop_owned_backend_starting") {
return "The desktop-owned Studio backend is still starting. Wait a moment, then try again.";
}
if (preflight.reason?.startsWith("desktop_owned_backend_unmanageable:")) {
return preflight.port
? `A desktop-owned Studio backend on port ${preflight.port} cannot be safely controlled by this desktop app. Stop that backend, then reopen Studio.`
: "A desktop-owned Studio backend cannot be safely controlled by this desktop app. Stop that backend, then reopen Studio.";
}
return preflight.port
? `A Studio server for this install is already running from a terminal on port ${preflight.port}. Stop that server, or run \`unsloth studio update\` from that terminal before using the desktop app.`
: "A Studio server for this install is already running from a terminal. Stop that server, or run `unsloth studio update` from that terminal before using the desktop app.";
}
async function waitForManagedServerReady(
invoke: TauriInvoke,
getPort: () => number | null,
shouldContinue: () => boolean,
): Promise<ManagedStartupResult> {
const deadline = Date.now() + MANAGED_STARTUP_TIMEOUT_MS;
while (Date.now() < deadline) {
while (true) {
if (!shouldContinue()) {
return { status: "aborted" };
}
@ -81,10 +101,6 @@ async function waitForManagedServerReady(
await wait(MANAGED_STARTUP_POLL_MS);
}
return getPort() === null
? { status: "missing-port" }
: { status: "unhealthy" };
}
export function useTauriBackend() {
@ -109,6 +125,7 @@ export function useTauriBackend() {
const externalPollAbortedRef = useRef(false);
const authFailureRef = useRef<string | null>(getTauriAuthFailure());
const elevationResumeRef = useRef<"install" | "repair" | null>(null);
const [tauriEventsReady, setTauriEventsReady] = useState(!isTauri);
function setBackendStatus(nextStatus: BackendStatus) {
if (authFailureRef.current) return;
@ -205,12 +222,24 @@ export function useTauriBackend() {
startExternalServerPoll(preflight.port);
return;
}
case "owned_ready":
if (!preflight.port) {
setBackendError("Desktop preflight found an owned backend without a port.");
return;
}
setApiBase(preflight.port);
portRef.current = preflight.port;
setIsExternalServer(false);
stopExternalServerPoll();
setRunningStatus();
return;
case "managed_ready":
setIsExternalServer(false);
stopExternalServerPoll();
setBackendStatus("starting");
await startManagedServer();
return;
case "owned_stale":
case "managed_stale":
setIsExternalServer(false);
stopExternalServerPoll();
@ -218,10 +247,17 @@ export function useTauriBackend() {
await startRepair();
} else {
setBackendError(
"Managed Studio install is too old. Run `unsloth studio update`.",
preflight.disposition === "owned_stale"
? "Desktop-owned Studio backend is too old for this desktop app. Run `unsloth studio update`, then restart Studio."
: "Managed Studio install is too old. Run `unsloth studio update`.",
);
}
return;
case "external_conflict":
setIsExternalServer(false);
stopExternalServerPoll();
setBackendError(externalConflictMessage(preflight));
return;
case "not_installed":
setBackendStatus("not-installed");
return;
@ -264,11 +300,6 @@ export function useTauriBackend() {
return;
}
const message =
startupResult.status === "missing-port"
? "Managed server started without reporting a port. Check the logs for details."
: "Server started but is not responding. Check the logs for details.";
setBackendError(message);
} catch (e) {
const msg = String(e);
if (msg.includes("already running")) {
@ -441,9 +472,9 @@ export function useTauriBackend() {
});
}, [currentStepIndex, elevationPackages, error, logs, progressDetail]);
// Initial check on mount (guarded against Strict Mode double-mount)
// Initial check on mount after Tauri event listeners are registered.
useEffect(() => {
if (mountedRef.current) return;
if (!tauriEventsReady || mountedRef.current) return;
mountedRef.current = true;
if (!isTauri) {
@ -451,7 +482,7 @@ export function useTauriBackend() {
return;
}
checkInstallAndStart();
}, []);
}, [tauriEventsReady]);
// Listen for Tauri events
useEffect(() => {
@ -460,17 +491,20 @@ export function useTauriBackend() {
let disposed = false;
import("@tauri-apps/api/event").then(({ listen }) => {
const registrations: Promise<void>[] = [];
function register<T>(
event: string,
handler: Parameters<typeof listen<T>>[1],
) {
listen<T>(event, handler).then((unlisten) => {
if (disposed) {
unlisten();
} else {
cleanup.push(unlisten);
}
});
registrations.push(
listen<T>(event, handler).then((unlisten) => {
if (disposed) {
unlisten();
} else {
cleanup.push(unlisten);
}
}),
);
}
register<string>("install-progress", (e) => {
@ -549,6 +583,16 @@ export function useTauriBackend() {
retry();
}
});
Promise.all(registrations)
.then(() => {
if (!disposed) setTauriEventsReady(true);
})
.catch((error) => {
if (!disposed) setBackendError(String(error));
});
}).catch((error) => {
if (!disposed) setBackendError(String(error));
});
const onAuthFailed = (event: Event) => {

View file

@ -31,6 +31,21 @@ export type UpdatePhase =
| "shell_install"
| "recovered_after_shell_failure";
export type DesktopUpdatePolicyMode = "in_app" | "manual_linux_package";
interface DesktopUpdatePolicy {
mode: DesktopUpdatePolicyMode;
releasePageBaseUrl: string;
releaseTagPrefix: string;
}
interface ManualUpdateInfo {
version: string;
currentVersion: string;
body?: string;
date?: string;
}
export interface RetainedUpdateFailure {
error: string;
phase: UpdatePhase;
@ -38,6 +53,29 @@ export interface RetainedUpdateFailure {
logs: string[];
}
const DEFAULT_UPDATE_POLICY: DesktopUpdatePolicy = {
mode: "in_app",
releasePageBaseUrl: "https://github.com/unslothai/unsloth/releases/tag/",
releaseTagPrefix: "desktop-v",
};
const UPDATE_VERSION_RE = /^v?\d+\.\d+\.\d+(?:(?:[-+][0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)|(?:\.(?:post|dev|rc)\d*)|(?:(?:post|dev|rc|a|b)\d*))?$/;
function normalizeUpdateVersion(version: string): string | null {
const trimmed = version.trim();
if (!UPDATE_VERSION_RE.test(trimmed)) return null;
return trimmed.startsWith("v") ? trimmed.slice(1) : trimmed;
}
function manualReleasePageUrl(
policy: DesktopUpdatePolicy,
version: string,
): string | null {
const normalized = normalizeUpdateVersion(version);
if (!normalized) return null;
return `${policy.releasePageBaseUrl}${policy.releaseTagPrefix}${normalized}`;
}
export function useTauriUpdate(isExternalServer = false) {
const [status, setStatus] = useState<UpdateStatus>("idle");
const [info, setInfo] = useState<UpdateInfo | null>(null);
@ -50,6 +88,7 @@ export function useTauriUpdate(isExternalServer = false) {
const [dismissed, setDismissed] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastFailure, setLastFailure] = useState<RetainedUpdateFailure | null>(null);
const [updatePolicy, setUpdatePolicy] = useState<DesktopUpdatePolicy>(DEFAULT_UPDATE_POLICY);
const updateRef = useRef<Awaited<
ReturnType<typeof import("@tauri-apps/plugin-updater").check>
> | null>(null);
@ -93,12 +132,63 @@ export function useTauriUpdate(isExternalServer = false) {
return failure;
}
async function resolveUpdatePolicy(): Promise<DesktopUpdatePolicy> {
if (!isTauri) return DEFAULT_UPDATE_POLICY;
try {
const { invoke } = await import("@tauri-apps/api/core");
const policy = await invoke<DesktopUpdatePolicy>("desktop_update_policy");
setUpdatePolicy(policy);
return policy;
} catch (e) {
console.warn("Desktop update policy check failed:", e);
const failSafePolicy: DesktopUpdatePolicy = {
...DEFAULT_UPDATE_POLICY,
mode: "manual_linux_package",
};
setUpdatePolicy(failSafePolicy);
return failSafePolicy;
}
}
async function checkManualUpdateFallback(policy: DesktopUpdatePolicy) {
if (policy.mode !== "manual_linux_package") return false;
try {
const { invoke } = await import("@tauri-apps/api/core");
const manualUpdate = await invoke<ManualUpdateInfo | null>(
"check_desktop_manual_update",
);
if (!manualUpdate) return false;
updateRef.current = null;
setInfo({
version: manualUpdate.version,
currentVersion: manualUpdate.currentVersion,
body: manualUpdate.body,
date: manualUpdate.date,
});
setStatus("available");
return true;
} catch (e) {
console.error("Manual update metadata check failed:", e);
return false;
}
}
async function openManualUpdatePage(policy: DesktopUpdatePolicy, version: string) {
const url = manualReleasePageUrl(policy, version);
if (!url) {
throw new Error(`Invalid desktop update version: ${version}`);
}
const { openUrl } = await import("@tauri-apps/plugin-opener");
await openUrl(url);
}
useEffect(() => {
if (!isTauri || checkedRef.current) return;
checkedRef.current = true;
async function checkForUpdate() {
setStatus("checking");
const policy = await resolveUpdatePolicy();
try {
const { check } = await import("@tauri-apps/plugin-updater");
const update = await check();
@ -111,12 +201,14 @@ export function useTauriUpdate(isExternalServer = false) {
date: update.date,
});
setStatus("available");
} else {
} else if (!(await checkManualUpdateFallback(policy))) {
setStatus("idle");
}
} catch (e) {
console.error("Update check failed:", e);
setStatus("idle");
if (!(await checkManualUpdateFallback(policy))) {
setStatus("idle");
}
}
}
@ -125,14 +217,31 @@ export function useTauriUpdate(isExternalServer = false) {
}, []);
async function installUpdate() {
const update = updateRef.current;
if (!update || updatingRef.current) return;
if (updatingRef.current) return;
updatingRef.current = true;
const cleanups: (() => void)[] = [];
try {
// ── Step 1: Backend update ──
const policy = await resolveUpdatePolicy();
if (policy.mode === "manual_linux_package") {
const version = info?.version ?? updateRef.current?.version;
if (!version) return;
try {
await openManualUpdatePage(policy, version);
setDismissed(true);
setError(null);
} catch (manualError) {
const msg = String(manualError);
setError(msg);
toast.error("Could not open release page", { description: msg });
}
return;
}
const update = updateRef.current;
if (!update) return;
setUpdatePhase("backend");
setStatus("updating-backend");
replaceLogs([]);
@ -144,7 +253,6 @@ export function useTauriUpdate(isExternalServer = false) {
const { listen } = await import("@tauri-apps/api/event");
const { invoke } = await import("@tauri-apps/api/core");
// Listen for backend update progress
const unlistenProgress = await listen<string>(
"update-progress",
(e) => {
@ -153,7 +261,6 @@ export function useTauriUpdate(isExternalServer = false) {
);
cleanups.push(unlistenProgress);
// Wait for complete or failed
const backendResult = await new Promise<"complete" | string>(
(resolve) => {
listen<void>("update-complete", () => resolve("complete")).then(
@ -171,12 +278,9 @@ export function useTauriUpdate(isExternalServer = false) {
retainFailure(backendResult, "backend");
setError(backendResult);
setStatus("error");
updatingRef.current = false;
cleanup(cleanups);
return;
}
// ── Step 2: Shell update ──
setUpdatePhase("shell_download");
setStatus("downloading");
setUpdateProgress(0);
@ -201,7 +305,6 @@ export function useTauriUpdate(isExternalServer = false) {
}
});
// ── Step 3: Relaunch ──
const { relaunch } = await import("@tauri-apps/plugin-process");
await relaunch();
} catch (e) {
@ -281,6 +384,11 @@ export function useTauriUpdate(isExternalServer = false) {
});
}
const manualReleaseUrl =
updatePolicy.mode === "manual_linux_package" && info
? manualReleasePageUrl(updatePolicy, info.version)
: null;
return {
status,
info,
@ -291,6 +399,8 @@ export function useTauriUpdate(isExternalServer = false) {
phase,
lastFailure,
isExternalServer,
updatePolicyMode: updatePolicy.mode,
manualReleaseUrl,
installUpdate,
retryUpdate,
skipAndRestart,

View file

@ -1,7 +1,17 @@
// Central API base URL for Tauri vs browser mode
let apiBase = ''
const isTauri = typeof window !== 'undefined' && '__TAURI__' in window
function detectTauri(): boolean {
if (typeof window === 'undefined') {
return false
}
return (
'__TAURI__' in window ||
'__TAURI_INTERNALS__' in window ||
window.location.protocol === 'tauri:'
)
}
const isTauri = detectTauri()
const isViteDev = import.meta.env.DEV
if (isTauri && !isViteDev) {

View file

@ -1,5 +1,5 @@
#!/bin/sh
# Post-removal script for Unsloth Studio (deb/rpm)
# Post-removal script for the Unsloth Studio Debian package
# Runs non-interactively; never deletes user data or touches other users' homes.
case "${1:-}" in

View file

@ -22,12 +22,78 @@ fn should_emit_repair_failed(msg: &str) -> bool {
!msg.contains("NEEDS_ELEVATION")
}
fn external_conflict_message(conflict: &crate::preflight::ExternalBackendConflict) -> String {
if conflict.reason == "desktop_owned_backend_active" {
return format!(
"A desktop-owned Studio server for this install is already running on port {}. Quit the other desktop app instance, then try again.",
conflict.port
);
}
format!(
"A Studio server for this install is already running from a terminal on port {}. Stop that server, or run `unsloth studio update` from that terminal before using desktop repair/update.",
conflict.port
)
}
fn owned_backend_port(state: &tauri::State<'_, BackendState>) -> Result<Option<u16>, String> {
state
.lock()
.map(|proc| proc.owned_backend_port())
.map_err(|e| e.to_string())
}
fn has_owned_backend(state: &tauri::State<'_, BackendState>) -> Result<bool, String> {
state
.lock()
.map(|proc| proc.has_owned_backend())
.map_err(|e| e.to_string())
}
async fn block_external_conflict(ignored_ports: &[u16]) -> Result<(), String> {
if let Some(conflict) =
crate::preflight::mutation_blocking_backend_ignoring(ignored_ports).await
{
return Err(external_conflict_message(&conflict));
}
Ok(())
}
#[tauri::command]
pub async fn desktop_preflight(
app: AppHandle,
state: tauri::State<'_, BackendState>,
shutdown: tauri::State<'_, ShutdownFlag>,
diagnostics: tauri::State<'_, DiagnosticsState>,
) -> Result<crate::preflight::DesktopPreflightResult, String> {
let result = crate::preflight::desktop_preflight_result().await;
let (result, adopted_watchdog_generation) =
crate::preflight::desktop_preflight_result_with_state(state.inner()).await?;
diagnostics::record_preflight(&diagnostics, &result);
if let Some((generation, newly_adopted)) = adopted_watchdog_generation {
if newly_adopted {
if let Some(port) = result.port {
diagnostics::begin_adopted_backend_session(&diagnostics, port, generation);
}
}
if process::claim_adopted_watchdog_if_current(state.inner(), generation) {
shutdown.store(false, std::sync::atomic::Ordering::SeqCst);
let watchdog_state = state.inner().clone();
let watchdog_shutdown = shutdown.inner().clone();
let watchdog_diagnostics = diagnostics.inner().clone();
tokio::spawn(async move {
health_watchdog(
app,
watchdog_state,
watchdog_shutdown,
watchdog_diagnostics,
generation,
true,
)
.await;
});
}
}
Ok(result)
}
@ -121,6 +187,7 @@ pub async fn start_server(
watchdog_shutdown,
diagnostics_state,
generation,
false,
)
.await;
});
@ -151,6 +218,7 @@ pub async fn start_managed_server(
watchdog_shutdown,
diagnostics_state,
generation,
false,
)
.await;
});
@ -158,17 +226,22 @@ pub async fn start_managed_server(
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.
/// Stop the current desktop-owned backend if this app can safely control it.
#[tauri::command]
pub fn stop_server(
pub async fn stop_server(
state: tauri::State<'_, BackendState>,
shutdown: tauri::State<'_, ShutdownFlag>,
diagnostics: tauri::State<'_, DiagnosticsState>,
) -> Result<(), String> {
info!("stop_server command called");
process::stop_backend(&state, &shutdown, Some(diagnostics.inner()))
let state = state.inner().clone();
let shutdown = shutdown.inner().clone();
let diagnostics = diagnostics.inner().clone();
tauri::async_runtime::spawn_blocking(move || {
process::stop_backend(&state, &shutdown, Some(&diagnostics))
})
.await
.map_err(|e| format!("stop backend task failed: {e}"))?
}
/// Check if a healthy Unsloth backend is running on the given port.
@ -207,6 +280,35 @@ async fn check_health_inner(port: u16) -> Result<bool, reqwest::Error> {
Ok(healthy && correct_service)
}
async fn check_watchdog_health(
state: &BackendState,
generation: u64,
port: u16,
has_adopted: bool,
) -> bool {
if !has_adopted {
return check_health_inner(port).await.unwrap_or(false);
}
let snapshot = match process::owned_backend_snapshot(state) {
Ok(Some(snapshot))
if snapshot.is_adopted
&& snapshot.generation == generation
&& snapshot.port == Some(port) =>
{
snapshot
}
_ => return false,
};
let Some(owner) = snapshot.owner else {
return false;
};
matches!(
crate::desktop_backend_owner::probe_owned_backend_state(owner, Some(port), false).await,
crate::desktop_backend_owner::OwnedBackendProbe::Verified(_)
)
}
/// Return buffered server logs.
#[tauri::command]
pub fn get_server_logs(state: tauri::State<'_, BackendState>) -> Vec<String> {
@ -308,12 +410,6 @@ pub async fn start_backend_update(
) -> 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())
@ -322,7 +418,6 @@ pub async fn start_backend_update(
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())
@ -331,17 +426,20 @@ pub async fn start_backend_update(
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)
{
let owned_port = owned_backend_port(&backend_state)?;
let has_owned = has_owned_backend(&backend_state)?;
if has_owned {
if let Some(port) = owned_port {
block_external_conflict(&[port]).await?;
}
info!("Stopping backend before update...");
process::stop_backend(&backend_state, &shutdown, Some(diagnostics.inner()))?;
process::stop_backend_for_mutation(&backend_state, &shutdown, Some(diagnostics.inner()))?;
block_external_conflict(&[]).await?;
} else {
block_external_conflict(&[]).await?;
}
// Run update in a blocking thread
let state = update_state.inner().clone();
let diagnostics_state = diagnostics.inner().clone();
tokio::task::spawn_blocking(move || update::run_backend_update(app, state, diagnostics_state))
@ -378,20 +476,24 @@ pub async fn start_managed_repair(
}
let diagnostics_state = diagnostics.inner().clone();
let owned_port = owned_backend_port(&backend_state)?;
let has_owned = has_owned_backend(&backend_state)?;
if has_owned {
if let Some(port) = owned_port {
block_external_conflict(&[port]).await?;
}
info!("Stopping backend before repair...");
process::stop_backend_for_mutation(&backend_state, &shutdown, Some(&diagnostics_state))?;
block_external_conflict(&[]).await?;
} else {
block_external_conflict(&[]).await?;
}
let repair_group_id = install::take_pending_repair_group_for_resume(&install_state)
.unwrap_or_else(|| diagnostics::begin_repair_group(&diagnostics_state));
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, Some(&diagnostics_state))?;
}
let _ = app.emit("repair-progress", "Updating existing Studio install...");
let update_app = app.clone();
let update_state = update_state.inner().clone();
@ -446,6 +548,17 @@ pub async fn start_managed_repair(
}
}
if let Err(msg) = block_external_conflict(&[]).await {
diagnostics::finish_repair_group(
&diagnostics_state,
&repair_group_id,
"failed",
Some(msg.clone()),
);
let _ = app.emit("repair-failed", &msg);
return Err(msg);
}
let install_app = app.clone();
let install_state = install_state.inner().clone();
let install_diagnostics = diagnostics_state.clone();
@ -501,6 +614,62 @@ pub async fn start_managed_repair(
#[cfg(test)]
mod tests {
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
const ROOT_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const OWNER_TOKEN: &str = "desktop-owner-token";
fn ready_health(include_owner: bool) -> String {
let owner = if include_owner {
format!(
r#", "desktop_owner":{{"kind":"tauri","token_sha256":"{}"}}"#,
crate::desktop_backend_owner::token_sha256(OWNER_TOKEN)
)
} else {
String::new()
};
format!(
r#"{{"status":"healthy","service":"Unsloth UI Backend","version":"2026.5.3","desktop_protocol_version":1,"desktop_manageability_version":1,"supports_desktop_auth":true,"supports_desktop_backend_ownership":true,"studio_root_id":"{ROOT_ID}"{owner}}}"#
)
}
async fn command_test_backend(health_body: String) -> u16 {
let mut listener = None;
for port in 8888u16..=8908 {
if let Ok(bound) = TcpListener::bind(("127.0.0.1", port)).await {
listener = Some(bound);
break;
}
}
let listener = listener.expect("test needs a free desktop preflight port");
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
for _ in 0..2 {
let Ok((mut stream, _)) = listener.accept().await else {
return;
};
let mut buffer = [0; 2048];
let Ok(n) = stream.read(&mut buffer).await else {
return;
};
let request = String::from_utf8_lossy(&buffer[..n]);
let (status, body) = if request.starts_with("GET /api/health ") {
("200 OK", health_body.as_str())
} else if request.starts_with("POST /api/auth/desktop-login ") {
("401 Unauthorized", "")
} 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()
);
let _ = stream.write_all(response.as_bytes()).await;
}
});
port
}
#[test]
fn repair_elevation_is_not_a_terminal_repair_failure() {
@ -510,28 +679,36 @@ mod tests {
));
}
#[test]
fn watchdog_ignores_startup_failures_within_grace_period() {
assert!(!super::should_count_watchdog_failure(
false,
super::BACKEND_STARTUP_GRACE_PERIOD - Duration::from_secs(1)
));
#[tokio::test]
async fn mutation_guard_blocks_second_external_backend_when_owned_child_is_ignored() {
crate::desktop_backend_owner::install_test_owner(ROOT_ID, OWNER_TOKEN);
let owned_port = command_test_backend(ready_health(true)).await;
let external_port = command_test_backend(ready_health(false)).await;
let err = super::block_external_conflict(&[owned_port])
.await
.expect_err("external non-owned backend should block mutation");
assert!(err.contains(&format!("port {external_port}")));
assert!(err.contains("Stop that server"));
}
#[test]
fn watchdog_counts_failures_after_backend_was_healthy() {
assert!(super::should_count_watchdog_failure(
true,
Duration::from_secs(1)
));
}
#[test]
fn watchdog_counts_startup_failures_after_grace_period() {
assert!(super::should_count_watchdog_failure(
false,
super::BACKEND_STARTUP_GRACE_PERIOD
));
fn watchdog_failure_policy_counts_only_after_health_or_grace_period() {
for (has_seen_healthy, elapsed, expected) in [
(
false,
super::BACKEND_STARTUP_GRACE_PERIOD - Duration::from_secs(1),
false,
),
(true, Duration::from_secs(1), true),
(false, super::BACKEND_STARTUP_GRACE_PERIOD, true),
] {
assert_eq!(
super::should_count_watchdog_failure(has_seen_healthy, elapsed),
expected
);
}
}
}
@ -546,12 +723,13 @@ async fn health_watchdog(
shutdown: ShutdownFlag,
diagnostics: DiagnosticsState,
generation: u64,
count_failures_immediately: bool,
) {
use std::sync::atomic::Ordering;
let started_at = Instant::now();
let mut consecutive_failures: u32 = 0;
let mut has_seen_healthy = false;
let mut has_seen_healthy = count_failures_immediately;
loop {
tokio::time::sleep(HEALTH_WATCHDOG_INTERVAL).await;
@ -561,12 +739,17 @@ async fn health_watchdog(
break;
}
let (port, has_child, current_generation) = {
let (port, has_owned, has_adopted, current_generation) = {
let proc = match state.lock() {
Ok(p) => p,
Err(_) => break,
};
(proc.port, proc.child.is_some(), proc.generation)
(
proc.port,
proc.has_owned_backend(),
proc.has_adopted_backend(),
proc.generation,
)
};
if current_generation != generation {
@ -575,70 +758,94 @@ async fn health_watchdog(
}
// Stop watching if the backend is gone
if !has_child {
if !has_owned {
info!("Health watchdog: backend stopped, exiting");
break;
}
let should_count_failure =
should_count_watchdog_failure(has_seen_healthy, started_at.elapsed());
port.is_some() || should_count_watchdog_failure(has_seen_healthy, started_at.elapsed());
let Some(port) = port else {
if should_count_failure {
consecutive_failures += 1;
warn!(
"Health watchdog: backend has not reported a port ({}/{})",
consecutive_failures, HEALTH_WATCHDOG_MAX_FAILURES
);
}
if consecutive_failures >= HEALTH_WATCHDOG_MAX_FAILURES {
error!(
"Health watchdog: backend never reported a port, killing and declaring dead"
);
if has_adopted {
diagnostics::record_backend_watchdog(
&diagnostics,
generation,
"no_port_after_grace",
"adopted_port_missing",
);
error!("Health watchdog: adopted backend lost its port, declaring dead");
process::clear_adopted_backend_if_current(
&state,
generation,
None,
"watchdog adopted port missing",
);
let _ = app.emit("server-crashed", ());
break;
}
if !should_count_failure {
info!("Health watchdog: backend has not reported a validated port yet");
continue;
}
consecutive_failures += 1;
warn!(
"Health watchdog: missing validated port failure {}/{}",
consecutive_failures, HEALTH_WATCHDOG_MAX_FAILURES
);
if consecutive_failures >= HEALTH_WATCHDOG_MAX_FAILURES {
diagnostics::record_backend_watchdog(
&diagnostics,
generation,
"missing_validated_port",
);
error!("Health watchdog: backend never reported a validated port, killing and declaring dead");
let _ = process::stop_backend(&state, &shutdown, Some(&diagnostics));
let _ = app.emit("server-crashed", ());
break;
}
continue;
};
match check_health_inner(port).await {
Ok(true) => {
has_seen_healthy = true;
consecutive_failures = 0;
}
_ if !should_count_failure => {
info!(
"Health watchdog: startup health check failed on port {} before grace period elapsed",
port
if check_watchdog_health(&state, generation, port, has_adopted).await {
has_seen_healthy = true;
consecutive_failures = 0;
} else if !should_count_failure {
info!(
"Health watchdog: startup health check failed on port {} before grace period elapsed",
port
);
} else {
consecutive_failures += 1;
warn!(
"Health watchdog: failure {}/{} on port {}",
consecutive_failures, HEALTH_WATCHDOG_MAX_FAILURES, port
);
if consecutive_failures >= HEALTH_WATCHDOG_MAX_FAILURES {
diagnostics::record_backend_watchdog(
&diagnostics,
generation,
"unresponsive_health_check",
);
}
_ => {
consecutive_failures += 1;
warn!(
"Health watchdog: failure {}/{} on port {}",
consecutive_failures, HEALTH_WATCHDOG_MAX_FAILURES, port
);
if consecutive_failures >= HEALTH_WATCHDOG_MAX_FAILURES {
error!("Health watchdog: backend unresponsive, killing and declaring dead");
diagnostics::record_backend_watchdog(
&diagnostics,
generation,
"unresponsive_health_check",
if has_adopted {
error!(
"Health watchdog: adopted backend unresponsive, clearing state and declaring dead"
);
process::clear_adopted_backend_if_current(
&state,
generation,
Some(port),
"watchdog health check failures",
);
} else {
error!("Health watchdog: backend unresponsive, killing and declaring dead");
// Kill the zombie process so retry can start fresh
let _ = process::stop_backend(&state, &shutdown, Some(&diagnostics));
let _ = app.emit("server-crashed", ());
break;
}
let _ = app.emit("server-crashed", ());
break;
}
}
}
process::clear_adopted_watchdog_if_current(&state, generation);
}

View file

@ -91,13 +91,36 @@ fn read_secret_if_exists(path: &Path) -> Result<Option<String>, String> {
async fn current_backend_port(
state: &tauri::State<'_, BackendState>,
) -> Result<BackendPort, String> {
if let Some(port) = state.lock().map_err(|e| e.to_string())?.port {
let cached_port = {
let proc = state.lock().map_err(|e| e.to_string())?;
if let Some(port) = proc.owned_backend_port() {
return Ok(BackendPort {
port,
source: PortSource::Cached,
});
}
if proc.has_owned_backend() {
None
} else {
proc.port
}
};
if let Some(port) = cached_port {
return Ok(BackendPort {
port,
source: PortSource::Cached,
});
}
if state
.lock()
.map(|proc| proc.has_owned_backend())
.map_err(|e| e.to_string())?
{
return Err("Backend is not ready".to_string());
}
let port = discover_compatible_backend_port()
.await
.ok_or_else(|| "Backend is not ready".to_string())?;
@ -152,6 +175,16 @@ fn should_retry_with_discovered_port(source: PortSource, error: &AuthError) -> b
)
}
fn can_retry_on_discovered_port(state: &BackendState, source: PortSource) -> Result<bool, String> {
if source != PortSource::Cached {
return Ok(false);
}
state
.lock()
.map(|proc| !proc.has_owned_backend())
.map_err(|e| e.to_string())
}
async fn exchange_desktop_secret(
client: &Client,
port: u16,
@ -234,7 +267,7 @@ async fn retry_on_discovered_port(
previous: BackendPort,
secret: &str,
) -> Result<Option<(Option<DesktopAuthResponse>, BackendPort)>, String> {
if previous.source != PortSource::Cached {
if !can_retry_on_discovered_port(state.inner(), previous.source)? {
return Ok(None);
}
let Some(port) = discover_compatible_backend_port().await else {
@ -359,23 +392,6 @@ mod tests {
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_recoverable_errors() {
assert!(should_retry_with_discovered_port(
@ -397,111 +413,56 @@ mod tests {
}
#[test]
fn read_secret_returns_none_for_missing_file() {
let path = std::env::temp_dir().join(format!(
"unsloth-missing-desktop-secret-{}",
std::process::id()
));
let _ = std::fs::remove_file(&path);
fn owned_handle_disables_discovered_auth_retry() {
const ROOT_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const TOKEN: &str = "desktop-owner-token";
assert_eq!(read_secret_if_exists(&path).unwrap(), None);
let state = crate::process::new_backend_state();
assert!(can_retry_on_discovered_port(&state, PortSource::Cached).unwrap());
assert!(!can_retry_on_discovered_port(&state, PortSource::Discovered).unwrap());
let owner = crate::desktop_backend_owner::test_owner_state(ROOT_ID, TOKEN, 8890);
state.lock().unwrap().owned = Some(crate::process::OwnedBackendHandle::adopted(
owner, 8890, 2, 3,
));
assert!(!can_retry_on_discovered_port(&state, PortSource::Cached).unwrap());
}
#[test]
fn read_secret_trims_existing_file() {
let path =
fn read_secret_handles_missing_trimmed_and_invalid_files() {
let base =
std::env::temp_dir().join(format!("unsloth-desktop-secret-{}", std::process::id()));
std::fs::write(&path, " desktop-secret\n").unwrap();
let missing = base.with_extension("missing");
let trimmed = base.with_extension("trimmed");
let invalid = base.with_extension("invalid");
let _ = std::fs::remove_file(&missing);
std::fs::write(&trimmed, " desktop-secret\n").unwrap();
std::fs::write(&invalid, [0xff, 0xfe]).unwrap();
assert_eq!(read_secret_if_exists(&missing).unwrap(), None);
assert_eq!(
read_secret_if_exists(&path).unwrap(),
read_secret_if_exists(&trimmed).unwrap(),
Some("desktop-secret".to_string())
);
std::fs::remove_file(path).unwrap();
}
#[test]
fn read_secret_treats_invalid_utf8_as_missing_for_repair() {
let path = std::env::temp_dir().join(format!(
"unsloth-invalid-desktop-secret-{}",
std::process::id()
));
std::fs::write(&path, [0xff, 0xfe]).unwrap();
assert_eq!(read_secret_if_exists(&path).unwrap(), None);
std::fs::remove_file(path).unwrap();
}
#[cfg(unix)]
#[test]
fn read_secret_treats_permission_denied_as_missing_for_repair() {
use std::os::unix::fs::PermissionsExt;
let path = std::env::temp_dir().join(format!(
"unsloth-unreadable-desktop-secret-{}",
std::process::id()
));
std::fs::write(&path, "desktop-stale").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
let result = read_secret_if_exists(&path);
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
std::fs::remove_file(path).unwrap();
if matches!(result, Ok(Some(_))) {
return;
}
assert_eq!(result.unwrap(), None);
}
#[test]
fn attached_ready_port_requires_attached_ready_with_port() {
let compatible = DesktopPreflightResult {
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);
assert_eq!(read_secret_if_exists(&invalid).unwrap(), None);
let _ = std::fs::remove_file(trimmed);
let _ = std::fs::remove_file(invalid);
}
#[tokio::test]
async fn exchange_desktop_secret_returns_none_for_unauthorized() {
async fn exchange_desktop_secret_handles_unauthorized_and_not_found() {
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."

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,420 @@
use serde::Serialize;
use std::collections::HashMap;
const DESKTOP_RELEASE_PAGE_BASE_URL: &str = "https://github.com/unslothai/unsloth/releases/tag/";
const DESKTOP_RELEASE_TAG_PREFIX: &str = "desktop-v";
const DESKTOP_UPDATER_CHANNEL_URL: &str =
"https://github.com/unslothai/unsloth/releases/download/desktop-latest/latest.json";
#[allow(dead_code)]
#[derive(Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum DesktopUpdateMode {
InApp,
ManualLinuxPackage,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DesktopUpdatePolicy {
mode: DesktopUpdateMode,
release_page_base_url: &'static str,
release_tag_prefix: &'static str,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ManualUpdateInfo {
version: String,
current_version: String,
body: Option<String>,
date: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
struct ChannelMetadata {
version: String,
body: Option<String>,
date: Option<String>,
platforms: HashMap<String, ChannelPlatform>,
}
#[derive(Debug, serde::Deserialize)]
struct ChannelPlatform {
url: String,
signature: String,
}
#[tauri::command]
pub(crate) fn desktop_update_policy() -> DesktopUpdatePolicy {
DesktopUpdatePolicy {
mode: desktop_update_mode(),
release_page_base_url: DESKTOP_RELEASE_PAGE_BASE_URL,
release_tag_prefix: DESKTOP_RELEASE_TAG_PREFIX,
}
}
#[tauri::command]
pub(crate) async fn check_desktop_manual_update() -> Result<Option<ManualUpdateInfo>, String> {
if !matches!(desktop_update_mode(), DesktopUpdateMode::ManualLinuxPackage) {
return Ok(None);
}
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| e.to_string())?;
let response = match client.get(DESKTOP_UPDATER_CHANNEL_URL).send().await {
Ok(response) => response,
Err(error) => {
log::warn!("Manual update metadata check failed: {}", error);
return Ok(None);
}
};
if !response.status().is_success() {
log::warn!(
"Manual update metadata check returned HTTP {}",
response.status()
);
return Ok(None);
}
let metadata = response
.json::<ChannelMetadata>()
.await
.map_err(|e| format!("Invalid desktop updater metadata: {e}"))?;
let current_version = env!("CARGO_PKG_VERSION");
let Some(latest_version) = normalize_version(&metadata.version) else {
return Err(format!(
"Desktop updater metadata has invalid version: {}",
metadata.version
));
};
validate_channel_metadata(&metadata, &latest_version)?;
if compare_versions(&latest_version, current_version) <= 0 {
return Ok(None);
}
Ok(Some(ManualUpdateInfo {
version: latest_version,
current_version: current_version.to_string(),
body: metadata.body,
date: metadata.date,
}))
}
fn validate_channel_metadata(
metadata: &ChannelMetadata,
normalized_version: &str,
) -> Result<(), String> {
if metadata.platforms.is_empty() {
return Err("Desktop updater metadata has no platforms".to_string());
}
let expected_prefix = format!(
"https://github.com/unslothai/unsloth/releases/download/desktop-v{normalized_version}/"
);
for (platform, entry) in &metadata.platforms {
if entry.url.trim().is_empty() {
return Err(format!(
"Desktop updater metadata missing URL for {platform}"
));
}
if entry.signature.trim().is_empty() {
return Err(format!(
"Desktop updater metadata missing signature for {platform}"
));
}
if entry.url.contains("/releases/latest/")
|| entry.url.contains("/releases/download/desktop-latest/")
|| !entry.url.starts_with(&expected_prefix)
{
return Err(format!(
"Desktop updater metadata has untrusted URL for {platform}: {}",
entry.url
));
}
}
Ok(())
}
fn desktop_update_mode() -> DesktopUpdateMode {
#[cfg(target_os = "linux")]
{
if std::env::var_os("APPIMAGE").is_some() {
DesktopUpdateMode::InApp
} else {
DesktopUpdateMode::ManualLinuxPackage
}
}
#[cfg(not(target_os = "linux"))]
{
DesktopUpdateMode::InApp
}
}
fn normalize_version(version: &str) -> Option<String> {
let trimmed = version.trim();
let without_v = trimmed.strip_prefix('v').unwrap_or(trimmed);
if parse_version(without_v).is_some() {
Some(without_v.to_string())
} else {
None
}
}
fn compare_versions(left: &str, right: &str) -> i8 {
let Some(left) = parse_version(left) else {
return 0;
};
let Some(right) = parse_version(right) else {
return 0;
};
match compare_parsed_versions(&left, &right) {
std::cmp::Ordering::Greater => 1,
std::cmp::Ordering::Equal => 0,
std::cmp::Ordering::Less => -1,
}
}
#[derive(Debug, Eq, PartialEq)]
struct ParsedVersion {
release: [u64; 3],
suffix: VersionSuffix,
}
#[derive(Debug, Eq, PartialEq)]
enum VersionSuffix {
Dev(u64),
Alpha(u64),
Beta(u64),
Rc(u64),
PreRelease(String),
Stable,
Build,
Post(u64),
}
fn parse_version(version: &str) -> Option<ParsedVersion> {
let mut parts = version.splitn(3, '.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch_and_suffix = parts.next()?;
let patch_len = patch_and_suffix
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(patch_and_suffix.len());
if patch_len == 0 {
return None;
}
let suffix = parse_version_suffix(&patch_and_suffix[patch_len..])?;
Some(ParsedVersion {
release: [major, minor, patch_and_suffix[..patch_len].parse().ok()?],
suffix,
})
}
fn parse_version_suffix(suffix: &str) -> Option<VersionSuffix> {
if suffix.is_empty() {
return Some(VersionSuffix::Stable);
}
if let Some(value) = suffix.strip_prefix('+') {
if suffix_part_valid(value) {
return Some(VersionSuffix::Build);
}
return None;
}
if let Some(value) = suffix.strip_prefix('-') {
if suffix_part_valid(value) {
return Some(VersionSuffix::PreRelease(value.to_ascii_lowercase()));
}
return None;
}
if let Some(number) = parse_numbered_suffix(suffix, &[".post", "post"]) {
return number.map(VersionSuffix::Post);
}
if let Some(number) = parse_numbered_suffix(suffix, &[".dev", "dev"]) {
return number.map(VersionSuffix::Dev);
}
if let Some(number) = parse_numbered_suffix(suffix, &[".rc", "rc"]) {
return number.map(VersionSuffix::Rc);
}
if let Some(number) = parse_numbered_suffix(suffix, &["a"]) {
return number.map(VersionSuffix::Alpha);
}
if let Some(number) = parse_numbered_suffix(suffix, &["b"]) {
return number.map(VersionSuffix::Beta);
}
None
}
fn parse_numbered_suffix(suffix: &str, prefixes: &[&str]) -> Option<Option<u64>> {
for prefix in prefixes {
let Some(number) = suffix.strip_prefix(prefix) else {
continue;
};
if number.is_empty() {
return Some(Some(0));
}
return Some(number.parse().ok());
}
None
}
fn suffix_part_valid(value: &str) -> bool {
!value.is_empty()
&& value.split('.').all(|part| {
!part.is_empty() && part.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
})
}
fn compare_parsed_versions(left: &ParsedVersion, right: &ParsedVersion) -> std::cmp::Ordering {
match left.release.cmp(&right.release) {
std::cmp::Ordering::Equal => compare_suffixes(&left.suffix, &right.suffix),
ordering => ordering,
}
}
fn compare_suffixes(left: &VersionSuffix, right: &VersionSuffix) -> std::cmp::Ordering {
let left_precedence = suffix_precedence(left);
let right_precedence = suffix_precedence(right);
if left_precedence != right_precedence {
return left_precedence.cmp(&right_precedence);
}
match (left, right) {
(VersionSuffix::Dev(left), VersionSuffix::Dev(right))
| (VersionSuffix::Alpha(left), VersionSuffix::Alpha(right))
| (VersionSuffix::Beta(left), VersionSuffix::Beta(right))
| (VersionSuffix::Rc(left), VersionSuffix::Rc(right))
| (VersionSuffix::Post(left), VersionSuffix::Post(right)) => left.cmp(right),
(VersionSuffix::Rc(left), VersionSuffix::PreRelease(right)) => {
compare_numbered_prefix_to_prerelease("rc", *left, right)
.unwrap_or(std::cmp::Ordering::Equal)
}
(VersionSuffix::PreRelease(left), VersionSuffix::Rc(right)) => {
compare_numbered_prefix_to_prerelease("rc", *right, left)
.map(std::cmp::Ordering::reverse)
.unwrap_or(std::cmp::Ordering::Equal)
}
(VersionSuffix::PreRelease(left), VersionSuffix::PreRelease(right)) => {
compare_prerelease(left, right)
}
_ => std::cmp::Ordering::Equal,
}
}
fn suffix_precedence(suffix: &VersionSuffix) -> u8 {
match suffix {
VersionSuffix::Dev(_) => 0,
VersionSuffix::Alpha(_) => 1,
VersionSuffix::Beta(_) => 2,
VersionSuffix::Rc(_) | VersionSuffix::PreRelease(_) => 3,
VersionSuffix::Stable | VersionSuffix::Build => 4,
VersionSuffix::Post(_) => 5,
}
}
fn compare_prerelease(left: &str, right: &str) -> std::cmp::Ordering {
for (left_part, right_part) in left.split('.').zip(right.split('.')) {
let ordering = compare_prerelease_part(left_part, right_part);
if !ordering.is_eq() {
return ordering;
}
}
left.split('.').count().cmp(&right.split('.').count())
}
fn compare_prerelease_part(left: &str, right: &str) -> std::cmp::Ordering {
let left_number = left.parse::<u64>();
let right_number = right.parse::<u64>();
match (left_number, right_number) {
(Ok(left), Ok(right)) => return left.cmp(&right),
(Ok(_), Err(_)) => return std::cmp::Ordering::Less,
(Err(_), Ok(_)) => return std::cmp::Ordering::Greater,
(Err(_), Err(_)) => {}
}
match (split_alpha_numeric(left), split_alpha_numeric(right)) {
(Some((left_prefix, left_number)), Some((right_prefix, right_number)))
if left_prefix == right_prefix =>
{
return left_number.cmp(&right_number);
}
_ => {}
}
left.cmp(right)
}
fn compare_numbered_prefix_to_prerelease(
prefix: &str,
number: u64,
prerelease: &str,
) -> Option<std::cmp::Ordering> {
let (other_prefix, other_number) = split_alpha_numeric(prerelease)?;
(other_prefix == prefix).then(|| number.cmp(&other_number))
}
fn split_alpha_numeric(value: &str) -> Option<(&str, u64)> {
let digit_start = value.find(|c: char| c.is_ascii_digit())?;
if digit_start == 0 || value[digit_start..].bytes().any(|b| !b.is_ascii_digit()) {
return None;
}
Some((&value[..digit_start], value[digit_start..].parse().ok()?))
}
#[cfg(test)]
mod tests {
#[test]
fn compare_versions_orders_supported_suffixes() {
assert!(super::compare_versions("2026.5.3", "2026.5.3-rc1") > 0);
assert!(super::compare_versions("2026.5.3", "2026.5.3rc1") > 0);
assert!(super::compare_versions("2026.5.3", "2026.5.3.dev1") > 0);
assert!(super::compare_versions("2026.5.3.post1", "2026.5.3") > 0);
assert!(super::compare_versions("2026.5.3.post1", "2026.5.3+build1") > 0);
assert!(super::compare_versions("2026.5.3+build1", "2026.5.3-beta.1") > 0);
assert!(super::compare_versions("2026.5.3-rc10", "2026.5.3-rc2") > 0);
assert!(super::compare_versions("2026.5.3-rc10", "2026.5.3rc2") > 0);
assert!(super::compare_versions("2026.5.3rc2", "2026.5.3-rc10") < 0);
assert!(super::compare_versions("2026.5.3-beta10", "2026.5.3-beta2") > 0);
assert!(super::compare_versions("2026.5.3rc2", "2026.5.3rc1") > 0);
assert!(super::compare_versions("2026.5.3b1", "2026.5.3a1") > 0);
}
#[test]
fn normalize_version_accepts_backend_suffix_formats() {
for version in [
"v2026.5.4.post1",
"2026.5.4post1",
"2026.5.4.dev1",
"2026.5.4dev1",
"2026.5.4.rc1",
"2026.5.4rc1",
"2026.5.4a1",
"2026.5.4b1",
"2026.5.4-rc1",
"2026.5.4+build1",
] {
assert!(super::normalize_version(version).is_some(), "{version}");
}
}
#[test]
fn normalize_version_rejects_invalid_suffixes() {
for version in [
"2026.5.4garbage",
"2026.5.4-",
"2026.5.4+",
"2026.5.4-rc..1",
"2026.5.4.devx",
] {
assert!(super::normalize_version(version).is_none(), "{version}");
}
}
}

View file

@ -13,13 +13,13 @@ pub use phase_log::append_phase_line;
#[cfg(target_os = "linux")]
pub use phase_log::PhaseLogHandle;
pub use state::{
begin_backend_session, begin_install_attempt, begin_repair_child, begin_repair_group,
begin_update_attempt, finish_attempt, finish_repair_group, new_diagnostics_state,
record_attached_external_backend, record_auth_failure, record_backend_exit,
record_backend_intentional_stop, record_backend_port, record_backend_start_failure,
record_backend_watchdog, record_diag_marker, record_elevation_packages, record_preflight,
record_progress, record_step, AttemptLog, BackendLog, DiagnosticsState,
FrontendSupportSnapshot,
begin_adopted_backend_session, begin_backend_session, begin_install_attempt,
begin_repair_child, begin_repair_group, begin_update_attempt, finish_attempt,
finish_repair_group, new_diagnostics_state, record_attached_external_backend,
record_auth_failure, record_backend_exit, record_backend_intentional_stop, record_backend_port,
record_backend_start_failure, record_backend_watchdog, record_diag_marker,
record_elevation_packages, record_preflight, record_progress, record_step, AttemptLog,
BackendLog, DiagnosticsState, FrontendSupportSnapshot,
};
pub const SCHEMA_VERSION: u32 = 1;
@ -151,8 +151,10 @@ async fn collect_backend_health(port: u16) -> report::BackendHealthSection {
"device_type",
"chat_only",
"desktop_protocol_version",
"desktop_manageability_version",
"supports_api_only",
"supports_desktop_auth",
"supports_desktop_backend_ownership",
] {
if let Some(value) = json.get(key) {
section

View file

@ -341,8 +341,10 @@ fn append_device_signals_section(
| "device_type"
| "chat_only"
| "desktop_protocol_version"
| "desktop_manageability_version"
| "supports_api_only"
| "supports_desktop_auth"
| "supports_desktop_backend_ownership"
) {
out.push_str(&format!(" {key}={value}\n"));
}

View file

@ -435,6 +435,27 @@ pub fn begin_backend_session(
}
}
pub fn begin_adopted_backend_session(diagnostics: &DiagnosticsState, port: u16, generation: u64) {
let session_id = new_id("adopted-backend");
let started_at_ms = now_ms();
with_snapshot(diagnostics, |snapshot| {
snapshot.backend = Some(BackendSummary {
session_id: Some(session_id.clone()),
backend_kind: "adopted".to_string(),
log_segments: Vec::new(),
requested_port: Some(port),
reported_port: Some(port),
generation: Some(generation),
started_at_ms: Some(started_at_ms),
ended_at_ms: None,
exit_status: None,
intentional_stop: false,
terminal_reason: None,
last_error: None,
});
});
}
pub fn record_backend_start_failure(
diagnostics: &DiagnosticsState,
requested_port: Option<u16>,
@ -769,6 +790,9 @@ fn disposition_label(disposition: &DesktopPreflightDisposition) -> &'static str
DesktopPreflightDisposition::NotInstalled => "not_installed",
DesktopPreflightDisposition::ManagedReady => "managed_ready",
DesktopPreflightDisposition::ManagedStale => "managed_stale",
DesktopPreflightDisposition::OwnedReady => "owned_ready",
DesktopPreflightDisposition::OwnedStale => "owned_stale",
DesktopPreflightDisposition::AttachedReady => "attached_ready",
DesktopPreflightDisposition::ExternalConflict => "external_conflict",
}
}

View file

@ -2,6 +2,8 @@
mod commands;
mod desktop_auth;
mod desktop_backend_owner;
mod desktop_update_policy;
mod diagnostics;
mod install;
mod native_backend_lease;
@ -192,6 +194,8 @@ fn main() {
commands::cancel_pending_elevation,
commands::install_system_packages,
desktop_auth::desktop_auth,
desktop_update_policy::check_desktop_manual_update,
desktop_update_policy::desktop_update_policy,
diagnostics::collect_support_diagnostics,
native_intents::drain_native_intents,
native_intents::register_native_model_path,

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,336 @@
use super::types::BackendProbe;
use super::version::{
backend_version_stale_reason, DESKTOP_MANAGEABILITY_VERSION, DESKTOP_PROTOCOL_VERSION,
};
use serde::{Deserialize, Serialize};
use std::time::Duration;
#[derive(Debug, Deserialize)]
struct DesktopOwnerHealth {
kind: Option<String>,
token_sha256: Option<String>,
}
#[derive(Debug)]
pub(super) struct BackendHealth {
desktop_protocol_version: Option<u16>,
desktop_manageability_version: Option<u16>,
supports_desktop_auth: Option<bool>,
supports_desktop_backend_ownership: Option<bool>,
studio_root_id: Option<String>,
desktop_owner: Option<DesktopOwnerHealth>,
version: Option<String>,
stale_reason: Option<String>,
}
pub(super) async fn backend_health(client: &reqwest::Client, port: u16) -> Option<BackendHealth> {
let url = format!("http://127.0.0.1:{port}/api/health");
let response = client.get(url).send().await.ok()?;
if !response.status().is_success() {
return None;
}
let json = response.json::<serde_json::Value>().await.ok()?;
let healthy = json
.get("status")
.and_then(|v| v.as_str())
.map(|s| s == "healthy")
.unwrap_or(false);
let service = json
.get("service")
.and_then(|v| v.as_str())
.map(|s| s == "Unsloth UI Backend")
.unwrap_or(false);
if !healthy || !service {
return None;
}
let desktop_protocol_version = json
.get("desktop_protocol_version")
.and_then(|v| v.as_u64())
.and_then(|v| u16::try_from(v).ok());
let desktop_manageability_version = json
.get("desktop_manageability_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());
let supports_desktop_backend_ownership = json
.get("supports_desktop_backend_ownership")
.and_then(|v| v.as_bool());
let studio_root_id = json
.get("studio_root_id")
.and_then(|v| v.as_str())
.map(ToOwned::to_owned);
let desktop_owner = json
.get("desktop_owner")
.and_then(|v| serde_json::from_value::<DesktopOwnerHealth>(v.clone()).ok());
let version = json
.get("version")
.and_then(|v| v.as_str())
.map(ToOwned::to_owned);
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,
desktop_manageability_version,
supports_desktop_auth,
supports_desktop_backend_ownership,
studio_root_id,
desktop_owner,
version,
stale_reason,
})
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum BackendRootStatus {
SameRoot,
ForeignRoot,
AmbiguousRoot,
ExpectedUnavailable,
}
fn backend_root_status(
health: &BackendHealth,
expected_studio_root_id: Option<&str>,
) -> BackendRootStatus {
let Some(expected) = expected_studio_root_id else {
return BackendRootStatus::ExpectedUnavailable;
};
match health.studio_root_id.as_deref() {
Some(actual) if actual == expected => BackendRootStatus::SameRoot,
Some(actual) if crate::desktop_backend_owner::is_valid_studio_root_id(actual) => {
BackendRootStatus::ForeignRoot
}
_ => BackendRootStatus::AmbiguousRoot,
}
}
fn backend_desktop_owner_match(
health: &BackendHealth,
) -> crate::desktop_backend_owner::HealthOwnerMatch {
let Some(owner) = health.desktop_owner.as_ref() else {
return crate::desktop_backend_owner::HealthOwnerMatch::None;
};
crate::desktop_backend_owner::classify_health_desktop_owner(
health.studio_root_id.as_deref(),
owner.kind.as_deref(),
owner.token_sha256.as_deref(),
)
}
fn backend_capability_stale_reason(health: &BackendHealth) -> Option<String> {
if health.desktop_protocol_version != Some(DESKTOP_PROTOCOL_VERSION) {
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()));
}
if health.desktop_manageability_version.unwrap_or(0) < DESKTOP_MANAGEABILITY_VERSION {
return Some("desktop_manageability_unsupported".to_string());
}
if health.supports_desktop_backend_ownership != Some(true) {
return Some("desktop_backend_ownership_unsupported".to_string());
}
backend_version_stale_reason(health.version.as_deref())
}
#[derive(Serialize)]
struct DesktopLoginProbe<'a> {
secret: &'a str,
}
pub(super) async fn probe_ownerless_spawned_backend(port: u16) -> BackendProbe {
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()
{
Ok(client) => client,
Err(_) => return BackendProbe::Missing,
};
let Some(health) = backend_health(&client, port).await else {
return BackendProbe::Missing;
};
if let Some(reason) = backend_capability_stale_reason(&health) {
return BackendProbe::Old { port, reason };
}
let response = client
.post(format!("http://127.0.0.1:{port}/api/auth/desktop-login"))
.json(&DesktopLoginProbe {
secret: "desktop-preflight-invalid-secret",
})
.send()
.await;
match response.map(|response| response.status()) {
Ok(reqwest::StatusCode::UNAUTHORIZED) => BackendProbe::Ready { port },
Ok(reqwest::StatusCode::NOT_FOUND) => BackendProbe::Old {
port,
reason: "desktop_login_not_found".to_string(),
},
_ => BackendProbe::Old {
port,
reason: "desktop_login_probe_failed".to_string(),
},
}
}
pub(super) async fn backend_desktop_auth_status(
client: &reqwest::Client,
port: u16,
health: &BackendHealth,
expected_studio_root_id: Option<&str>,
) -> BackendProbe {
let root_status = backend_root_status(health, expected_studio_root_id);
let owner_match = backend_desktop_owner_match(health);
let verified_owner = owner_match == crate::desktop_backend_owner::HealthOwnerMatch::CurrentApp;
let same_root_external = root_status == BackendRootStatus::SameRoot && !verified_owner;
match root_status {
BackendRootStatus::AmbiguousRoot | BackendRootStatus::ExpectedUnavailable => {
return BackendProbe::ExternalConflict {
port,
reason: "ambiguous_root_external_backend_active".to_string(),
};
}
BackendRootStatus::ForeignRoot => {
return BackendProbe::Old {
port,
reason: "studio_root_id_mismatch".to_string(),
};
}
BackendRootStatus::SameRoot => {}
}
if same_root_external
&& matches!(
owner_match,
crate::desktop_backend_owner::HealthOwnerMatch::PreviousApp
| crate::desktop_backend_owner::HealthOwnerMatch::OtherDesktopOwner
)
{
return BackendProbe::ExternalConflict {
port,
reason: "desktop_owned_backend_active".to_string(),
};
}
if let Some(reason) = backend_capability_stale_reason(health) {
return if same_root_external {
BackendProbe::ExternalConflict { port, reason }
} else {
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 {
let reason = backend_capability_stale_reason(health)
.unwrap_or_else(|| "desktop_login_probe_failed".to_string());
return if same_root_external {
BackendProbe::ExternalConflict { port, reason }
} else {
BackendProbe::Old { port, reason }
};
};
match response.status() {
reqwest::StatusCode::UNAUTHORIZED => BackendProbe::Ready { port },
reqwest::StatusCode::NOT_FOUND => {
let reason = "desktop_login_not_found".to_string();
if same_root_external {
BackendProbe::ExternalConflict { port, reason }
} else {
BackendProbe::Old { port, reason }
}
}
_ => {
let reason = backend_capability_stale_reason(health)
.unwrap_or_else(|| "desktop_login_probe_failed".to_string());
if same_root_external {
BackendProbe::ExternalConflict { port, reason }
} else {
BackendProbe::Old { port, reason }
}
}
}
}
pub(super) async fn probe_existing_backends(ignored_ports: &[u16]) -> BackendProbe {
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()
{
Ok(client) => client,
Err(_) => return BackendProbe::Missing,
};
// Fan out health probes concurrently. The desktop-auth probe is still
// sequential per candidate because it has auth-log side effects.
let ports: Vec<u16> = crate::desktop_backend_owner::desktop_candidate_ports().collect();
let mut health_futs = Vec::with_capacity(ports.len());
for port in ports {
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 expected_studio_root_id = crate::desktop_backend_owner::read_expected_studio_root_id();
let mut first_conflict = None;
let mut first_ready = None;
let mut first_old = None;
for (port, health) in candidates {
if ignored_ports.contains(&port) {
continue;
}
match backend_desktop_auth_status(
&client,
port,
&health,
expected_studio_root_id.as_deref(),
)
.await
{
conflict @ BackendProbe::ExternalConflict { .. } if first_conflict.is_none() => {
first_conflict = Some(conflict)
}
ready @ BackendProbe::Ready { .. } if first_ready.is_none() => {
first_ready = Some(ready)
}
old @ BackendProbe::Old { .. } if first_old.is_none() => first_old = Some(old),
_ => {}
}
}
first_conflict
.or(first_ready)
.or(first_old)
.unwrap_or(BackendProbe::Missing)
}

View file

@ -0,0 +1,169 @@
use super::types::ManagedProbe;
use super::version::{
backend_version_stale_reason, DESKTOP_MANAGEABILITY_VERSION, DESKTOP_PROTOCOL_VERSION,
};
use serde::Deserialize;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::Duration;
use tokio::io::AsyncReadExt;
use tokio::process::Command;
#[derive(Debug, Deserialize)]
struct DesktopCapability {
desktop_protocol_version: Option<u16>,
desktop_manageability_version: Option<u16>,
supports_api_only: Option<bool>,
supports_provision_desktop_auth: Option<bool>,
supports_desktop_backend_ownership: Option<bool>,
desktop_auth_stale_reason: Option<String>,
version: Option<String>,
}
async fn run_cli_probe(bin: &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");
}
// Tauri uses the legacy root regardless of UNSLOTH_STUDIO_HOME / STUDIO_HOME;
// probe subprocesses must follow the same isolation as process.rs.
cmd.env_remove("UNSLOTH_STUDIO_HOME");
cmd.env_remove("STUDIO_HOME");
#[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: &Path) -> Option<DesktopCapability> {
let mut cmd = Command::new(bin);
cmd.args(["studio", "desktop-capabilities", "--json"])
.stdout(Stdio::piped())
.stderr(Stdio::null());
#[cfg(target_os = "linux")]
if std::env::var_os("APPIMAGE").is_some() {
cmd.env_remove("LD_LIBRARY_PATH");
cmd.env_remove("PYTHONHOME");
cmd.env_remove("PYTHONPATH");
}
// Tauri uses the legacy root regardless of UNSLOTH_STUDIO_HOME / STUDIO_HOME;
// probe subprocesses must follow the same isolation as process.rs.
cmd.env_remove("UNSLOTH_STUDIO_HOME");
cmd.env_remove("STUDIO_HOME");
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(crate::process::CREATE_NO_WINDOW);
}
let Ok(mut child) = cmd.spawn() else {
return None;
};
let Some(mut stdout) = child.stdout.take() else {
return None;
};
match tokio::time::timeout(Duration::from_secs(10), child.wait()).await {
Ok(Ok(status)) if status.success() => {}
Err(_) => {
let _ = child.kill().await;
let _ = child.wait().await;
return None;
}
_ => return None,
}
let mut output = Vec::new();
if stdout.read_to_end(&mut output).await.is_err() {
return None;
}
serde_json::from_slice::<DesktopCapability>(&output).ok()
}
fn desktop_capability_stale_reason(capability: &DesktopCapability) -> Option<String> {
if capability.desktop_protocol_version != Some(DESKTOP_PROTOCOL_VERSION) {
return Some("desktop_protocol_incompatible".to_string());
}
if capability.supports_api_only != Some(true) {
return Some("desktop_api_only_unsupported".to_string());
}
if capability.supports_provision_desktop_auth != Some(true) {
return capability
.desktop_auth_stale_reason
.clone()
.or_else(|| Some("desktop_auth_unsupported".to_string()));
}
if capability.desktop_manageability_version.unwrap_or(0) < DESKTOP_MANAGEABILITY_VERSION {
return Some("desktop_manageability_unsupported".to_string());
}
if capability.supports_desktop_backend_ownership != Some(true) {
return Some("desktop_backend_ownership_unsupported".to_string());
}
backend_version_stale_reason(capability.version.as_deref())
}
fn desktop_capability_ready(capability: &DesktopCapability) -> bool {
desktop_capability_stale_reason(capability).is_none()
}
pub(super) 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)
.unwrap_or_else(|| "desktop_capability_incompatible".to_string()),
};
}
ManagedProbe::Stale {
bin,
reason: "desktop_capability_probe_failed".to_string(),
}
}
pub(super) 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 { .. })
}

View file

@ -0,0 +1,44 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DesktopPreflightDisposition {
NotInstalled,
ManagedReady,
ManagedStale,
OwnedReady,
OwnedStale,
AttachedReady,
ExternalConflict,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DesktopPreflightResult {
pub disposition: DesktopPreflightDisposition,
pub reason: Option<String>,
pub port: Option<u16>,
pub can_auto_repair: bool,
pub managed_bin: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExternalBackendConflict {
pub port: u16,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum ManagedProbe {
Missing,
Ready { bin: PathBuf },
Stale { bin: PathBuf, reason: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum BackendProbe {
Missing,
Ready { port: u16 },
Old { port: u16, reason: String },
ExternalConflict { port: u16, reason: String },
}

View file

@ -0,0 +1,163 @@
use std::cmp::Ordering;
pub(crate) const DESKTOP_PROTOCOL_VERSION: u16 = 1;
pub(crate) const DESKTOP_MANAGEABILITY_VERSION: u16 = 1;
// Explicit backend package minimum, not the desktop app Cargo version: backend
// and app releases can diverge. When bumping, verify this package exists on PyPI.
pub(super) const MIN_DESKTOP_BACKEND_VERSION: &str = "2026.5.3";
#[derive(Debug, Eq, PartialEq)]
pub(super) struct ParsedVersion {
release: [u64; 3],
suffix: VersionSuffix,
}
#[derive(Debug, Eq, PartialEq)]
enum VersionSuffix {
Dev(u64),
Alpha(u64),
Beta(u64),
Rc(u64),
PreRelease(String),
Stable,
Build,
Post(u64),
}
pub(super) fn parse_version(value: &str) -> Option<ParsedVersion> {
let value = value.trim();
let mut parts = value.splitn(3, '.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch_and_suffix = parts.next()?;
let patch_len = patch_and_suffix
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(patch_and_suffix.len());
if patch_len == 0 {
return None;
}
let suffix = parse_version_suffix(&patch_and_suffix[patch_len..])?;
Some(ParsedVersion {
release: [major, minor, patch_and_suffix[..patch_len].parse().ok()?],
suffix,
})
}
fn parse_version_suffix(suffix: &str) -> Option<VersionSuffix> {
if suffix.is_empty() {
return Some(VersionSuffix::Stable);
}
if let Some(value) = suffix.strip_prefix('+') {
return suffix_part_valid(value).then_some(VersionSuffix::Build);
}
if let Some(value) = suffix.strip_prefix('-') {
return suffix_part_valid(value)
.then(|| VersionSuffix::PreRelease(value.to_ascii_lowercase()));
}
if let Some(number) = parse_numbered_suffix(suffix, &[".post", "post"]) {
return number.map(VersionSuffix::Post);
}
if let Some(number) = parse_numbered_suffix(suffix, &[".dev", "dev"]) {
return number.map(VersionSuffix::Dev);
}
if let Some(number) = parse_numbered_suffix(suffix, &[".rc", "rc"]) {
return number.map(VersionSuffix::Rc);
}
if let Some(number) = parse_numbered_suffix(suffix, &["a"]) {
return number.map(VersionSuffix::Alpha);
}
if let Some(number) = parse_numbered_suffix(suffix, &["b"]) {
return number.map(VersionSuffix::Beta);
}
None
}
fn parse_numbered_suffix(suffix: &str, prefixes: &[&str]) -> Option<Option<u64>> {
for prefix in prefixes {
let Some(number) = suffix.strip_prefix(prefix) else {
continue;
};
if number.is_empty() {
return Some(Some(0));
}
return Some(number.parse().ok());
}
None
}
fn suffix_part_valid(value: &str) -> bool {
!value.is_empty()
&& value.split('.').all(|part| {
!part.is_empty() && part.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
})
}
fn compare_versions(left: &ParsedVersion, right: &ParsedVersion) -> Ordering {
match left.release.cmp(&right.release) {
Ordering::Equal => compare_suffixes(&left.suffix, &right.suffix),
ordering => ordering,
}
}
fn compare_suffixes(left: &VersionSuffix, right: &VersionSuffix) -> Ordering {
let left_precedence = suffix_precedence(left);
let right_precedence = suffix_precedence(right);
if left_precedence != right_precedence {
return left_precedence.cmp(&right_precedence);
}
match (left, right) {
(VersionSuffix::Dev(left), VersionSuffix::Dev(right))
| (VersionSuffix::Alpha(left), VersionSuffix::Alpha(right))
| (VersionSuffix::Beta(left), VersionSuffix::Beta(right))
| (VersionSuffix::Rc(left), VersionSuffix::Rc(right))
| (VersionSuffix::Post(left), VersionSuffix::Post(right)) => left.cmp(right),
(VersionSuffix::PreRelease(left), VersionSuffix::PreRelease(right)) => left.cmp(right),
_ => Ordering::Equal,
}
}
fn suffix_precedence(suffix: &VersionSuffix) -> u8 {
match suffix {
VersionSuffix::Dev(_) => 0,
VersionSuffix::Alpha(_) => 1,
VersionSuffix::Beta(_) => 2,
VersionSuffix::Rc(_) | VersionSuffix::PreRelease(_) => 3,
VersionSuffix::Stable | VersionSuffix::Build => 4,
VersionSuffix::Post(_) => 5,
}
}
pub(super) fn backend_version_compatible(version: Option<&str>) -> bool {
let Some(version) = version else {
return false;
};
if cfg!(debug_assertions) && version == "dev" {
return true;
}
let Some(actual) = parse_version(version) else {
return false;
};
let Some(minimum) = parse_version(MIN_DESKTOP_BACKEND_VERSION) else {
return false;
};
compare_versions(&actual, &minimum) != Ordering::Less
}
pub(crate) fn backend_version_stale_reason(version: Option<&str>) -> Option<String> {
if backend_version_compatible(version) {
return None;
}
match version {
None | Some("") => Some("desktop_backend_version_missing".to_string()),
Some("dev") if !cfg!(debug_assertions) => {
Some("desktop_backend_version_invalid".to_string())
}
Some(value) if parse_version(value).is_none() => {
Some("desktop_backend_version_invalid".to_string())
}
Some(_) => Some("desktop_backend_version_too_old".to_string()),
}
}

File diff suppressed because it is too large Load diff

View file

@ -34,7 +34,7 @@
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEQ2RDhGMDMzNDNEMTlGMkQKUldRdG45RkRNL0RZMXZsaVo2TElUQlVHb1hVNWEyajhUOGFXeTdNVDFZTFdBVUtlZUh5L2wwWVYK",
"endpoints": [
"https://github.com/unslothai/unsloth/releases/latest/download/latest.json"
"https://github.com/unslothai/unsloth/releases/download/desktop-latest/latest.json"
],
"windows": {
"installMode": "passive"
@ -74,9 +74,6 @@
"linux": {
"deb": {
"postRemoveScript": "./linux/postremove.sh"
},
"rpm": {
"postRemoveScript": "./linux/postremove.sh"
}
}
}

View file

@ -639,18 +639,26 @@ def test_tauri_preflight_scrubs_studio_home_env():
"""All three Tauri CLI-spawn sites that lacked the scrub must now
env_remove UNSLOTH_STUDIO_HOME and STUDIO_HOME, mirroring
process.rs / install.rs / desktop_auth.rs / update.rs."""
preflight = (
REPO_ROOT / "studio" / "src-tauri" / "src" / "preflight.rs"
).read_text()
# preflight was originally a single .rs file; PR #5341 split it into
# a directory of submodules (backend / managed / types / version).
# Read whichever shape is on disk so the guard stays valid through
# future reorgs as long as the scrub calls live somewhere under
# studio/src-tauri/src/preflight*.
preflight_root = REPO_ROOT / "studio" / "src-tauri" / "src"
preflight_paths = [
preflight_root / "preflight.rs",
*(preflight_root / "preflight").glob("*.rs"),
]
preflight = "\n".join(p.read_text() for p in preflight_paths if p.exists())
commands = (REPO_ROOT / "studio" / "src-tauri" / "src" / "commands.rs").read_text()
# Both functions in preflight.rs (run_cli_probe + probe_cli_capability)
# must scrub. Count occurrences -- expect 2 in preflight, 1 in commands.
# Both functions (run_cli_probe + probe_cli_capability) must scrub.
# Count occurrences -- expect 2 in preflight (one per fn), 1 in commands.
assert (
preflight.count('cmd.env_remove("UNSLOTH_STUDIO_HOME")') >= 2
), "preflight.rs must scrub UNSLOTH_STUDIO_HOME in both run_cli_probe and probe_cli_capability"
), "preflight must scrub UNSLOTH_STUDIO_HOME in both run_cli_probe and probe_cli_capability"
assert (
preflight.count('cmd.env_remove("STUDIO_HOME")') >= 2
), "preflight.rs must scrub STUDIO_HOME in both run_cli_probe and probe_cli_capability"
), "preflight must scrub STUDIO_HOME in both run_cli_probe and probe_cli_capability"
assert (
'cmd.env_remove("UNSLOTH_STUDIO_HOME")' in commands
), "commands.rs check_install_status must scrub UNSLOTH_STUDIO_HOME"

View file

@ -1121,8 +1121,10 @@ def desktop_capabilities(
):
payload = {
"desktop_protocol_version": 1,
"desktop_manageability_version": 1,
"supports_provision_desktop_auth": True,
"supports_api_only": True,
"supports_desktop_backend_ownership": True,
"version": "unknown",
}
try: