Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/desktop/loopx-control-plane/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
56 changes: 44 additions & 12 deletions apps/desktop/loopx-control-plane/src-tauri/src/bundled_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,10 @@ pub(crate) fn private_executable(app: &AppHandle) -> Result<std::path::PathBuf,
.join("loopx"))
}

pub(crate) fn install_private(app: &AppHandle) -> Result<String, String> {
pub(crate) fn install_private(
app: &AppHandle,
selected_executable: &str,
) -> Result<String, String> {
let metadata = identity(app)?;
let archive = app
.path()
Expand All @@ -142,11 +145,16 @@ pub(crate) fn install_private(app: &AppHandle) -> Result<String, String> {
.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}"))
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -304,13 +320,17 @@ mod tests {
&fs::read(bundle.join("runtime-source.tar.gz")).unwrap(),
&metadata,
&root,
&default,
)
.unwrap();
let observed = crate::services::runtime_identity_for_executable(
root.join("bin/loopx").to_str().unwrap(),
)
.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"))
Expand All @@ -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,
Expand Down
17 changes: 11 additions & 6 deletions apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -420,10 +420,11 @@ async fn reinstall_bundled_runtime(app: &AppHandle) -> Result<Value, String> {
}
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 {
Expand Down Expand Up @@ -685,7 +686,8 @@ fn resume_runtime(app: &AppHandle) -> Result<crate::services::SelectedRuntime, S
let state = app.state::<Maintenance>();
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.
Expand Down Expand Up @@ -755,7 +757,10 @@ fn resume_runtime(app: &AppHandle) -> Result<crate::services::SelectedRuntime, S
if newer_bundle && !selection.environment_override {
let mut prepared = None;
let result = state.prepare_runtime(Instant::now(), || {
prepared = Some(bundled_runtime::install_private(app)?);
prepared = Some(bundled_runtime::install_private(
app,
&selection.executable,
)?);
Ok(())
});
match result {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,14 +107,33 @@ pub(crate) fn forget(app: &AppHandle) -> Result<(), String> {
}
}

fn parse_like<T: std::str::FromStr>(_: &T, text: &str) -> Option<T> {
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<Value, String> {
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<std::cmp::Ordering> {
fn parse_like<T: std::str::FromStr>(_: &T, text: &str) -> Option<T> {
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
Expand Down Expand Up @@ -279,6 +298,59 @@ pub(crate) fn compare_official_commits(left: &str, right: &str) -> Option<std::c
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn main_shell_build_number_does_not_rank_the_bundled_core() {
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 installed = json!({"package_version":"1.4.0", "source_revision":"installed"});
let bundled = json!({"package_version":"1.4.0", "source_revision":"bundle"});
let candidate = bundled_runtime_candidate(&package, &bundled).unwrap();
for (relation, expected) in [
(Some(Ordering::Less), true),
(Some(Ordering::Greater), false),
(Some(Ordering::Equal), false),
(None, false),
] {
assert_eq!(
prefer_current_bundle(&package, &installed, &candidate, false, |left, right| {
assert_eq!((left, right), ("installed", "bundle"));
relation
}),
expected,
"source ancestry owns equal Core versions, even with a 0.0.0 shell"
);
}
for (version, expected) in [("1.3.0", false), ("1.5.0", true)] {
let candidate = bundled_runtime_candidate(
&package,
&json!({"package_version":version, "source_revision":"bundle"}),
)
.unwrap();
assert_eq!(
prefer_current_bundle(&package, &installed, &candidate, false, |_, _| panic!(
"unequal Core versions need no ancestry lookup"
)),
expected
);
}
for invalid in [json!(null), json!(42), json!("invalid"), json!("")] {
assert_eq!(
bundled_runtime_candidate(&package, &json!({"package_version":invalid}))
.unwrap_err(),
"runtime_bundle_invalid"
);
}
let legacy =
bundled_runtime_candidate(&package, &json!({"source_revision":"legacy"})).unwrap();
assert_eq!(legacy["package_version"], "0.0.0-main.42.1");
}

#[test]
fn newer_runtime_uses_versions_then_ancestry_and_never_install_time() {
use std::cmp::Ordering;
Expand Down
14 changes: 14 additions & 0 deletions scripts/desktop_runtime_bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import tarfile
import io
import importlib.util
import tomllib


def build(root: Path) -> None:
Expand Down Expand Up @@ -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")
Expand All @@ -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.

Expand Down
25 changes: 25 additions & 0 deletions tests/desktop/test_runtime_bundle.py
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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"),
Expand Down
Loading