diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 66ade64a0..e8cc8fbd5 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -88,6 +88,9 @@ jobs: run: cargo test --manifest-path src-tauri/Cargo.toml env: NIXMAC_ENV: production + # Packaged builds hard-fail without the packaged source revision + # (build.rs embeds it as the build identity). + NIXMAC_BUILD_ID: ${{ github.sha }} build: needs: rust-tests runs-on: [self-hosted, macOS] @@ -170,6 +173,26 @@ jobs: run: cargo clippy --workspace --all-targets --features nixmac/codegen -- -D warnings env: NIXMAC_ENV: production + # Packaged builds hard-fail without the packaged source revision. + NIXMAC_BUILD_ID: ${{ github.sha }} + + # Same test set as the Linux `rust-tests` job, run again here because a + # large share of this crate's behavioral coverage is + # `cfg(target_os = "macos")` and compiles away to nothing on Linux — the + # privileged helper's socket serving, peer authentication, and launchd + # paths among it. Without this step CI can be green while none of it has + # ever executed. + # + # Placed with clippy: after the toolchain and passkey patch are in place, + # before the sccache setup and the expensive release build, so a failure + # is fast and cheap. `NIXMAC_ENV`/`NIXMAC_BUILD_ID` match the clippy and + # build steps — build.rs reruns on those, so a different value here would + # force a rebuild of everything that follows. + - name: Rust unit tests (macOS) + run: cargo test -p nixmac + env: + NIXMAC_ENV: production + NIXMAC_BUILD_ID: ${{ github.sha }} # Decide what kind of build this is: # - tag: push of refs/tags/v* → ship that exact version on stable @@ -252,6 +275,9 @@ jobs: # Pass DSNs and build metadata into the tauri action step so build.rs can read them SENTRY_DSN: ${{ secrets.SENTRY_DSN }} NIXMAC_ENV: production + # Build identity compiled into the GUI, helper, and sync agent; the + # sidecar build inherits it through the environment. + NIXMAC_BUILD_ID: ${{ github.sha }} NIXMAC_VERSION: ${{ steps.sync-version.outputs.build_version }} VITE_SERVER_URL: ${{ secrets.VITE_SERVER_URL }} SUBMITTED_FEEDBACK_DSN: ${{ secrets.SUBMITTED_FEEDBACK_DSN }} diff --git a/apps/native/scripts/build-tauri-sidecars.mjs b/apps/native/scripts/build-tauri-sidecars.mjs index c353d244c..07b26e683 100644 --- a/apps/native/scripts/build-tauri-sidecars.mjs +++ b/apps/native/scripts/build-tauri-sidecars.mjs @@ -16,6 +16,9 @@ const tauriConf = JSON.parse( await readFile(path.join(root, "src-tauri", "tauri.conf.json"), "utf8"), ); const minimumSystemVersion = tauriConf.bundle?.macOS?.minimumSystemVersion; +// execa extends process.env by default, so NIXMAC_ENV and NIXMAC_BUILD_ID +// reach build.rs unchanged: the helper and sync agent must compile in the same +// build identity as the GUI built from this environment. const cargoEnv = process.platform === "darwin" && minimumSystemVersion ? { MACOSX_DEPLOYMENT_TARGET: minimumSystemVersion } diff --git a/apps/native/src-tauri/Info.plist b/apps/native/src-tauri/Info.template.plist similarity index 100% rename from apps/native/src-tauri/Info.plist rename to apps/native/src-tauri/Info.template.plist diff --git a/apps/native/src-tauri/build.rs b/apps/native/src-tauri/build.rs index 4a6c338e2..f51ecdd12 100644 --- a/apps/native/src-tauri/build.rs +++ b/apps/native/src-tauri/build.rs @@ -3,6 +3,11 @@ mod env_keys { include!("src/env_keys.rs"); } +mod build_id { + #![allow(dead_code)] + include!("src/build_id.rs"); +} + use std::path::Path; use std::process::Command; @@ -88,6 +93,52 @@ fn embed_signing_team_id() { } } +/// Embed the build identity (`NIXMAC_BUILD_ID`, supplied by CI from the +/// packaged source revision) into every target of this crate — the GUI, the +/// helper, and the sync agent — and stamp the same string into the plist the +/// macOS bundler merges into the app's `Info.plist`. Packaged builds +/// (`NIXMAC_ENV` = `production`) hard-fail on a missing or empty value; +/// development builds fall back to a fixed literal. Git is deliberately never +/// run here: the value must describe the packaged source, which only the build +/// orchestrator knows. +/// +/// One resolution feeds both the compiled constant and the on-disk stamp, so a +/// GUI comparing itself against the bundle it was built from always matches. +fn embed_build_id() { + println!("cargo:rerun-if-env-changed=NIXMAC_BUILD_ID"); + println!("cargo:rerun-if-env-changed=NIXMAC_ENV"); + + let packaged = matches!(std::env::var("NIXMAC_ENV").as_deref(), Ok("production")); + let raw = std::env::var("NIXMAC_BUILD_ID").ok(); + let build_id = match build_id::resolve_build_id(raw.as_deref(), packaged) { + Ok(build_id) => build_id, + Err(error) => panic!("{error}"), + }; + println!("cargo:rustc-env=NIXMAC_BUILD_ID={build_id}"); + stamp_bundle_build_id(&build_id); +} + +/// Write the stamped copy of the tracked `Info.plist` that +/// `bundle > macOS > infoPlist` points at. +/// +/// The stamp has to live in the bundle rather than only in the executables: a +/// running GUI reads it to notice that its own bundle was replaced on disk. The +/// tracked template stays the source of every other key; this copy is generated +/// output. +fn stamp_bundle_build_id(build_id: &str) { + let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let template = crate_dir.join(build_id::INFO_PLIST_TEMPLATE_PATH); + let stamped = crate_dir.join(build_id::STAMPED_INFO_PLIST_PATH); + println!("cargo:rerun-if-changed={}", template.display()); + // Regenerate when the output is missing (a cleaned checkout): a bundle + // without the stamp reads as somebody else's build to every GUI. + println!("cargo:rerun-if-changed={}", stamped.display()); + + if let Err(error) = build_id::write_stamped_info_plist(&template, &stamped, build_id) { + panic!("{error}"); + } +} + fn add_debug_swift_runtime_rpaths() { if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("macos") || std::env::var("PROFILE").as_deref() != Ok("debug") @@ -127,6 +178,7 @@ fn add_debug_swift_runtime_rpaths() { fn main() { embed_build_profile(); embed_signing_team_id(); + embed_build_id(); add_debug_swift_runtime_rpaths(); // Set up passthrough for relevant environment variables. diff --git a/apps/native/src-tauri/src/build_id.rs b/apps/native/src-tauri/src/build_id.rs new file mode 100644 index 000000000..4446d1fea --- /dev/null +++ b/apps/native/src-tauri/src/build_id.rs @@ -0,0 +1,361 @@ +// Build identity, shared between `build.rs` (which resolves the +// `NIXMAC_BUILD_ID` build input and embeds the result) and the crate (whose +// tests pin the resolution table). +// +// The value exists only to identify a build. The GUI, the helper, and the sync +// agent of one build compile in the same string, and two builds match only +// when the strings are byte-equal. Packaged builds normally supply a Git +// commit, but nothing here — or on the wire — requires or validates Git +// syntax. + +/// Literal used when a development build (`NIXMAC_ENV` unset or anything but +/// `production`) supplies no build ID. Development rebuilds therefore share one +/// identity; developers swap helpers with the explicit Disable/Grant workflow. +pub const DEVELOPMENT_BUILD_ID: &str = "development"; + +/// Info.plist key carrying the build ID of the bundle **on disk**. +/// +/// A running GUI compares its own compiled build ID against this key read from +/// the bundle it would register, which is how it notices that its bundle was +/// replaced underneath it. The value is the same string that is compiled in — +/// one build, one identity — and is opaque: no syntax, no length rule. +pub const BUNDLE_BUILD_ID_KEY: &str = "NixmacBuildId"; + +/// Tracked plist whose keys the bundle's Info.plist inherits, relative to the +/// crate directory. Named away from `Info.plist` so the bundler does not also +/// merge it by convention: the stamped copy below is the one merged, and it +/// carries every key from here, so one file reaches the bundle rather than two +/// that could disagree. +pub const INFO_PLIST_TEMPLATE_PATH: &str = "Info.template.plist"; + +/// Where the build writes the stamped copy of [`INFO_PLIST_TEMPLATE_PATH`], +/// relative to the crate directory. Generated output, hence under the ignored +/// `gen/` directory, and it must stay equal to `bundle.macOS.infoPlist` in +/// `tauri.conf.json` — a test pins that, because a bundle whose Info.plist +/// carries no stamp would make every GUI consider itself displaced. +pub const STAMPED_INFO_PLIST_PATH: &str = "gen/Info.stamped.plist"; + +/// The `Info.plist` inside an installed `.app`. Fixed by macOS, unlike the +/// repository paths above, which are this project's choice. +pub const BUNDLE_INFO_PLIST_PATH: &str = "Contents/Info.plist"; + +/// Resolves the `NIXMAC_BUILD_ID` build input. +/// +/// A supplied identifier is taken byte-for-byte — no trimming, parsing, length +/// rule, or character validation — because equality is the only operation +/// performed on it anywhere. What is enforced is that no binary silently +/// embeds an empty ID: packaged builds (`NIXMAC_ENV` = `production`, not the +/// cargo profile, which is `release` even for local sidecar builds) fail when +/// the value is missing or empty, and development builds fall back to the one +/// fixed [`DEVELOPMENT_BUILD_ID`] literal. +pub fn resolve_build_id(raw: Option<&str>, packaged: bool) -> Result { + match raw.filter(|value| !value.is_empty()) { + Some(value) => Ok(value.to_string()), + None if packaged => Err( + "NIXMAC_BUILD_ID must be a non-empty value for packaged builds (NIXMAC_ENV=production); CI supplies it from the packaged source revision" + .to_string(), + ), + None => Ok(DEVELOPMENT_BUILD_ID.to_string()), + } +} + +/// Writes `template`'s keys plus the [`BUNDLE_BUILD_ID_KEY`] stamp to +/// `destination`. Called from `build.rs` with the same resolved build ID that +/// is compiled into the binaries, so the stamp and the compiled constant can +/// never disagree for one build. +/// +/// The file is left untouched when its contents would not change, so a rebuild +/// that resolves the same build ID does not perturb the bundler's inputs. +pub fn write_stamped_info_plist( + template: &std::path::Path, + destination: &std::path::Path, + build_id: &str, +) -> Result<(), String> { + let mut keys = match plist::Value::from_file(template) { + Ok(plist::Value::Dictionary(keys)) => keys, + Ok(_) => return Err(format!("{} is not a plist dictionary", template.display())), + Err(error) => return Err(format!("cannot read {}: {error}", template.display())), + }; + keys.insert( + BUNDLE_BUILD_ID_KEY.to_string(), + plist::Value::String(build_id.to_string()), + ); + + let mut stamped = Vec::new(); + plist::to_writer_xml(&mut stamped, &plist::Value::Dictionary(keys)) + .map_err(|error| format!("cannot serialize the stamped Info.plist: {error}"))?; + if std::fs::read(destination).is_ok_and(|current| current == stamped) { + return Ok(()); + } + if let Some(parent) = destination.parent() { + std::fs::create_dir_all(parent) + .map_err(|error| format!("cannot create {}: {error}", parent.display()))?; + } + std::fs::write(destination, &stamped) + .map_err(|error| format!("cannot write {}: {error}", destination.display())) +} + +/// Reads the build ID stamped into an installed bundle's `Info.plist`. +/// +/// The GUI's helper reconciliation compares this against +/// [`crate::privileged_helper::protocol::BUILD_ID`] before it mutates anything. +/// It lives beside the writer so the two can never disagree about the key or the +/// format. +/// +/// `bundle` is a `.app` directory. Every failure — no bundle, no plist, no +/// stamp, a stamp that is not a string, or an empty stamp — is an error the +/// caller reports as such. It must never be flattened into an empty string: +/// that would compare unequal to the compiled build ID forever and freeze +/// every decision that consults it. +pub fn read_bundle_build_id(bundle: &std::path::Path) -> Result { + read_stamped_build_id(&bundle.join(BUNDLE_INFO_PLIST_PATH)) +} + +/// [`read_bundle_build_id`] against an explicit plist path. +pub fn read_stamped_build_id(info_plist: &std::path::Path) -> Result { + let keys = match plist::Value::from_file(info_plist) { + Ok(plist::Value::Dictionary(keys)) => keys, + Ok(_) => { + return Err(format!( + "{} is not a plist dictionary", + info_plist.display() + )); + } + Err(error) => return Err(format!("cannot read {}: {error}", info_plist.display())), + }; + match keys.get(BUNDLE_BUILD_ID_KEY).map(plist::Value::as_string) { + Some(Some(stamp)) if !stamp.is_empty() => Ok(stamp.to_string()), + Some(Some(_)) => Err(format!( + "{} carries an empty {BUNDLE_BUILD_ID_KEY}", + info_plist.display() + )), + Some(None) => Err(format!( + "{} carries a non-string {BUNDLE_BUILD_ID_KEY}", + info_plist.display() + )), + None => Err(format!( + "{} carries no {BUNDLE_BUILD_ID_KEY}", + info_plist.display() + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_supplied_build_id_is_embedded_byte_for_byte() { + // The protocol only ever compares these strings for equality, so no + // shape is rejected: a Git commit, an abbreviation, uppercase, and a + // value that is not a commit at all are all embedded unchanged. + for supplied in [ + "0123456789abcdef0123456789abcdef01234567", + "0123456", + "0123456789ABCDEF0123456789ABCDEF01234567", + "not-a-commit", + " padded ", + "release-2026.07.31+1", + ] { + for packaged in [true, false] { + assert_eq!( + resolve_build_id(Some(supplied), packaged).unwrap(), + supplied, + "packaged: {packaged}" + ); + } + } + } + + #[test] + fn a_packaged_build_fails_without_a_build_id() { + // Never silently embed an empty identity into a shipped binary. + assert!(resolve_build_id(None, true).is_err()); + assert!(resolve_build_id(Some(""), true).is_err()); + } + + #[test] + fn a_development_build_falls_back_to_the_fixed_literal() { + assert_eq!(resolve_build_id(None, false).unwrap(), DEVELOPMENT_BUILD_ID); + assert_eq!( + resolve_build_id(Some(""), false).unwrap(), + DEVELOPMENT_BUILD_ID + ); + } + + #[test] + fn the_development_fallback_is_never_empty() { + // An empty build ID would compare unequal to every peer's, including + // another binary of the same build. + assert!(!DEVELOPMENT_BUILD_ID.is_empty()); + } + + fn crate_dir() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + } + + fn write_plist(path: &std::path::Path, keys: Vec<(&str, plist::Value)>) { + let dictionary: plist::Dictionary = keys + .into_iter() + .map(|(key, value)| (key.to_string(), value)) + .collect(); + plist::to_file_xml(path, &plist::Value::Dictionary(dictionary)).expect("write plist"); + } + + #[test] + fn this_build_stamped_its_own_compiled_build_id() { + // The whole point of the stamp: the value in the plist the bundler + // merges is the value the binaries compiled in. If these ever diverge, + // a running GUI reads the bundle it was built from as somebody else's + // and refuses every helper mutation forever. Test builds are + // development builds, so this also pins the fallback literal. + let stamped = read_stamped_build_id(&crate_dir().join(STAMPED_INFO_PLIST_PATH)) + .expect("the build stamped a plist"); + + assert_eq!(stamped, crate::privileged_helper::protocol::BUILD_ID); + } + + #[test] + fn the_stamped_plist_carries_every_template_key() { + // The bundler is pointed at the stamped copy, so anything the tracked + // template declares (the usage descriptions macOS shows in its access + // prompts) survives the copy on its own — without relying on the + // bundler also merging the template by convention. + let template = plist::Value::from_file(crate_dir().join(INFO_PLIST_TEMPLATE_PATH)) + .expect("read the tracked template") + .into_dictionary() + .expect("the template is a dictionary"); + let stamped = plist::Value::from_file(crate_dir().join(STAMPED_INFO_PLIST_PATH)) + .expect("read the stamped copy") + .into_dictionary() + .expect("the stamped copy is a dictionary"); + + for (key, value) in &template { + assert_eq!(stamped.get(key), Some(value), "template key {key}"); + } + assert!(stamped.contains_key(BUNDLE_BUILD_ID_KEY)); + } + + #[test] + fn no_unstamped_info_plist_sits_next_to_the_tauri_config() { + // A file with this exact name would be merged by convention, on top of + // nothing that stamps it — reintroducing a second, unstamped source of + // bundle keys. The template is named away from it for that reason. + assert!(!crate_dir().join("Info.plist").exists()); + } + + #[test] + fn the_bundler_is_pointed_at_the_stamped_plist() { + // The stamp only reaches the installed bundle through this config path; + // a rename on either side would silently ship an unstamped bundle. + let config: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(crate_dir().join("tauri.conf.json")).expect("read config"), + ) + .expect("parse config"); + + assert_eq!( + config + .pointer("/bundle/macOS/infoPlist") + .and_then(|value| value.as_str()), + Some(STAMPED_INFO_PLIST_PATH) + ); + } + + #[test] + fn stamping_preserves_the_build_id_byte_for_byte() { + // Opaque string: whatever the build supplies is what a later GUI reads + // back, XML metacharacters and all. + let directory = tempfile::tempdir().expect("temp dir"); + let template = directory.path().join("Info.plist"); + let stamped = directory.path().join("gen").join("Info.stamped.plist"); + write_plist( + &template, + vec![( + "NSDesktopFolderUsageDescription", + plist::Value::String("because".to_string()), + )], + ); + + for build_id in [ + "0123456789abcdef0123456789abcdef01234567", + DEVELOPMENT_BUILD_ID, + "release <2026> & \"quoted\"", + " padded ", + ] { + write_stamped_info_plist(&template, &stamped, build_id).expect("stamp"); + + assert_eq!(read_stamped_build_id(&stamped).as_deref(), Ok(build_id)); + } + } + + #[test] + fn restamping_the_same_build_id_leaves_the_file_alone() { + // The bundler and cargo both key off this file; rewriting it on every + // build would churn their inputs for nothing. + let directory = tempfile::tempdir().expect("temp dir"); + let template = directory.path().join("Info.plist"); + let stamped = directory.path().join("Info.stamped.plist"); + write_plist(&template, vec![("Key", plist::Value::String("v".into()))]); + + write_stamped_info_plist(&template, &stamped, "build-a").expect("stamp"); + let first = std::fs::metadata(&stamped).expect("metadata"); + write_stamped_info_plist(&template, &stamped, "build-a").expect("restamp"); + let second = std::fs::metadata(&stamped).expect("metadata"); + + assert_eq!( + first.modified().expect("mtime"), + second.modified().expect("mtime") + ); + } + + #[test] + fn a_bundle_stamp_is_read_from_the_bundles_own_info_plist() { + let directory = tempfile::tempdir().expect("temp dir"); + let bundle = directory.path().join("nixmac.app"); + std::fs::create_dir_all(bundle.join("Contents")).expect("bundle layout"); + write_plist( + &bundle.join("Contents").join("Info.plist"), + vec![( + BUNDLE_BUILD_ID_KEY, + plist::Value::String("build-on-disk".to_string()), + )], + ); + + assert_eq!( + read_bundle_build_id(&bundle).as_deref(), + Ok("build-on-disk") + ); + } + + #[test] + fn an_unreadable_stamp_is_an_error_and_never_an_empty_string() { + // Each of these has to reach the caller as a report ("restart the + // app", "this bundle is broken"), never as a build ID that happens to + // compare unequal to everything. + let directory = tempfile::tempdir().expect("temp dir"); + let missing_key = directory.path().join("missing-key.plist"); + write_plist( + &missing_key, + vec![("Other", plist::Value::String("v".into()))], + ); + let empty = directory.path().join("empty.plist"); + write_plist( + &empty, + vec![(BUNDLE_BUILD_ID_KEY, plist::Value::String(String::new()))], + ); + let wrong_type = directory.path().join("wrong-type.plist"); + write_plist( + &wrong_type, + vec![(BUNDLE_BUILD_ID_KEY, plist::Value::Integer(7.into()))], + ); + let not_a_dictionary = directory.path().join("not-a-dictionary.plist"); + plist::to_file_xml(¬_a_dictionary, &plist::Value::String("nope".into())) + .expect("write plist"); + + for path in [&missing_key, &empty, &wrong_type, ¬_a_dictionary] { + assert!(read_stamped_build_id(path).is_err(), "{}", path.display()); + } + assert!(read_stamped_build_id(&directory.path().join("absent.plist")).is_err()); + assert!(read_bundle_build_id(&directory.path().join("absent.app")).is_err()); + } +} diff --git a/apps/native/src-tauri/src/system/install_location.rs b/apps/native/src-tauri/src/system/install_location.rs index 7cb1e8074..9bcb27cf0 100644 --- a/apps/native/src-tauri/src/system/install_location.rs +++ b/apps/native/src-tauri/src/system/install_location.rs @@ -8,13 +8,35 @@ //! Applications folder"; this module lets the UI detect that condition //! proactively instead of relying on the user reading the instructions. //! +//! The privileged helper's reconciliation reads the same judgment for a +//! stricter purpose: a copy running from anywhere else mutates no helper at +//! all. That is why the bundle path is resolved before it is compared, rather +//! than compared as observed. +//! //! On non-macOS targets every call reports `NotRunningFromBundle`. -#[cfg(target_os = "macos")] -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use crate::shared_types::InstallLocationState; +/// Where `/Applications` is, and nowhere else it may be. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +const APPLICATIONS_DIR: &str = "/Applications"; + +/// Where this app runs from, judged against the one location that counts. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum InstallLocation { + /// A `.app` bundle whose real, symlink-resolved path sits directly in + /// `/Applications`. The path is the resolved one. + Canonical(PathBuf), + /// Anywhere else, including not running from a bundle at all: a disk image, + /// a download folder, a nested directory, a symlink into `/Applications` + /// pointing elsewhere, or a translocated copy (macOS runs quarantined apps + /// from a random read-only mount, whose path is never under + /// `/Applications`). The path is what was observed, when there was one. + Elsewhere(Option), +} + /// Walk up from the current executable to the enclosing `.app` bundle, if any. #[cfg(target_os = "macos")] fn current_app_bundle() -> Option { @@ -24,41 +46,60 @@ fn current_app_bundle() -> Option { .map(std::path::Path::to_path_buf) } -/// Canonicalize both sides so symlinks (e.g. `/Applications` itself, or a -/// trailing `/` in a copied path) don't cause a false mismatch. -#[cfg(target_os = "macos")] -fn same_path(a: &std::path::Path, b: &std::path::Path) -> bool { - let canon = |p: &std::path::Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()); - canon(a) == canon(b) -} - -/// Inspect the running app's install location. -pub fn check_install_location() -> InstallLocationState { +/// Where the running app is installed. +pub fn locate_app_bundle() -> InstallLocation { #[cfg(target_os = "macos")] { - let Some(bundle) = current_app_bundle() else { - return InstallLocationState { - in_applications_dir: false, - bundle_path: None, - }; - }; - - let in_applications_dir = bundle - .parent() - .is_some_and(|parent| same_path(parent, std::path::Path::new("/Applications"))); - - InstallLocationState { - in_applications_dir, - bundle_path: Some(bundle.to_string_lossy().into_owned()), + match current_app_bundle() { + Some(bundle) => classify_bundle(&bundle, Path::new(APPLICATIONS_DIR)), + None => InstallLocation::Elsewhere(None), } } #[cfg(not(target_os = "macos"))] { - InstallLocationState { + InstallLocation::Elsewhere(None) + } +} + +/// Judges one bundle path against one applications directory. +/// +/// The **bundle itself** is resolved first, not just its parent: a symlink at +/// `/Applications/nixmac.app` pointing into a home directory has `/Applications` +/// for a parent while the code that actually runs lives somewhere else +/// entirely, and the location this answers about is where the code is. Both +/// sides are resolved so the comparison survives `/Applications` itself being a +/// symlink and a trailing slash on either path. +/// +/// A path that cannot be resolved is not canonical: this is the gate in front of +/// every helper mutation, so an unanswerable question is answered no. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn classify_bundle(bundle: &Path, applications: &Path) -> InstallLocation { + let observed = || InstallLocation::Elsewhere(Some(bundle.to_path_buf())); + let (Ok(resolved), Ok(applications)) = ( + std::fs::canonicalize(bundle), + std::fs::canonicalize(applications), + ) else { + return observed(); + }; + if resolved.parent() == Some(applications.as_path()) { + InstallLocation::Canonical(resolved) + } else { + observed() + } +} + +/// Inspect the running app's install location. +pub fn check_install_location() -> InstallLocationState { + match locate_app_bundle() { + InstallLocation::Canonical(bundle) => InstallLocationState { + in_applications_dir: true, + bundle_path: Some(bundle.to_string_lossy().into_owned()), + }, + InstallLocation::Elsewhere(bundle) => InstallLocationState { in_applications_dir: false, - bundle_path: None, - } + bundle_path: bundle.map(|bundle| bundle.to_string_lossy().into_owned()), + }, } } @@ -78,13 +119,82 @@ mod tests { } } - #[cfg(target_os = "macos")] + /// A real directory named like a bundle, since the judgment resolves paths + /// and a fake one would not resolve. + fn bundle_in(parent: &Path) -> PathBuf { + let bundle = parent.join("nixmac.app"); + std::fs::create_dir_all(&bundle).expect("bundle"); + bundle + } + + #[test] + fn a_bundle_directly_in_the_applications_directory_is_canonical() { + let directory = tempfile::tempdir().expect("temp dir"); + let applications = directory.path().join("Applications"); + let bundle = bundle_in(&applications); + + assert_eq!( + classify_bundle(&bundle, &applications), + InstallLocation::Canonical( + std::fs::canonicalize(&bundle).expect("the bundle resolves") + ) + ); + } + #[test] - fn same_path_handles_missing_canonicalization() { - // `/Applications` exists on macOS CI runners; canonicalize both sides - // and confirm the helper agrees. If /Applications is absent (very - // unlikely), this still exercises the fallback branch. - let a = std::path::Path::new("/Applications"); - assert!(same_path(a, a)); + fn a_symlink_in_the_applications_directory_is_not_canonical() { + // The case a parent-only comparison passes: `/Applications/nixmac.app` + // is the parent's child, but the code behind it runs from elsewhere, so + // it is not installed in `/Applications` — and under the helper's + // reconciliation it may mutate nothing. + let directory = tempfile::tempdir().expect("temp dir"); + let applications = directory.path().join("Applications"); + std::fs::create_dir_all(&applications).expect("applications"); + let elsewhere = bundle_in(&directory.path().join("Downloads")); + let link = applications.join("nixmac.app"); + std::os::unix::fs::symlink(&elsewhere, &link).expect("symlink"); + + assert_eq!( + classify_bundle(&link, &applications), + InstallLocation::Elsewhere(Some(link)) + ); + } + + #[test] + fn a_bundle_anywhere_else_is_not_canonical() { + // A disk image, a download folder, a nested copy, a translocated + // read-only mount: all of them differ from the applications directory + // in exactly this way, and the report carries the path the user can be + // told about. + let directory = tempfile::tempdir().expect("temp dir"); + let applications = directory.path().join("Applications"); + std::fs::create_dir_all(&applications).expect("applications"); + + for parent in [ + directory.path().join("Volumes").join("nixmac"), + directory.path().join("Downloads"), + applications.join("Utilities"), + ] { + let bundle = bundle_in(&parent); + + assert_eq!( + classify_bundle(&bundle, &applications), + InstallLocation::Elsewhere(Some(bundle)) + ); + } + } + + #[test] + fn a_path_that_cannot_be_resolved_is_not_canonical() { + // The gate in front of every helper mutation: a question that cannot be + // answered is answered no. + let directory = tempfile::tempdir().expect("temp dir"); + let applications = directory.path().join("Applications"); + let absent = applications.join("nixmac.app"); + + assert_eq!( + classify_bundle(&absent, &applications), + InstallLocation::Elsewhere(Some(absent)) + ); } } diff --git a/apps/native/src-tauri/tauri.conf.json b/apps/native/src-tauri/tauri.conf.json index a3545f0f8..e3659a38f 100644 --- a/apps/native/src-tauri/tauri.conf.json +++ b/apps/native/src-tauri/tauri.conf.json @@ -31,7 +31,7 @@ "macOS": { "minimumSystemVersion": "10.13", "entitlements": "entitlements.plist", - "infoPlist": "Info.plist", + "infoPlist": "gen/Info.stamped.plist", "frameworks": [], "files": { "Library/LaunchDaemons/com.darkmatter.nixmac.helper.plist": "resources/launchd/com.darkmatter.nixmac.helper.plist"