update: a package manager's copy and another app's bundle do not self-update on macOS and Windows

self_update::detect fell back to Install::Standalone for any executable it
could not place, so on macOS a Homebrew, MacPorts or Nix copy, or a program
inside another app's bundle, was replaced in place, and on Windows so was a
scoop, winget, Chocolatey or Store copy. The package manager's record then
stays on the old version, and the bundle belongs to another app.

macOS now treats executables under /opt/homebrew, /usr/local/Cellar,
/usr/local/Homebrew, /usr/local/opt, /opt/local and /nix as owned by a package
manager, and any executable inside an .app bundle that is not this app's own
as not ours; none of them self-update. Windows does the same for scoop, winget,
Chocolatey and WindowsApps folders. The rules are plain path functions, so
they are tested on every platform.

Tested: three new tests failed before the change (the macOS and Windows folder
rules, and detect on an executable in another app's bundle) and pass after it.
cargo test -p craft-update (209 tests), clippy -D warnings and fmt pass on
macOS; the Windows branch was not compiled here.

Fixes #390 for macOS and Windows; the Linux side is #388.
This commit is contained in:
marshiyar committed 2026-10-30 20:02:37 -07:00
1 parent 2b8bd856ce
commit 61bde471fd
1 file changed
+92
+92
View File
@@ -176,6 +176,24 @@ pub fn is_self_updating(app: &App) -> bool {
current(app).is_some()
}
/// Whether `exe` sits in a folder a macOS package manager owns (Homebrew, MacPorts, Nix).
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn macos_package_manager_owns(exe: &Path) -> bool {
// Homebrew (both prefixes), MacPorts and the Nix store; component-wise, so `/opt/homebrewed` is not one
["/opt/homebrew", "/usr/local/Cellar", "/usr/local/Homebrew", "/usr/local/opt", "/opt/local", "/nix"].iter().any(|root| exe.starts_with(root))
}
/// Whether `exe` sits in a folder a Windows package manager owns (scoop, winget, Chocolatey, the
/// Store).
#[cfg_attr(not(windows), allow(dead_code))]
fn windows_package_manager_owns(exe: &Path) -> bool {
// Compared as text so the rule reads the same wherever it is tested: either separator, any case.
let text = exe.to_string_lossy().to_lowercase();
let parts: Vec<&str> = text.split(['\\', '/']).collect();
parts.contains(&"windowsapps")
|| parts.windows(2).any(|w| matches!((w[0], w[1]), ("scoop", "apps" | "shims") | ("winget", "packages") | ("chocolatey", "lib" | "bin")))
}
#[cfg(target_os = "macos")]
fn detect(app: &App, exe: &Path) -> Option<Install> {
if let Some(bundle) = exe.ancestors().find(|p| p.extension().is_some_and(|e| e == "app"))
@@ -185,6 +203,11 @@ fn detect(app: &App, exe: &Path) -> Option<Install> {
{
return Some(Install::MacApp(bundle.to_path_buf()));
}
// A package manager's copy is its to update (replacing it in place leaves its record stale), and
// a program inside another app's bundle is that app's file.
if macos_package_manager_owns(exe) || exe.ancestors().any(|p| p.extension().is_some_and(|e| e == "app")) {
return None;
}
if exe.is_file() {
return Some(Install::Standalone(exe.to_path_buf()));
}
@@ -204,6 +227,10 @@ fn detect(app: &App, exe: &Path) -> Option<Install> {
if crate::msi_copy::windows::find(app, exe).is_some() {
return Some(Install::Msi(exe.to_path_buf()));
}
// scoop, winget, Chocolatey and the Store own their copies; an in-place replace leaves their record stale
if windows_package_manager_owns(exe) {
return None;
}
if exe.is_file() {
return Some(Install::Standalone(exe.to_path_buf()));
}
@@ -896,6 +923,71 @@ fn run_step(app: &App, reference: &Path, pid: u32, exe: &Path, step: &Finish, wa
mod tests {
use super::*;
#[test]
fn macos_package_manager_folders_are_recognised() {
for owned in [
"/opt/homebrew/bin/artcraft-launcher",
"/opt/homebrew/Cellar/artcraft/1.0/bin/artcraft-launcher",
"/usr/local/Cellar/artcraft/1.0/bin/artcraft-launcher",
"/usr/local/Homebrew/bin/artcraft-launcher",
"/usr/local/opt/artcraft/bin/artcraft-launcher",
"/opt/local/bin/artcraft-launcher",
"/nix/store/abc123-artcraft/bin/artcraft-launcher",
] {
assert!(macos_package_manager_owns(Path::new(owned)), "{owned}");
}
for loose in [
"/Users/ada/bin/artcraft-launcher",
"/usr/local/bin/artcraft-launcher",
"/Users/ada/opt/homebrew/bin/artcraft-launcher",
"/opt/homebrewed/artcraft-launcher",
"/Applications/Tools/artcraft-launcher",
] {
assert!(!macos_package_manager_owns(Path::new(loose)), "{loose}");
}
}
#[test]
fn windows_package_manager_folders_are_recognised() {
for owned in [
r"C:\Users\Ada\scoop\apps\artcraft-launcher\current\artcraft-launcher.exe",
r"C:\Users\Ada\scoop\shims\artcraft-launcher.exe",
r"C:\ProgramData\scoop\apps\artcraft-launcher\current\artcraft-launcher.exe",
r"C:\Users\Ada\AppData\Local\Microsoft\WinGet\Packages\Storytold.ArtCraftLauncher_x\artcraft-launcher.exe",
r"C:\ProgramData\chocolatey\lib\artcraft-launcher\tools\artcraft-launcher.exe",
r"C:\Program Files\WindowsApps\Storytold.ArtCraftLauncher_1.0_x64\artcraft-launcher.exe",
] {
assert!(windows_package_manager_owns(Path::new(owned)), "{owned}");
}
for loose in [
r"C:\Tools\ArtCraft Launcher\artcraft-launcher.exe",
r"D:\myscoopy\apps\artcraft-launcher.exe",
r"C:\Users\Ada\Downloads\artcraft-launcher.exe",
] {
assert!(!windows_package_manager_owns(Path::new(loose)), "{loose}");
}
}
/// A program inside an `.app` bundle that is not this app's own (a different bundle id) is
/// another app's file, not a loose copy of ours to replace.
#[cfg(target_os = "macos")]
#[test]
fn an_executable_in_another_apps_bundle_is_not_self_updating() {
let dir = tempfile::tempdir().unwrap();
let macos = dir.path().join("Other.app/Contents/MacOS");
std::fs::create_dir_all(&macos).unwrap();
let mut info = plist::Dictionary::new();
info.insert("CFBundleIdentifier".into(), plist::Value::String("com.example.other".into()));
plist::Value::Dictionary(info).to_file_xml(dir.path().join("Other.app/Contents/Info.plist")).unwrap();
let exe = macos.join("other");
std::fs::write(&exe, b"binary").unwrap();
assert_eq!(detect(&crate::suite::LAUNCHER, &exe), None);
// a loose copy outside any bundle is still a standalone one
let loose = dir.path().join("artcraft-launcher");
std::fs::write(&loose, b"binary").unwrap();
assert_eq!(detect(&crate::suite::LAUNCHER, &loose), Some(Install::Standalone(loose.clone())));
}
#[cfg(unix)]
#[test]
fn msi_self_update_uses_the_target_of_a_symlinked_download() {