collective

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

diff --git a/crates/native/src/install.rs b/crates/native/src/install.rs--- a/crates/native/src/install.rs+++ b/crates/native/src/install.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();diff --git a/crates/native/src/lib.rs b/crates/native/src/lib.rs--- a/crates/native/src/lib.rs+++ b/crates/native/src/lib.rs@@ -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)]diff --git a/crates/native/src/nix.rs b/crates/native/src/nix.rsnew file mode 100644--- /dev/null+++ b/crates/native/src/nix.rs@@ -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());+    }+}diff --git a/crates/native/src/self_update.rs b/crates/native/src/self_update.rs--- a/crates/native/src/self_update.rs+++ b/crates/native/src/self_update.rs@@ -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) }