diff --git a/apps/desktop/loopx-control-plane/README.md b/apps/desktop/loopx-control-plane/README.md index e119d3aacb..3a742ae6e2 100644 --- a/apps/desktop/loopx-control-plane/README.md +++ b/apps/desktop/loopx-control-plane/README.md @@ -37,6 +37,11 @@ unknown; this is maintenance of the App's own installation, not proof that one source revision is newer. A provably newer runtime remains selected. Both local HTTP services must expose the selected 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. + 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. This updates the CLI used by the App without replacing uv/pip/pipx's command or @@ -69,6 +74,9 @@ The release workflow packages both layers from one Git revision. The installer and App-owned services use the same bounded tool search, including standard Homebrew locations on macOS, without loading interactive shell profiles. Finder launches therefore do not depend on terminal PATH setup. +When preparing the first App-owned snapshot, an already selected managed CLI's +recorded Python is reused if the App has no interpreter record of its own. +This leaves that CLI's launcher and installation owner unchanged. Installation uses `loopx doctor --deep --installation-only`: package ownership, representative imports/commands/files and the TypeScript runtime semantic probe remain required, but existing Goal projects are not traversed. Native runtime diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/bundled_runtime.rs b/apps/desktop/loopx-control-plane/src-tauri/src/bundled_runtime.rs index baaa30bb08..aac7c44b7e 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/bundled_runtime.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/bundled_runtime.rs @@ -129,7 +129,10 @@ pub(crate) fn private_executable(app: &AppHandle) -> Result Result { +pub(crate) fn install_private( + app: &AppHandle, + selected_executable: &str, +) -> Result { let metadata = identity(app)?; let archive = app .path() @@ -142,11 +145,16 @@ pub(crate) fn install_private(app: &AppHandle) -> Result { .parent() .and_then(Path::parent) .ok_or("runtime_selection_unavailable")?; - install_snapshot(&bytes, &metadata, root)?; + install_snapshot(&bytes, &metadata, root, selected_executable)?; Ok(executable.to_string_lossy().into_owned()) } -fn install_snapshot(bytes: &[u8], metadata: &Value, private_root: &Path) -> Result<(), String> { +fn install_snapshot( + bytes: &[u8], + metadata: &Value, + private_root: &Path, + selected_executable: &str, +) -> Result<(), String> { let digest: String = Sha256::digest(bytes) .iter() .map(|byte| format!("{byte:02x}")) @@ -178,14 +186,19 @@ fn install_snapshot(bytes: &[u8], metadata: &Value, private_root: &Path) -> Resu .env("LOOPX_SHELL_PROFILE", root.join("shell-profile")) .env("LOOPX_MAN_ROOT", root.join("man")) .env("LOOPX_MAN_DIR", root.join("man/man1")); - let selected_executable = root.join("bin/loopx").to_string_lossy().into_owned(); - // Preserve the working interpreter of an existing managed snapshot. - if let Ok(executable) = fs::canonicalize(&selected_executable) { - if let Some(release) = executable.parent().and_then(Path::parent) { - if let Ok(python) = fs::read_to_string(release.join(".loopx-python")) { - command.env("LOOPX_PYTHON", python.trim()); - } - } + let private_executable = root.join("bin/loopx").to_string_lossy().into_owned(); + // Finder may see only the system Python even when the selected managed CLI + // already has a working interpreter. Reuse its installer's record for a + // first private install; an existing App-owned snapshot keeps precedence. + let managed_python = |executable: &str| { + let executable = fs::canonicalize(executable).ok()?; + let release = executable.parent()?.parent()?; + fs::read_to_string(release.join(".loopx-python")).ok() + }; + if let Some(python) = + managed_python(&private_executable).or_else(|| managed_python(selected_executable)) + { + command.env("LOOPX_PYTHON", python.trim()); } command .current_dir(source.path()) @@ -231,9 +244,12 @@ fn install_snapshot(bytes: &[u8], metadata: &Value, private_root: &Path) -> Resu Ok(Some(status)) => { return if status.success() { let installed = - crate::services::runtime_identity_for_executable(&selected_executable); + crate::services::runtime_identity_for_executable(&private_executable); if installed.as_ref().map(|v| &v["source_revision"]) == Some(&metadata["source_revision"]) + && metadata.get("package_version").is_none_or(|version| { + installed.as_ref().map(|v| &v["package_version"]) == Some(version) + }) { Ok(()) } else { @@ -304,6 +320,7 @@ mod tests { &fs::read(bundle.join("runtime-source.tar.gz")).unwrap(), &metadata, &root, + &default, ) .unwrap(); let observed = crate::services::runtime_identity_for_executable( @@ -311,6 +328,9 @@ mod tests { ) .unwrap(); assert_eq!(observed["source_revision"], metadata["source_revision"]); + if let Some(version) = metadata.get("package_version") { + assert_eq!(&observed["package_version"], version); + } let manifest: Value = serde_json::from_slice( &fs::read( fs::canonicalize(root.join("bin/loopx")) @@ -328,6 +348,18 @@ mod tests { manifest["source"]["git_ref"].is_null() && manifest["source"]["git_dirty"].is_null(), "a bundled archive has no Git checkout; host repository facts are not its provenance" ); + let mut wrong_version = metadata.clone(); + wrong_version["package_version"] = json!("0.0.0-mismatch"); + assert_eq!( + install_snapshot( + &fs::read(bundle.join("runtime-source.tar.gz")).unwrap(), + &wrong_version, + &root, + &default, + ) + .unwrap_err(), + "runtime_identity_mismatch" + ); assert_eq!( fs::read(default).unwrap(), original, diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs b/apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs index 1e0cbfe0ca..2a8d060bd8 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs @@ -420,10 +420,11 @@ async fn reinstall_bundled_runtime(app: &AppHandle) -> Result { } state.publish("installing_runtime", json!({})); let handle = app.clone(); - let executable = - tauri::async_runtime::spawn_blocking(move || bundled_runtime::install_private(&handle)) - .await - .map_err(|_| "runtime_install_failed".to_string())??; + let executable = tauri::async_runtime::spawn_blocking(move || { + bundled_runtime::install_private(&handle, &selection.executable) + }) + .await + .map_err(|_| "runtime_install_failed".to_string())??; crate::runtime_selection::remember( app, &crate::runtime_selection::Selection { @@ -685,7 +686,8 @@ fn resume_runtime(app: &AppHandle) -> Result(); let _guard = state.acquire()?; let bundled = bundled_runtime::identity(app)?; - let candidate = json!({"package_version":app.package_info().version.to_string(), "source_revision":bundled["source_revision"]}); + let candidate = + crate::runtime_selection::bundled_runtime_candidate(app.package_info(), &bundled)?; let private_executable = bundled_runtime::private_executable(app)?; // Only a launch-time developer override pins a runtime. Ordinary launches // reconcile discovery, a previous choice, and the App-owned installation. @@ -755,7 +757,10 @@ fn resume_runtime(app: &AppHandle) -> Result Result<(), String> { } } +fn parse_like(_: &T, text: &str) -> Option { + text.parse().ok() +} + +// 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. +pub(crate) fn bundled_runtime_candidate( + package: &tauri::utils::PackageInfo, + bundled: &Value, +) -> Result { + let version = match bundled.get("package_version") { + Some(value) => value + .as_str() + .filter(|text| parse_like(&package.version, text).is_some()) + .ok_or("runtime_bundle_invalid")? + .to_owned(), + None => package.version.to_string(), + }; + Ok(json!({"package_version": version, "source_revision": bundled["source_revision"]})) +} + fn compare_package_versions( package: &tauri::utils::PackageInfo, installed: &Value, candidate: &Value, ) -> Option { - fn parse_like(_: &T, text: &str) -> Option { - text.parse().ok() - } 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 @@ -279,6 +298,59 @@ pub(crate) fn compare_official_commits(left: &str, right: &str) -> Option None: @@ -109,6 +110,7 @@ def build(root: Path) -> None: identity = { "schema_version": "desktop_runtime_bundle_v1", "source_revision": revision, + "package_version": archive_package_version(archive), "sha256": hashlib.sha256(archive.read_bytes()).hexdigest(), } (destination / "identity.json").write_text(json.dumps(identity, indent=2) + "\n") @@ -117,6 +119,18 @@ def build(root: Path) -> None: ) +def archive_package_version(archive: Path) -> str: + """Use the archived wheel metadata, not the shell tag or working tree.""" + with tarfile.open(archive, "r:gz") as source: + metadata = source.extractfile("pyproject.toml") + if metadata is None: + raise RuntimeError("runtime archive has no package metadata") + version = tomllib.loads(metadata.read().decode("utf-8"))["project"]["version"] + if not isinstance(version, str) or not version.strip(): + raise RuntimeError("runtime archive has no package version") + return version + + def qualify_archive(archive: Path) -> int: """Fail the release build when the App-side extractor could never install it. diff --git a/tests/desktop/test_runtime_bundle.py b/tests/desktop/test_runtime_bundle.py index 86420c6a99..e494c84e8e 100644 --- a/tests/desktop/test_runtime_bundle.py +++ b/tests/desktop/test_runtime_bundle.py @@ -1,9 +1,11 @@ """Reject runtime snapshots the App-side installer could never unpack.""" import gzip +import io import importlib.util from pathlib import Path import tempfile import unittest +import tarfile spec = importlib.util.spec_from_file_location( "desktop_bundle", Path(__file__).resolve().parents[2] / "scripts/desktop_runtime_bundle.py" @@ -37,6 +39,29 @@ def write_archive(self, *blocks: bytes) -> Path: compressed.write(block) return archive + def test_core_version_comes_from_archived_wheel_metadata(self): + archive = self.root / "versioned.tar.gz" + content = b'[project]\nversion = "1.5.2"\n' + with tarfile.open(archive, "w:gz") as target: + member = tarfile.TarInfo("pyproject.toml") + member.size = len(content) + target.addfile(member, io.BytesIO(content)) + # Neither a newer working-tree version nor a Main shell tag owns this + # archive's package identity. + (self.root / "pyproject.toml").write_text('[project]\nversion = "1.6.0"\n') + self.assertEqual(bundle.archive_package_version(archive), "1.5.2") + + def test_invalid_archived_package_version_is_rejected(self): + for version in ["42", '""', '" "']: + archive = self.root / "invalid-version.tar.gz" + content = f"[project]\nversion = {version}\n".encode() + with tarfile.open(archive, "w:gz") as target: + member = tarfile.TarInfo("pyproject.toml") + member.size = len(content) + target.addfile(member, io.BytesIO(content)) + with self.assertRaises(RuntimeError): + bundle.archive_package_version(archive) + def test_files_and_directories_qualify(self): archive = self.write_archive( tar_header(b"loopx/", b"5"),