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:
parent
040b80a60e
commit
0a54d001ec
26 changed files with 4877 additions and 1066 deletions
693
.github/workflows/release-desktop.yml
vendored
693
.github/workflows/release-desktop.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {}),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
1018
studio/src-tauri/src/desktop_backend_owner.rs
Normal file
1018
studio/src-tauri/src/desktop_backend_owner.rs
Normal file
File diff suppressed because it is too large
Load diff
420
studio/src-tauri/src/desktop_update_policy.rs
Normal file
420
studio/src-tauri/src/desktop_update_policy.rs
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
336
studio/src-tauri/src/preflight/backend.rs
Normal file
336
studio/src-tauri/src/preflight/backend.rs
Normal 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)
|
||||
}
|
||||
169
studio/src-tauri/src/preflight/managed.rs
Normal file
169
studio/src-tauri/src/preflight/managed.rs
Normal 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 { .. })
|
||||
}
|
||||
44
studio/src-tauri/src/preflight/types.rs
Normal file
44
studio/src-tauri/src/preflight/types.rs
Normal 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 },
|
||||
}
|
||||
163
studio/src-tauri/src/preflight/version.rs
Normal file
163
studio/src-tauri/src/preflight/version.rs
Normal 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
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue