* 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>
169 lines
5.3 KiB
Rust
169 lines
5.3 KiB
Rust
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 { .. })
|
|
}
|