Files
OneCAD/crates/io/src/legacy_dir.rs
T
bherbruckandClaude Opus 5.5 e67cc0c747 Linux app id: ai.storyteller.solidcraft, with an end-of-life rebase from the old id (#619)
The Flatpak app id, desktop/metainfo/MIME file names, icon name and the window's Wayland app_id / X11 class are now ai.storyteller.solidcraft, the id the ArtCraft launcher expects.

Existing Flatpak installs move over: the Pages repo marks app/ai.storyteller.solvecraft end-of-life with a rebase to the new id, so flatpak update offers the switch and Flatpak carries ~/.var/app along. A new install next to the old one copies settings and autosaves on first launch through a read-only grant on the old data folder. The metainfo declares <replaces>/<provides>, the desktop entry X-Flatpak-RenamedFrom, and AppImages drop the menu entry an older AppImage installed. deb/rpm upgrades remove the old-named files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 05:20:58 -04:00

130 lines
5.7 KiB
Rust

//! First-launch adoption of a pre-rename settings or data folder. Compatibility shim (see
//! docs/compatibility.md): when the current folder doesn't exist yet and the legacy one does,
//! the legacy folder is copied over. The legacy folder is never changed or deleted, so an older
//! release installed side by side keeps working.
use std::path::{Path, PathBuf};
/// The Linux app id (Flatpak, desktop entry, AppStream, Wayland `app_id`).
pub const APP_ID: &str = "ai.storyteller.solidcraft";
/// The app id of releases before 0.8 (compatibility shim, docs/compatibility.md).
pub const LEGACY_APP_ID: &str = "ai.storyteller.solvecraft";
/// Most files and nesting copied from a legacy folder (it holds preferences, autosaves and
/// small caches; anything bigger is not ours).
const MAX_ENTRIES: usize = 10_000;
const MAX_DEPTH: usize = 8;
/// Copy `legacy` to `current` if `current` doesn't exist and `legacy` is a folder. Returns
/// whether a copy was made. Symbolic links are skipped. A copy that fails part way leaves what
/// was copied (the next launch sees `current` and doesn't try again) and reports the error.
pub fn adopt_legacy_dir(current: &Path, legacy: &Path) -> std::io::Result<bool> {
if current.exists() || !legacy.is_dir() || current == legacy {
return Ok(false);
}
let mut budget = MAX_ENTRIES;
copy_tree(legacy, current, 0, &mut budget)?;
Ok(true)
}
/// In the Flatpak sandbox, the same `path` under the pre-rename app's data folder: Flatpak keeps
/// each app's XDG folders under `~/.var/app/<app id>`, so after the app id changed, settings and
/// autosaves of earlier Flatpak releases sit under the old id. `None` outside the sandbox or for
/// paths outside our own `~/.var/app` folder. Flatpak moves that folder itself when an install
/// follows the end-of-life rebase; this covers installs made next to the old app, which reach it
/// read-only through the manifest's `--filesystem` grant.
pub fn legacy_flatpak_path(path: &Path) -> Option<PathBuf> {
flatpak_twin(path, &std::env::var_os("FLATPAK_ID")?, Path::new(&std::env::var_os("HOME")?))
}
fn flatpak_twin(path: &Path, flatpak_id: &std::ffi::OsStr, home: &Path) -> Option<PathBuf> {
if flatpak_id != APP_ID {
return None;
}
let apps = home.join(".var").join("app");
let rel = path.strip_prefix(apps.join(APP_ID)).ok()?;
Some(apps.join(LEGACY_APP_ID).join(rel))
}
fn copy_tree(from: &Path, to: &Path, depth: usize, budget: &mut usize) -> std::io::Result<()> {
std::fs::create_dir_all(to)?;
if depth >= MAX_DEPTH {
return Ok(());
}
for entry in std::fs::read_dir(from)? {
let entry = entry?;
if *budget == 0 {
return Ok(());
}
*budget -= 1;
let kind = entry.file_type()?;
let target = to.join(entry.file_name());
if kind.is_dir() {
copy_tree(&entry.path(), &target, depth + 1, budget)?;
} else if kind.is_file() {
std::fs::copy(entry.path(), &target)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("solidcraft-legacy-dir-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn copies_legacy_folder_once_and_keeps_it() {
let d = scratch("copy");
let old = d.join("solvecraft");
std::fs::create_dir_all(old.join("recovery")).unwrap();
std::fs::write(old.join("graphics.json"), "{\"msaa\":8}").unwrap();
std::fs::write(old.join("recovery").join("a.solvecraft"), "x").unwrap();
let new = d.join("solidcraft");
assert!(adopt_legacy_dir(&new, &old).unwrap());
assert_eq!(std::fs::read_to_string(new.join("graphics.json")).unwrap(), "{\"msaa\":8}");
assert!(new.join("recovery").join("a.solvecraft").is_file());
// The legacy folder is untouched.
assert!(old.join("graphics.json").is_file() && old.join("recovery").join("a.solvecraft").is_file());
// Once the current folder exists, nothing is copied again.
std::fs::write(old.join("later.json"), "{}").unwrap();
assert!(!adopt_legacy_dir(&new, &old).unwrap());
assert!(!new.join("later.json").exists());
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn flatpak_twin_maps_our_sandbox_data_to_the_old_app_id() {
let home = Path::new("/home/u");
let ours = home.join(".var/app/ai.storyteller.solidcraft/data/solidcraft");
let id = std::ffi::OsStr::new(APP_ID);
assert_eq!(flatpak_twin(&ours, id, home), Some(home.join(".var/app/ai.storyteller.solvecraft/data/solidcraft")));
// Outside our sandbox folder, or in another app's sandbox, there is nothing to map.
assert_eq!(flatpak_twin(&home.join(".local/share/solidcraft"), id, home), None);
assert_eq!(flatpak_twin(&ours, std::ffi::OsStr::new("org.example.Other"), home), None);
}
#[test]
fn nothing_to_adopt() {
let d = scratch("none");
let new = d.join("solidcraft");
assert!(!adopt_legacy_dir(&new, &d.join("missing")).unwrap());
assert!(!new.exists());
// An existing current folder is never overwritten.
let old = d.join("solvecraft");
std::fs::create_dir_all(&old).unwrap();
std::fs::write(old.join("p.json"), "old").unwrap();
std::fs::create_dir_all(&new).unwrap();
std::fs::write(new.join("p.json"), "new").unwrap();
assert!(!adopt_legacy_dir(&new, &old).unwrap());
assert_eq!(std::fs::read_to_string(new.join("p.json")).unwrap(), "new");
let _ = std::fs::remove_dir_all(&d);
}
}