unsloth/studio/src-tauri/src/preflight/version.rs
Wasim Yousef Said 0a54d001ec
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>
2026-05-12 20:30:20 -07:00

163 lines
5.3 KiB
Rust

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()),
}
}