From a44228ce3ba47bc4ed38536ea2b3fba8a7c8a57e Mon Sep 17 00:00:00 2001 From: draplater <6349758+draplater@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:02:15 +0800 Subject: [PATCH] app: skip macOS startup from Trash --- app/cmd/app/app_darwin.go | 2 + app/cmd/app/trash_darwin.go | 48 +++++++++++++ app/cmd/app/trash_darwin_test.go | 117 +++++++++++++++++++++++++++++++ 3 files changed, 167 insertions(+) create mode 100644 app/cmd/app/trash_darwin.go create mode 100644 app/cmd/app/trash_darwin_test.go diff --git a/app/cmd/app/app_darwin.go b/app/cmd/app/app_darwin.go index 07cbace8b..1548509e9 100644 --- a/app/cmd/app/app_darwin.go +++ b/app/cmd/app/app_darwin.go @@ -181,6 +181,8 @@ func darwinStartHiddenTasks() { } func init() { + exitIfRunningFromTrash() + // Temporary code to mimic Squirrel ShipIt behavior if len(os.Args) > 2 { if os.Args[1] == "___launch___" { diff --git a/app/cmd/app/trash_darwin.go b/app/cmd/app/trash_darwin.go new file mode 100644 index 000000000..fd1475eac --- /dev/null +++ b/app/cmd/app/trash_darwin.go @@ -0,0 +1,48 @@ +package main + +/* +#cgo CFLAGS: -x objective-c +#cgo LDFLAGS: -framework Foundation +#include +#include + +static bool isInTrash(const char *path) { + @autoreleasepool { + NSURL *url = [[NSURL fileURLWithPath:@(path)] URLByResolvingSymlinksInPath]; + NSURLRelationship relationship; + return [[NSFileManager defaultManager] getRelationship:&relationship + ofDirectory:NSTrashDirectory + inDomain:0 + toItemAtURL:url + error:nil] + && relationship == NSURLRelationshipContains; + } +} +*/ +import "C" + +import ( + "log/slog" + "os" + "unsafe" +) + +func isInTrash(path string) bool { + if path == "" { + return false + } + p := C.CString(path) + defer C.free(unsafe.Pointer(p)) + return bool(C.isInTrash(p)) +} + +func exitIfRunningFromTrash() { + // Use the executable's actual location, not updater.BundlePath, which may + // resolve to an installed copy. This also covers the background helper and + // the legacy ShipIt entry point, before either can launch another process. + executable, err := os.Executable() + if err == nil && isInTrash(executable) { + slog.Info("not starting Ollama from Trash", "path", executable) + os.Exit(0) + } +} diff --git a/app/cmd/app/trash_darwin_test.go b/app/cmd/app/trash_darwin_test.go new file mode 100644 index 000000000..16fe9a54e --- /dev/null +++ b/app/cmd/app/trash_darwin_test.go @@ -0,0 +1,117 @@ +package main + +import ( + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func testTrashDirectory(t *testing.T) string { + t.Helper() + home, err := os.UserHomeDir() + if err != nil { + t.Fatal(err) + } + dir, err := os.MkdirTemp(filepath.Join(home, ".Trash"), "ollama-startup-test-") + if err != nil { + t.Skipf("cannot create a temporary directory in Trash: %v", err) + } + t.Cleanup(func() { os.RemoveAll(dir) }) + return dir +} + +func TestIsInTrash(t *testing.T) { + trash := testTrashDirectory(t) + normal := t.TempDir() + for _, tc := range []struct { + name string + path string + want bool + }{ + {"trashed bundle", filepath.Join(trash, "Ollama.app"), true}, + {"nested backup", filepath.Join(trash, "uninstalled", "Ollama.app"), true}, + {"ordinary bundle", filepath.Join(normal, "Ollama.app"), false}, + {"unrelated Trash name", filepath.Join(normal, "Trash", "Ollama.app"), false}, + {"unrelated dot Trash name", filepath.Join(normal, ".Trash", "Ollama.app"), false}, + } { + t.Run(tc.name, func(t *testing.T) { + if err := os.MkdirAll(tc.path, 0o700); err != nil { + t.Fatal(err) + } + if got := isInTrash(tc.path); got != tc.want { + t.Fatalf("isInTrash(%q) = %v, want %v", tc.path, got, tc.want) + } + }) + } + link := filepath.Join(normal, "linked-app") + if err := os.Symlink(filepath.Join(trash, "Ollama.app"), link); err != nil { + t.Fatal(err) + } + if !isInTrash(link) { + t.Fatal("symlink to a trashed bundle was not recognized") + } + if isInTrash("") { + t.Fatal("empty path is in Trash") + } +} + +func TestTrashStartupHelper(t *testing.T) { + if os.Getenv("OLLAMA_TEST_TRASH_STARTUP") != "1" { + return + } + exitIfRunningFromTrash() + os.Exit(42) // Startup was allowed to continue. +} + +func TestTrashedExecutableDoesNotStart(t *testing.T) { + trash := testTrashDirectory(t) + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + for _, path := range []string{ + "Ollama.app/Contents/MacOS/Ollama", + "backup/Ollama.app/Contents/Frameworks/Squirrel.framework/Versions/A/Squirrel", + } { + t.Run(path, func(t *testing.T) { + destination := filepath.Join(trash, path) + if err := os.MkdirAll(filepath.Dir(destination), 0o700); err != nil { + t.Fatal(err) + } + src, err := os.Open(executable) + if err != nil { + t.Fatal(err) + } + defer src.Close() + dst, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o700) + if err != nil { + t.Fatal(err) + } + _, copyErr := io.Copy(dst, src) + closeErr := dst.Close() + if copyErr != nil || closeErr != nil { + t.Fatalf("copy test executable: %v, %v", copyErr, closeErr) + } + run := func(path string) ([]byte, error) { + cmd := exec.Command(path, "-test.run=^TestTrashStartupHelper$") + cmd.Env = append(os.Environ(), "OLLAMA_TEST_TRASH_STARTUP=1") + return cmd.CombinedOutput() + } + output, err := run(destination) + if err != nil || !strings.Contains(string(output), "not starting Ollama from Trash") { + t.Fatalf("trashed executable did not exit cleanly: %v\n%s", err, output) + } + restored := filepath.Join(t.TempDir(), "Ollama") + if err := os.Rename(destination, restored); err != nil { + t.Fatal(err) + } + output, err = run(restored) + if exit, ok := err.(*exec.ExitError); !ok || exit.ExitCode() != 42 { + t.Fatalf("restored executable was not allowed to continue: %v\n%s", err, output) + } + }) + } +}