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.

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());
+ }
+ }
+}