feat: gate bundle to OCI migration behind experimental feature

This commit is contained in:
Dylan M. Taylor committed 2026-11-02 17:37:32 -05:00
1 parent 3e09878c6b
commit 774bc395eb
6 files changed
+141

No files matched your search

+27
View File
@@ -345,3 +345,30 @@ Forks and modified versions must remove them.
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
</p>
## 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.
+2
View File
@@ -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"]
+2
View File
@@ -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.
+10
View File
@@ -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()
+68
View File
@@ -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<bool, String> {
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<String, String> {
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,
+32
View File
@@ -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<bool, String> {
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());
}
}
}