From 774bc395eb45ee0eb4d0d09c80f5c4de8d9b50fa Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 1 Nov 2026 22:49:10 -0500 Subject: [PATCH] feat: gate bundle to OCI migration behind experimental feature --- README.md | 27 ++++++++++++ apps/artcraft-launcher/Cargo.toml | 2 + crates/native/Cargo.toml | 2 + crates/native/src/backend.rs | 10 +++++ crates/native/src/install.rs | 68 +++++++++++++++++++++++++++++++ crates/native/src/packages.rs | 32 +++++++++++++++ 6 files changed, 141 insertions(+) diff --git a/README.md b/README.md index abf0a4c8..abede633 100644 --- a/README.md +++ b/README.md @@ -345,3 +345,30 @@ Forks and modified versions must remove them. ArtCraft
Made by the ArtCraft team and community.

+ +## Experimental OCI migration + +The `experimental-oci-migration` Cargo feature is disabled by default and only acts on Linux. +Enable it when building to test migrating existing first party Flatpak bundles to the +ArtCraft OCI remote. For the launcher: + +```sh +cargo run -p artcraft-launcher --features experimental-oci-migration +``` + +On an update, a first party installation with a bundle-created `-origin` remote may be +reinstalled from the OCI repository in the same installation scope, using the same full +Flatpak ref. Existing installations managed by another repository are not redirected. +The migration reuses a remote with URL `oci+https://getartcraft.com`, or adds `artcraft-oci` +using `https://getartcraft.com/artcraft-oci.flatpakrepo`. A conflicting remote name is an error. +The current descriptor is a preview with `GPGVerify=false`; enabling this feature explicitly +opts into that repository configuration. The launcher does not uninstall the app or remove +its data. Subsequent updates use the detected OCI remote. + +If the exact ref is absent, the existing verified bundle update proceeds. A repository error, +cancellation, or missing/different repository version stops the update; migration requires +an exact match to the selected release version. Launcher pinned/nightly tracks and explicit +replacement operations do not trigger migration. The feature does not change new installs. + +Test with an isolated Flatpak installation before enabling this in release builds. The +feature is not enabled by the packaging defaults. diff --git a/apps/artcraft-launcher/Cargo.toml b/apps/artcraft-launcher/Cargo.toml index 208c7122..685bb062 100644 --- a/apps/artcraft-launcher/Cargo.toml +++ b/apps/artcraft-launcher/Cargo.toml @@ -13,6 +13,8 @@ publish = false workspace = true [features] +# Opt-in migration of first party Flatpak bundles to the ArtCraft OCI remote. +experimental-oci-migration = ["artcraft-launcher-native/experimental-oci-migration"] # Development only: install from `cargo xtask local serve` (see the README). local-mirror = ["artcraft-launcher-native/local-mirror"] diff --git a/crates/native/Cargo.toml b/crates/native/Cargo.toml index 404b2967..b04c8b09 100644 --- a/crates/native/Cargo.toml +++ b/crates/native/Cargo.toml @@ -11,6 +11,8 @@ publish = false workspace = true [features] +# Opt-in migration of first party Flatpak bundles to the ArtCraft OCI remote. +experimental-oci-migration = [] # Lets ARTCRAFT_LAUNCHER_DOWNLOAD_BASE point the launcher at a developer's own loopback mirror # (`cargo xtask local serve`). Development only: release builds never enable it, so a shipped # launcher can't be steered away from GitHub's releases by an environment variable. diff --git a/crates/native/src/backend.rs b/crates/native/src/backend.rs index 03eb9aab..fca8e3b7 100644 --- a/crates/native/src/backend.rs +++ b/crates/native/src/backend.rs @@ -566,6 +566,16 @@ fn run_install( if !release_matches_track(&release, track, options.prereleases) { return Err("the selected version isn't available yet. Check for updates before installing.".into()); } + #[cfg(all(target_os = "linux", feature = "experimental-oci-migration"))] + if matches!(track, VersionTrack::Stable) + && !release.prerelease + && !replace + && let Some(installed) = existing + && let Some(expected) = release.version.as_ref() + && install::linux::migrate_bundle_to_oci(app, installed, expected, stop)? + { + return Ok(format!("{} now updates through the ArtCraft OCI repository", app.name)); + } let (asset, kind) = planned_asset(app, &release, platform, options, existing, record).ok_or_else(|| { if platform.os == artcraft_launcher_core::Os::Linux && options.install_locations.folder(app.id).is_some() diff --git a/crates/native/src/install.rs b/crates/native/src/install.rs index 56c17711..49af6a8c 100644 --- a/crates/native/src/install.rs +++ b/crates/native/src/install.rs @@ -1226,6 +1226,74 @@ pub(crate) mod linux { } } + /// Experimental in-place migration; only an absent ref permits bundle fallback. + #[cfg(feature = "experimental-oci-migration")] + pub fn migrate_bundle_to_oci(app: &CraftApp, installed: &Installed, expected: &super::version::Version, stop: &Stop) -> Result { + if installed.source != InstallSource::Flatpak || installed.externally_managed || !app.repo.starts_with("storytold/") { + return Ok(false); + } + // Only bundle-created origins qualify; never redirect another repository's install. + if !installed.origin.as_deref().is_some_and(|origin| origin.ends_with("-origin")) { + return Ok(false); + } + let id = installed.package.as_deref().ok_or("Flatpak installation has no package ID")?; + let scope = match installed.installation.as_deref() { + Some("user") => "--user".to_string(), + Some("system") => "--system".to_string(), + Some(name) => format!("--installation={name}"), + None => return Err("Flatpak installation scope is unknown".into()), + }; + let run = |args: &[&str]| -> Result { + let mut cmd = if process::in_sandbox() { + let mut cmd = process::command("flatpak-spawn"); + cmd.args(["--host", "--watch-bus", "flatpak"]); + cmd + } else { + process::command("flatpak") + }; + cmd.arg(&scope).args(args); + let output = process::cancellable_output(cmd, || stop.check())?; + if !output.status.success() { + return Err(format!("Flatpak OCI migration failed: {}", String::from_utf8_lossy(&output.stderr).trim())); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) + }; + let reference = run(&["info", "--show-ref", id])?; + let reference = reference.trim(); + if !reference.starts_with(&format!("app/{id}/")) || !reference.ends_with("/stable") { + return Ok(false); + } + let remotes = run(&["remotes", "--columns=name,url"])?; + let remote = remotes.lines().find_map(|line| { + let (name, _) = line.split_once('\t')?; + crate::packages::is_artcraft_remote(name, line).then(|| name.to_string()) + }); + let remote = match remote { + Some(remote) => remote, + None => { + if remotes.lines().any(|line| line.split('\t').next() == Some("artcraft-oci")) { + return Err("The artcraft-oci remote points to a different repository".into()); + } + run(&["remote-add", "--noninteractive", "artcraft-oci", "https://getartcraft.com/artcraft-oci.flatpakrepo"])?; + let remotes = run(&["remotes", "--columns=name,url"])?; + if !crate::packages::is_artcraft_remote("artcraft-oci", &remotes) { + return Err("The ArtCraft OCI descriptor configured an unexpected repository".into()); + } + "artcraft-oci".to_string() + } + }; + let refs = run(&["remote-ls", "--app", "--columns=ref,version", &remote])?; + if !crate::packages::oci_ref_available(reference, expected, &refs)? { + return Ok(false); + } + run(&["install", "--reinstall", "-y", "--noninteractive", &remote, reference])?; + let origin = run(&["info", "--show-origin", id])?; + if origin.trim() != remote { + return Err("Flatpak did not switch the installed app to the ArtCraft OCI remote".into()); + } + Ok(true) + } + /// Updates an installed Flatpak from its remote repository (such as FlatPark). pub fn update_flatpak_app( app: &CraftApp, diff --git a/crates/native/src/packages.rs b/crates/native/src/packages.rs index 22a72b11..e50d87e5 100644 --- a/crates/native/src/packages.rs +++ b/crates/native/src/packages.rs @@ -273,3 +273,35 @@ mod remote_url_tests { assert!(!is_artcraft_remote("other", "custom\toci+https://getartcraft.com")); } } + +/// A missing ref permits bundle fallback; an unknown or different version must not replace it. +#[cfg(all(target_os = "linux", feature = "experimental-oci-migration"))] +pub(crate) fn oci_ref_available(reference: &str, expected: &version::Version, listing: &str) -> Result { + for line in listing.lines() { + let mut fields = line.split('\t'); + if fields.next().map(str::trim) != Some(reference) { + continue; + } + let candidate = fields.next().and_then(|value| version::parse(value.trim())); + if candidate.as_ref() != Some(expected) { + return Err("The ArtCraft OCI repository does not yet provide the selected version".into()); + } + return Ok(true); + } + Ok(false) +} + +#[cfg(all(test, target_os = "linux", feature = "experimental-oci-migration"))] +mod migration_tests { + use super::*; + #[test] + fn migration_requires_the_exact_ref_and_selected_version() { + let reference = "app/ai.storyteller.photocraft/x86_64/stable"; + let expected = version::parse("1.2.3").unwrap(); + assert_eq!(oci_ref_available(reference, &expected, &format!("{reference}\t1.2.3")), Ok(true)); + assert_eq!(oci_ref_available(reference, &expected, "app/ai.storyteller.photocraft/aarch64/stable\t1.2.3"), Ok(false)); + for value in ["", "1.2.2", "1.2.4", "unknown"] { + assert!(oci_ref_available(reference, &expected, &format!("{reference}\t{value}")).is_err()); + } + } +}