* 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>
163 lines
5.3 KiB
Rust
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()),
|
|
}
|
|
}
|