From 0a54d001ec0f6d65cc766480c76588cd8370f5a0 Mon Sep 17 00:00:00 2001 From: Wasim Yousef Said Date: Wed, 13 May 2026 05:30:20 +0200 Subject: [PATCH] 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 Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .github/workflows/release-desktop.yml | 693 +++++++++- studio/backend/main.py | 22 + studio/backend/run.py | 75 +- studio/frontend/src/app/provider.tsx | 17 +- .../src/components/tauri/update-banner.tsx | 45 +- .../frontend/src/hooks/use-tauri-backend.ts | 100 +- studio/frontend/src/hooks/use-tauri-update.ts | 132 +- studio/frontend/src/lib/api-base.ts | 14 +- studio/src-tauri/linux/postremove.sh | 2 +- studio/src-tauri/src/commands.rs | 395 ++++-- studio/src-tauri/src/desktop_auth.rs | 163 +-- studio/src-tauri/src/desktop_backend_owner.rs | 1018 +++++++++++++++ studio/src-tauri/src/desktop_update_policy.rs | 420 ++++++ studio/src-tauri/src/diagnostics/mod.rs | 16 +- studio/src-tauri/src/diagnostics/report.rs | 2 + studio/src-tauri/src/diagnostics/state.rs | 24 + studio/src-tauri/src/main.rs | 4 + studio/src-tauri/src/preflight.rs | 1122 ++++++++--------- studio/src-tauri/src/preflight/backend.rs | 336 +++++ studio/src-tauri/src/preflight/managed.rs | 169 +++ studio/src-tauri/src/preflight/types.rs | 44 + studio/src-tauri/src/preflight/version.rs | 163 +++ studio/src-tauri/src/process.rs | 1018 ++++++++++++--- studio/src-tauri/tauri.conf.json | 5 +- tests/test_studio_install_workspace_guard.py | 22 +- unsloth_cli/commands/studio.py | 2 + 26 files changed, 4917 insertions(+), 1106 deletions(-) create mode 100644 studio/src-tauri/src/desktop_backend_owner.rs create mode 100644 studio/src-tauri/src/desktop_update_policy.rs create mode 100644 studio/src-tauri/src/preflight/backend.rs create mode 100644 studio/src-tauri/src/preflight/managed.rs create mode 100644 studio/src-tauri/src/preflight/types.rs create mode 100644 studio/src-tauri/src/preflight/version.rs diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index ea82739968..13afacc2a8 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -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 diff --git a/studio/backend/main.py b/studio/backend/main.py index 650a488212..2fba2756e7 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.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 {}), } diff --git a/studio/backend/run.py b/studio/backend/run.py index 1dd1230a17..dfd4b7453e 100644 --- a/studio/backend/run.py +++ b/studio/backend/run.py @@ -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( diff --git a/studio/frontend/src/app/provider.tsx b/studio/frontend/src/app/provider.tsx index fe3173aa41..8360186d1e 100644 --- a/studio/frontend/src/app/provider.tsx +++ b/studio/frontend/src/app/provider.tsx @@ -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 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; diff --git a/studio/frontend/src/components/tauri/update-banner.tsx b/studio/frontend/src/components/tauri/update-banner.tsx index 79fbae6bb7..62038f92a9 100644 --- a/studio/frontend/src/components/tauri/update-banner.tsx +++ b/studio/frontend/src/components/tauri/update-banner.tsx @@ -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; @@ -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]" >
- {/* Close button */} - {/* Header */}
🦥
@@ -88,37 +99,39 @@ export function UpdateBanner({

{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"}

- {/* Retained failure */} {showFailure && lastFailure && (

{lastFailure.error}

)} - {/* Actions */}
{showFailure ? ( <> - - ) : ( <> - - @@ -132,7 +145,7 @@ export function UpdateBanner({ )} {manualReport && (