diff --git a/apps/desktop/loopx-control-plane/README.md b/apps/desktop/loopx-control-plane/README.md index 7dc229897c..d2f07ab10b 100644 --- a/apps/desktop/loopx-control-plane/README.md +++ b/apps/desktop/loopx-control-plane/README.md @@ -38,9 +38,19 @@ source revision is newer. A provably newer runtime remains selected. Both local artifact's identity before the workspace opens. The bundled runtime carries its Core package version from the archived wheel -metadata. Main App build numbers (`0.0.0-main.…`) identify the native shell; -they are not compared with CLI package versions. Older bundle metadata retains -its previous shell-version fallback; this correction requires an App update. +metadata. Native validation and ordering use Python's [PEP 440 version format](https://packaging.python.org/en/latest/specifications/version-specifiers/), +including alpha, beta, release candidate, development, post-release and local +versions. The original version string remains in the identity; parsing does not +rewrite receipts or installation metadata. A stable package is newer than its +same-base prerelease, so even an App-owned or installer-default fallback cannot +downgrade it. Equal package versions still need source ancestry or the existing +same-installation ownership rule. Invalid versions remain unavailable. + +Main App build numbers (`0.0.0-main.…`) identify the native shell; they are not +compared with CLI package versions. Older bundle metadata retains its previous +shell-version fallback. Only those historical Main shell versions use their +release base for comparison; real Core prereleases keep their order. This +correction requires an App update; updating the CLI alone cannot patch it. If the bundle is newer, or no selected CLI qualifies, the App prepares its bundled snapshot in its own data directory through the existing installer. diff --git a/apps/desktop/loopx-control-plane/src-tauri/Cargo.lock b/apps/desktop/loopx-control-plane/src-tauri/Cargo.lock index e7f8863cbc..9be88a3d72 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/Cargo.lock +++ b/apps/desktop/loopx-control-plane/src-tauri/Cargo.lock @@ -2169,6 +2169,7 @@ dependencies = [ "minisign-verify", "objc2", "open", + "pep440_rs", "rustix", "serde_json", "sha2 0.11.0", @@ -2691,6 +2692,18 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "pep440_rs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31095ca1f396e3de32745f42b20deef7bc09077f918b085307e8eab6ddd8fb9c" +dependencies = [ + "once_cell", + "serde", + "unicode-width", + "unscanny", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -4618,6 +4631,18 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unscanny" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9df2af067a7953e9c3831320f35c1cc0600c30d44d9f7a12b01db1cd88d6b47" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/apps/desktop/loopx-control-plane/src-tauri/Cargo.toml b/apps/desktop/loopx-control-plane/src-tauri/Cargo.toml index 1cc53e29de..5eb605b73f 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/Cargo.toml +++ b/apps/desktop/loopx-control-plane/src-tauri/Cargo.toml @@ -20,6 +20,7 @@ tauri-build = { version = "2.5.6", features = [] } [dependencies] command-group = "5.0.1" open = "5.4.4" +pep440_rs = "0.7.3" serde_json = "1.0" tauri = { version = "2.11.1", features = [] } tauri-plugin-single-instance = "2.4.3" diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/runtime_selection.rs b/apps/desktop/loopx-control-plane/src-tauri/src/runtime_selection.rs index f1f28d020a..71c4928da1 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/runtime_selection.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/runtime_selection.rs @@ -111,6 +111,27 @@ fn parse_like(_: &T, text: &str) -> Option { text.parse().ok() } +// Core package metadata uses PEP 440, independently of Tauri's SemVer shell. +// Only historical Main shell versions have the old release-base comparison: +// they were a fallback when bundles did not carry a wheel package_version. +fn core_package_version( + package: &tauri::utils::PackageInfo, + text: &str, +) -> Option { + if let Ok(version) = text.parse::() { + return Some(version); + } + let legacy = parse_like(&package.version, text)?; + if legacy.pre.as_str().split('.').next() != Some("main") { + return None; + } + Some(pep440_rs::Version::new([ + legacy.major, + legacy.minor, + legacy.patch, + ])) +} + // The updater's Main build number is a shell version, not a Core version. // New bundles carry the wheel version from their exact archived source. Old // bundles retain their existing shell-version fallback for compatibility. @@ -121,7 +142,7 @@ pub(crate) fn bundled_runtime_candidate( let version = match bundled.get("package_version") { Some(value) => value .as_str() - .filter(|text| parse_like(&package.version, text).is_some()) + .filter(|text| core_package_version(package, text).is_some()) .ok_or("runtime_bundle_invalid")? .to_owned(), None => package.version.to_string(), @@ -134,14 +155,8 @@ fn compare_package_versions( installed: &Value, candidate: &Value, ) -> Option { - let mut left = parse_like(&package.version, installed["package_version"].as_str()?)?; - let mut right = parse_like(&package.version, candidate["package_version"].as_str()?)?; - // Core wheels carry the release base; main-channel shell prereleases do - // not make that same Core version older than the stable package string. - left.pre = Default::default(); - left.build = Default::default(); - right.pre = Default::default(); - right.build = Default::default(); + let left = core_package_version(package, installed["package_version"].as_str()?)?; + let right = core_package_version(package, candidate["package_version"].as_str()?)?; Some(left.cmp(&right)) } @@ -298,6 +313,128 @@ pub(crate) fn compare_official_commits(left: &str, right: &str) -> Option=2.0.0"), + json!("2.0.0min0"), + json!("2.0.0max0"), + ] { + assert_eq!( + bundled_runtime_candidate(&package, &json!({"package_version":invalid})) + .unwrap_err(), + "runtime_bundle_invalid" + ); + } + } + + #[test] + fn core_version_order_keeps_prereleases_before_source_ancestry() { + use std::cmp::Ordering; + let package = tauri::utils::PackageInfo { + name: "LoopX".into(), + version: "0.0.0-main.42.1".parse().unwrap(), + authors: "contributors", + description: "desktop", + crate_name: "desktop", + }; + let identity = |version: &str| { + json!({"package_version":version, + "source_revision":"same-source"}) + }; + for (older, newer) in [ + ("1.4.2", "2.0.0a1"), + ("2.0.0.dev2", "2.0.0a1"), + ("2.0.0a1", "2.0.0a2"), + ("2.0.0a10", "2.0.0b1"), + ("2.0.0b2", "2.0.0rc1"), + ("2.0.0rc1", "2.0.0"), + ("2.0.0", "2.0.0.post1"), + ("2.0.0a1+local.2", "2.0.0a1+local.10"), + ] { + for (left, right, expected) in [ + (older, newer, Ordering::Less), + (newer, older, Ordering::Greater), + ] { + assert_eq!( + compare_runtimes(&package, &identity(left), &identity(right), |_, _| panic!( + "different package versions need no ancestry lookup" + )), + Some(expected), + "{left} vs {right}" + ); + } + assert!(!prefer_current_bundle( + &package, + &identity(newer), + &identity(older), + true, + |_, _| panic!("same-owner fallback must not downgrade a newer package") + )); + assert!(!prefer_discovered_runtime( + &package, + &identity(newer), + &identity(older), + true, + |_, _| panic!("installer fallback must not downgrade a newer package") + )); + } + let current = json!({"package_version":"2.0.0a1", "source_revision":"current"}); + let candidate = json!({"package_version":"2.0.0-alpha.1", "source_revision":"candidate"}); + assert_eq!( + compare_runtimes(&package, ¤t, &candidate, |left, right| { + assert_eq!((left, right), ("current", "candidate")); + Some(Ordering::Greater) + }), + Some(Ordering::Greater) + ); + assert_eq!( + compare_runtimes(&package, ¤t, &candidate, |_, _| None), + None + ); + assert_eq!( + compare_runtimes(&package, &identity("invalid"), ¤t, |_, _| panic!( + "invalid version cannot authorize a source lookup" + )), + None + ); + } + #[test] fn main_shell_build_number_does_not_rank_the_bundled_core() { use std::cmp::Ordering;