Browse / github.com/storytold/craft-launcher / patch
Keep Nix-managed bundles and launcher updates under Nix control
Keep Nix-managed bundles and launcher updates under Nix control A Craft app installed by Nix can appear in the macOS Applications folder through a symlink. ArtCraft Launcher recognizes the bundle, but currently treats it like a manually installed copy that it may replace or remove. The same bundle detection can offer self-update for a Nix-installed launcher. This keeps those copies under Nix's control. Before updating or removing an app, the backend resolves its location and refuses changes to an output in the conventional `/nix/store`. A launcher running from that store does not offer self-update. Opening an app and reading its installed version keep the
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- IanHollow (imported from pull request #459, open)
- Base
307efcf45a080af9643e750d43034eb25540cef8- Size
- +130 −0 in 4 files
- Review
- pending
- Shared
- 2026-10-28
- Id
b70ca9be9a58cfeb3c7a7e54
How it went
No other operator has reported using it yet.
Diff
raw · crates/native/src/install.rs crates/native/src/lib.rs crates/native/src/nix.rs crates/native/src/self_update.rs
@@ -73,6 +73,9 @@ pub fn install_at( let mut commit = commit; #[cfg(not(windows))] let _ = commit;+ if let Some(message) = existing.and_then(|installed| managed_elsewhere(app, installed, false)) {+ return Err(message);+ } #[allow(unused_variables)] let destination = destination(app, kind, existing, previous, job.install_locations)?; if let Some(inst) = existing@@ -330,6 +333,11 @@ fn published_checksum(release: &Release, asset: &Asset, http: &Http) -> Result<O /// package, but updated by the user's AUR helper, which builds as the user and can't run behind /// the password prompt. pub fn managed_elsewhere(app: &CraftApp, installed: &Installed, uninstall: bool) -> Option<String> {+ // Applications-folder links and launcher records do not transfer ownership of a Nix output.+ #[cfg(unix)]+ if crate::nix::is_managed(Path::new(&installed.location)) {+ return Some(format!("{} was installed outside the launcher. Update or remove it the way it was installed.", app.name));+ } match installed.source { InstallSource::Pacman if !uninstall => { let pkg = installed.package.clone().unwrap_or_else(|| app.id.to_string());@@ -1404,6 +1412,36 @@ pub mod windows { mod tests { use super::*; + #[cfg(unix)]+ #[test]+ fn nix_store_copies_refuse_reinstall_and_removal_before_starting_work() {+ let app = CraftApp::by_id("photocraft").unwrap();+ let dir = tempfile::tempdir().unwrap();+ let paths = Paths::under(dir.path());+ let http = Http::new().unwrap();+ let stop = Stop::default();+ let mut phases = Vec::new();+ for source in [InstallSource::MacApp, InstallSource::Launcher, InstallSource::Binary] {+ let installed =+ Installed { version: None, source, location: "/nix/store/hash-photocraft/Applications/PhotoCraft.app".into(), package: None };+ let message = managed_elsewhere(app, &installed, false).unwrap();+ assert_eq!(managed_elsewhere(app, &installed, true).unwrap(), message);+ let mut phase = |phase| phases.push(phase);+ let locations = InstallLocations::default();+ let mut job = Job { http: &http, paths: &paths, stop: &stop, phase: &mut phase, install_locations: &locations, replace: false };+ // The deliberately empty release and asset would fail validation if any download+ // work ran. Ownership must take precedence over either validation or installation.+ let result = install(app, &Release::default(), &Asset::default(), PackageKind::Dmg, Some(&installed), None, &mut job);+ assert_eq!(result.unwrap_err(), message);+ let result = install_at(app, &Release::default(), &Asset::default(), PackageKind::Dmg, Some(&installed), None, &mut job, None);+ assert_eq!(result.unwrap_err(), message);+ assert_eq!(uninstall(app, &installed, None, &paths, &stop, &mut phase).unwrap_err(), message);+ assert_eq!(installed.source, source);+ }+ assert!(phases.is_empty());+ assert!(!paths.downloads().exists());+ }+ #[test] fn selected_folders_must_exist_and_leave_no_probe_files() { let dir = tempfile::tempdir().unwrap();@@ -15,6 +15,8 @@ pub mod http; pub mod install; pub mod login; pub mod manifest;+#[cfg(unix)]+mod nix; pub mod packages; pub mod paths; #[cfg(windows)]new file mode 100644@@ -0,0 +1,86 @@+//! Ownership of outputs in Nix's conventional immutable store.+//!+//! This does not change application discovery, PATH precedence, or package-manager metadata.+//! Custom store roots cannot be inferred from a path and are not covered here.++use std::path::Path;++pub(crate) fn is_managed(path: &Path) -> bool {+ resolved_in_store(path, Path::new("/nix/store"))+}++fn resolved_in_store(path: &Path, store: &Path) -> bool {+ if !path.is_absolute() {+ return false;+ }+ // Resolve profile and Applications-folder links before checking ownership. Keep direct+ // store paths protected even when the output has disappeared since the detection pass.+ let resolved = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());+ output_path(&resolved, store)+}++fn output_path(path: &Path, store: &Path) -> bool {+ path.strip_prefix(store).is_ok_and(|relative| {+ let mut components = relative.components();+ components.next().is_some_and(|first| matches!(first, std::path::Component::Normal(_)))+ && components.all(|component| matches!(component, std::path::Component::Normal(_)))+ })+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn store_outputs_and_their_contents_are_managed() {+ for path in+ ["/nix/store/hash-photocraft", "/nix/store/hash-photocraft/bin/photocraft", "/nix/store/hash-photocraft/Applications/PhotoCraft.app"]+ {+ assert!(is_managed(Path::new(path)), "{path}");+ }+ for path in [+ "/nix/store",+ "/nix/storefront/hash/bin/app",+ "/tmp/nix/store/hash/bin/app",+ "/nix/store/missing/../../tmp/app",+ "ai.storyteller.photocraft",+ "nix/store/hash/bin/app",+ ] {+ assert!(!is_managed(Path::new(path)), "{path}");+ }+ }++ #[test]+ fn profile_and_bundle_links_keep_external_ownership() {+ use std::os::unix::fs::{PermissionsExt, symlink};++ let root = tempfile::tempdir().unwrap();+ let root = root.path().canonicalize().unwrap();+ let store = root.join("store");+ let bundle = store.join("hash-photocraft/Applications/PhotoCraft.app");+ let executable = bundle.join("Contents/MacOS/PhotoCraft");+ std::fs::create_dir_all(executable.parent().unwrap()).unwrap();+ std::fs::write(&executable, b"#!/bin/sh\nexit 0\n").unwrap();+ std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();+ let profile = root.join("photocraft");+ let applications = root.join("PhotoCraft.app");+ symlink(&executable, &profile).unwrap();+ symlink(&bundle, &applications).unwrap();+ assert!(resolved_in_store(&profile, &store));+ assert!(resolved_in_store(&applications, &store));+ assert!(resolved_in_store(&executable, &store));+ assert!(!resolved_in_store(&root, &store));+ let manual = root.join("Manual.app");+ std::fs::create_dir(&manual).unwrap();+ let manual_link = root.join("Manual link.app");+ symlink(&manual, &manual_link).unwrap();+ assert!(!resolved_in_store(&manual_link, &store));+ let external_executable = root.join("external-app");+ std::fs::write(&external_executable, b"#!/bin/sh\nexit 0\n").unwrap();+ let external_link = root.join("external-link");+ symlink(&external_executable, &external_link).unwrap();+ assert!(!resolved_in_store(&external_link, &store));+ assert!(profile.is_file());+ assert!(applications.is_dir());+ }+}@@ -89,6 +89,10 @@ fn marker(paths: &Paths) -> PathBuf { pub fn current() -> Option<Install> { let exe = std::env::current_exe().ok()?; let exe = exe.canonicalize().unwrap_or(exe);+ #[cfg(unix)]+ if crate::nix::is_managed(&exe) {+ return None;+ } detect(&exe) }