app: synchronize macOS app handoff (#18056)

This commit is contained in:
Parth Sareen
2026-08-26 23:15:54 -07:00
committed by GitHub
parent d366f4868a
commit 39f7f91563
6 changed files with 581 additions and 52 deletions
+3 -1
View File
@@ -175,7 +175,9 @@ func main() {
// Check if another instance is already running
// On Windows, focus the existing instance; on other platforms, kill it
handleExistingInstance(startHidden)
if !handleExistingInstance(startHidden) {
return
}
// on macOS, offer the user to create a symlink
// from /usr/local/bin/ollama to the app bundle
+265 -10
View File
@@ -26,6 +26,7 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"unsafe"
@@ -39,6 +40,7 @@ import (
"github.com/ollama/ollama/envconfig"
"github.com/ollama/ollama/internal/modelref"
"github.com/ollama/ollama/internal/proxy"
"golang.org/x/sys/unix"
)
var ollamaPath = func() string {
@@ -213,9 +215,259 @@ func maybeMoveAndRestart() appMove {
return status
}
// handleExistingInstance handles existing instances on macOS
func handleExistingInstance(_ bool) {
C.killOtherInstances()
type appProcessIdentity struct {
pid int
startedAt int64
}
func (p appProcessIdentity) sameProcess(other appProcessIdentity) bool {
return p.pid == other.pid && p.startedAt == other.startedAt
}
func (p appProcessIdentity) startedAfter(other appProcessIdentity) bool {
if p.startedAt != other.startedAt {
return p.startedAt > other.startedAt
}
return p.pid > other.pid
}
type appProcessStopMode uint8
const (
appProcessStopForHandoff appProcessStopMode = iota
appProcessStopGracefully
appProcessStopForcefully
)
type appProcessController struct {
discover func() ([]appProcessIdentity, error)
running func(appProcessIdentity) (bool, error)
stop func(appProcessIdentity, appProcessStopMode) error
}
type appSyncBarrierConfig struct {
handoffTimeout time.Duration
terminateTimeout time.Duration
killTimeout time.Duration
pollInterval time.Duration
settlePeriod time.Duration
}
// runAppSyncBarrier elects the newest launch, stops every older instance, and
// returns only after no other instances remain.
func runAppSyncBarrier(self appProcessIdentity, controller appProcessController, config appSyncBarrierConfig) error {
started := time.Now()
handoffDeadline := started.Add(config.handoffTimeout)
terminateDeadline := handoffDeadline.Add(config.terminateTimeout)
deadline := terminateDeadline.Add(config.killTimeout)
sawEmpty := false
for {
processes, err := controller.discover()
if err != nil {
return err
}
// Overlapping launches are ordered by process age. Only the newest
// candidate may terminate existing instances.
for _, process := range processes {
if process.startedAfter(self) {
return fmt.Errorf("%w: pid %d", errNewerAppInstance, process.pid)
}
}
// Require two consecutive empty snapshots so a process that is still
// appearing in NSWorkspace cannot slip through the barrier.
if len(processes) == 0 {
if sawEmpty {
return nil
}
sawEmpty = true
if !time.Now().Before(deadline) {
return fmt.Errorf("timed out waiting for app instances to exit")
}
time.Sleep(config.settlePeriod)
continue
}
sawEmpty = false
for _, process := range processes {
if err := controller.stop(process, appProcessStopForHandoff); err != nil {
return err
}
}
exited, err := waitForAppProcesses(processes, controller, handoffDeadline, config.pollInterval)
if err != nil {
return err
}
if !exited {
for _, process := range processes {
if err := controller.stop(process, appProcessStopGracefully); err != nil {
return err
}
}
exited, err = waitForAppProcesses(processes, controller, terminateDeadline, config.pollInterval)
if err != nil {
return err
}
}
if !exited {
// Graceful shutdown owns most of the deadline. Force only exact
// surviving identities so one stuck instance cannot block update.
for _, process := range processes {
if err := controller.stop(process, appProcessStopForcefully); err != nil {
return err
}
}
exited, err = waitForAppProcesses(processes, controller, deadline, config.pollInterval)
if err != nil {
return err
}
}
if !exited {
return fmt.Errorf("timed out waiting for app instances to exit")
}
}
}
func waitForAppProcesses(processes []appProcessIdentity, controller appProcessController, deadline time.Time, pollInterval time.Duration) (bool, error) {
for {
running := false
for _, process := range processes {
alive, err := controller.running(process)
if err != nil {
return false, err
}
running = running || alive
}
if !running {
return true, nil
}
if !time.Now().Before(deadline) {
return false, nil
}
time.Sleep(pollInterval)
}
}
const (
appSyncBarrierHandoffTimeout = 5 * time.Second
appSyncBarrierTerminateTimeout = 30 * time.Second
appSyncBarrierKillTimeout = 5 * time.Second
appSyncBarrierPollInterval = 50 * time.Millisecond
appSyncBarrierSettlePeriod = 100 * time.Millisecond
)
var errNewerAppInstance = errors.New("newer app instance owns the handoff")
var killOtherInstances = runDarwinAppSyncBarrier
// Once a replacement handoff starts, later shutdown signals must not restore
// the Claude profile out from under the new app.
var appHandoffInProgress atomic.Bool
func darwinProcessIdentityForPID(pid int) (appProcessIdentity, error) {
process, err := unix.SysctlKinfoProc("kern.proc.pid", pid)
if err != nil {
if errors.Is(err, unix.EIO) && errors.Is(syscall.Kill(pid, 0), syscall.ESRCH) {
return appProcessIdentity{}, syscall.ESRCH
}
return appProcessIdentity{}, err
}
if int(process.Proc.P_pid) != pid {
return appProcessIdentity{}, syscall.ESRCH
}
return appProcessIdentity{
pid: pid,
startedAt: process.Proc.P_starttime.Sec*1_000_000 + int64(process.Proc.P_starttime.Usec),
}, nil
}
func darwinOtherOllamaProcesses() ([]appProcessIdentity, error) {
var discovered *C.AppProcessIdentity
var count C.size_t
if !C.otherOllamaProcesses(&discovered, &count) {
return nil, errors.New("discover other Ollama app processes")
}
defer C.free(unsafe.Pointer(discovered))
identities := unsafe.Slice(discovered, int(count))
processes := make([]appProcessIdentity, len(identities))
for i, process := range identities {
processes[i] = appProcessIdentity{pid: int(process.pid), startedAt: int64(process.started_at)}
}
return processes, nil
}
func darwinAppProcessRunning(expected appProcessIdentity) (bool, error) {
actual, err := darwinProcessIdentityForPID(expected.pid)
if errors.Is(err, syscall.ESRCH) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("inspect Ollama app process %d: %w", expected.pid, err)
}
return actual.sameProcess(expected), nil
}
func stopDarwinAppProcess(process appProcessIdentity, mode appProcessStopMode) error {
running, err := darwinAppProcessRunning(process)
if err != nil || !running {
return err
}
processSignal := syscall.SIGUSR1
switch mode {
case appProcessStopGracefully:
processSignal = syscall.SIGTERM
case appProcessStopForcefully:
processSignal = syscall.SIGKILL
}
slog.Info("signaling Ollama app process", "pid", process.pid, "signal", processSignal)
if err := syscall.Kill(process.pid, processSignal); err != nil && !errors.Is(err, syscall.ESRCH) {
return fmt.Errorf("signal Ollama app process %d: %w", process.pid, err)
}
return nil
}
func runDarwinAppSyncBarrier() bool {
// NSWorkspace snapshots are not atomic, so two concurrent launches can both
// pass if neither is visible yet. This edge case is intentionally unhandled.
self, err := darwinProcessIdentityForPID(os.Getpid())
if err == nil {
err = runAppSyncBarrier(self, appProcessController{
discover: darwinOtherOllamaProcesses,
running: darwinAppProcessRunning,
stop: stopDarwinAppProcess,
}, appSyncBarrierConfig{
handoffTimeout: appSyncBarrierHandoffTimeout,
terminateTimeout: appSyncBarrierTerminateTimeout,
killTimeout: appSyncBarrierKillTimeout,
pollInterval: appSyncBarrierPollInterval,
settlePeriod: appSyncBarrierSettlePeriod,
})
}
switch {
case errors.Is(err, errNewerAppInstance):
slog.Info("newer Ollama app instance owns the handoff")
case err != nil:
slog.Warn("app instance sync barrier failed, continuing startup", "error", err)
}
return continueAfterBarrierError(err)
}
// continueAfterBarrierError reports whether startup may proceed after the sync
// barrier. Losing the election to a newer instance is the only reason to block
// launch; any other failure leaves at most a stale instance running, so the
// app warns and continues rather than refusing to start.
func continueAfterBarrierError(err error) bool {
return err == nil || !errors.Is(err, errNewerAppInstance)
}
// handleExistingInstance handles existing instances on macOS.
func handleExistingInstance(_ bool) bool {
if !isApp {
return true
}
return killOtherInstances()
}
func installSymlink() {
@@ -268,8 +520,7 @@ func osRun(_ func(), hasCompletedFirstRun, startHidden, showOnboarding bool, _ s
select {
case <-handoffSignal:
slog.Info("received app handoff signal, shutting down")
stopClaudeAppProxy()
C.quit()
quitForHandoff()
case <-handoffDone:
}
}()
@@ -1585,18 +1836,22 @@ func stopClaudeAppProxy() {
}
}
func quitForHandoff() {
appHandoffInProgress.Store(true)
quit()
}
func quit() {
ctx, cancel := context.WithTimeout(context.Background(), claudeShutdownTimeout)
defer cancel()
handoff := bool(C.otherOllamaInstanceRunning())
if err := restoreClaudeAppForTermination(ctx, handoff); err != nil {
if err := restoreClaudeAppForTermination(ctx, appHandoffInProgress.Load()); err != nil {
slog.Warn("failed to restore Claude before quitting", "error", err)
}
C.quit()
}
func restoreClaudeBeforeQuit(ctx context.Context, handoff, configured bool, restore func(context.Context) error) error {
if handoff || !configured {
func restoreClaudeBeforeQuit(ctx context.Context, configured bool, restore func(context.Context) error) error {
if !configured {
return nil
}
return restore(ctx)
@@ -1611,7 +1866,7 @@ func restoreClaudeAppForTermination(ctx context.Context, handoff bool) error {
return nil
}
configured := claudeDesktop.UsesOllamaGateway()
err := restoreClaudeBeforeQuit(ctx, handoff, configured, claudeDesktop.RestoreForShutdown)
err := restoreClaudeBeforeQuit(ctx, configured, claudeDesktop.RestoreForShutdown)
if !claudeDesktop.UsesOllamaGateway() {
stopClaudeAppProxy()
}
+7 -2
View File
@@ -1,5 +1,7 @@
#import <Cocoa/Cocoa.h>
#import <Security/Security.h>
#include <stddef.h>
#include <stdint.h>
@interface AppDelegate : NSObject <NSApplicationDelegate>
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification;
@@ -17,8 +19,11 @@ enum AppMove
};
void run(bool showOnboarding, bool startHidden);
void killOtherInstances();
bool otherOllamaInstanceRunning(void);
typedef struct {
int pid;
int64_t started_at;
} AppProcessIdentity;
bool otherOllamaProcesses(AppProcessIdentity **processes, size_t *count);
enum AppMove askToMoveToApplications();
int createSymlinkWithAuthorization();
int installSymlink(const char *cliPath);
+68 -29
View File
@@ -8,7 +8,10 @@
#import <ServiceManagement/ServiceManagement.h>
#import <WebKit/WebKit.h>
#import <objc/runtime.h>
#include <errno.h>
#include <libproc.h>
#include <stdlib.h>
#include <unistd.h>
extern NSString *SystemWidePath;
@@ -1561,38 +1564,74 @@ static BOOL isOllamaApplication(NSRunningApplication *app) {
[bundleId isEqualToString:@"com.electron.ollama"];
}
bool otherOllamaInstanceRunning(void) {
pid_t myPid = getpid();
for (NSRunningApplication *app in
[[NSWorkspace sharedWorkspace] runningApplications]) {
if (isOllamaApplication(app) && app.processIdentifier > 0 &&
app.processIdentifier != myPid) {
return true;
}
}
return false;
}
// killOtherInstances kills all other instances of the app currently
// running. This way we can ensure that only the most recently started
// instance of Ollama is running
void killOtherInstances() {
bool otherOllamaProcesses(AppProcessIdentity **processes, size_t *count) {
pid_t myPid = getpid();
NSArray *apps = [[NSWorkspace sharedWorkspace] runningApplications];
for (NSRunningApplication *app in apps) {
if (isOllamaApplication(app)) {
pid_t pid = app.processIdentifier;
if (pid != myPid && pid > 0) {
appLogInfo([NSString stringWithFormat:@"terminating other ollama instance %d", pid]);
// Preserve the Claude profile while the replacement instance
// takes ownership of the local gateway.
kill(pid, SIGUSR1);
} else if (pid == -1) {
appLogInfo([NSString stringWithFormat:@"skipping app with invalid pid: %@", app.bundleIdentifier]);
}
}
AppProcessIdentity *result = calloc(apps.count, sizeof(*result));
if (result == NULL && apps.count > 0) {
return false;
}
size_t resultCount = 0;
for (NSRunningApplication *app in apps) {
pid_t pid = app.processIdentifier;
if (!isOllamaApplication(app) || pid == myPid) {
continue;
}
if (pid <= 0) {
appLogInfo([NSString stringWithFormat:
@"skipping app with invalid pid: %@", app.bundleIdentifier]);
continue;
}
// Tie the NSWorkspace match to the kernel process. Re-read the start
// time after confirming the current app so PID reuse is rejected.
struct proc_bsdinfo before = {0};
int size = proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &before,
sizeof(before));
if (size != sizeof(before)) {
if (kill(pid, 0) != 0 && errno == ESRCH) {
continue;
}
appLogInfo([NSString stringWithFormat:
@"unable to inspect ollama instance %d", pid]);
free(result);
return false;
}
NSRunningApplication *current =
[NSRunningApplication runningApplicationWithProcessIdentifier:pid];
if (current == nil || current.isTerminated ||
!isOllamaApplication(current)) {
continue;
}
struct proc_bsdinfo after = {0};
size = proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &after, sizeof(after));
if (size != sizeof(after)) {
if (kill(pid, 0) != 0 && errno == ESRCH) {
continue;
}
appLogInfo([NSString stringWithFormat:
@"unable to confirm ollama instance %d", pid]);
free(result);
return false;
}
if (before.pbi_start_tvsec != after.pbi_start_tvsec ||
before.pbi_start_tvusec != after.pbi_start_tvusec) {
continue;
}
result[resultCount++] = (AppProcessIdentity){
.pid = pid,
.started_at = (int64_t)after.pbi_start_tvsec * 1000000 +
after.pbi_start_tvusec,
};
}
*processes = result;
*count = resultCount;
return true;
}
// Move the source bundle to the system-wide applications location
+236 -9
View File
@@ -6,6 +6,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"maps"
"net"
"net/http"
@@ -2662,9 +2663,182 @@ func TestClaudeGatewayLocalSelectionCatalogPolicy(t *testing.T) {
}
}
func TestRunAppSyncBarrierStopsOlderInstances(t *testing.T) {
for _, test := range []struct {
name string
exitOn appProcessStopMode
wantStops []appProcessStopMode
}{
{name: "handoff", exitOn: appProcessStopForHandoff, wantStops: []appProcessStopMode{appProcessStopForHandoff}},
{name: "graceful", exitOn: appProcessStopGracefully, wantStops: []appProcessStopMode{appProcessStopForHandoff, appProcessStopGracefully}},
{name: "forced", exitOn: appProcessStopForcefully, wantStops: []appProcessStopMode{appProcessStopForHandoff, appProcessStopGracefully, appProcessStopForcefully}},
} {
t.Run(test.name, func(t *testing.T) {
self := appProcessIdentity{pid: 20, startedAt: 20}
older := appProcessIdentity{pid: 10, startedAt: 10}
alive := true
var stops []appProcessStopMode
controller := appProcessController{
discover: func() ([]appProcessIdentity, error) {
if alive {
return []appProcessIdentity{older}, nil
}
return nil, nil
},
running: func(process appProcessIdentity) (bool, error) {
return alive && process.sameProcess(older), nil
},
stop: func(process appProcessIdentity, mode appProcessStopMode) error {
if !process.sameProcess(older) {
t.Fatalf("stopped process %+v, want %+v", process, older)
}
stops = append(stops, mode)
if mode == test.exitOn {
alive = false
}
return nil
},
}
err := runAppSyncBarrier(self, controller, appSyncBarrierConfig{
killTimeout: time.Second,
pollInterval: time.Millisecond,
})
if err != nil {
t.Fatal(err)
}
if !slices.Equal(stops, test.wantStops) {
t.Fatalf("stop modes = %v, want %v", stops, test.wantStops)
}
})
}
}
func TestRunAppSyncBarrierStopsEveryOlderInstance(t *testing.T) {
self := appProcessIdentity{pid: 30, startedAt: 30}
graceful := appProcessIdentity{pid: 10, startedAt: 10}
stubborn := appProcessIdentity{pid: 20, startedAt: 20}
late := appProcessIdentity{pid: 25, startedAt: 25}
alive := map[appProcessIdentity]bool{
graceful: true,
stubborn: true,
}
type stoppedProcess struct {
process appProcessIdentity
mode appProcessStopMode
}
var stops []stoppedProcess
lateDiscovered := false
controller := appProcessController{
discover: func() ([]appProcessIdentity, error) {
if !alive[graceful] && !alive[stubborn] && !lateDiscovered {
alive[late] = true
lateDiscovered = true
}
var processes []appProcessIdentity
for _, process := range []appProcessIdentity{graceful, stubborn, late} {
if alive[process] {
processes = append(processes, process)
}
}
return processes, nil
},
running: func(process appProcessIdentity) (bool, error) {
return alive[process], nil
},
stop: func(process appProcessIdentity, mode appProcessStopMode) error {
if !alive[process] {
return nil
}
stops = append(stops, stoppedProcess{process: process, mode: mode})
if !process.sameProcess(stubborn) || mode == appProcessStopForcefully {
alive[process] = false
}
return nil
},
}
err := runAppSyncBarrier(self, controller, appSyncBarrierConfig{
killTimeout: time.Second,
pollInterval: time.Millisecond,
})
if err != nil {
t.Fatal(err)
}
want := []stoppedProcess{
{process: graceful, mode: appProcessStopForHandoff},
{process: stubborn, mode: appProcessStopForHandoff},
{process: stubborn, mode: appProcessStopGracefully},
{process: stubborn, mode: appProcessStopForcefully},
{process: late, mode: appProcessStopForHandoff},
}
if !slices.Equal(stops, want) {
t.Fatalf("stops = %+v, want %+v", stops, want)
}
}
func TestRunAppSyncBarrierDefersToNewerInstance(t *testing.T) {
self := appProcessIdentity{pid: 10, startedAt: 10}
newer := appProcessIdentity{pid: 20, startedAt: 20}
stopped := false
err := runAppSyncBarrier(self, appProcessController{
discover: func() ([]appProcessIdentity, error) {
return []appProcessIdentity{newer}, nil
},
running: func(appProcessIdentity) (bool, error) { return true, nil },
stop: func(appProcessIdentity, appProcessStopMode) error {
stopped = true
return nil
},
}, appSyncBarrierConfig{killTimeout: time.Second})
if !errors.Is(err, errNewerAppInstance) {
t.Fatalf("barrier error = %v, want newer-instance error", err)
}
if stopped {
t.Fatal("older launch stopped the newer instance")
}
}
func TestRunAppSyncBarrierRequiresSettledEmptyList(t *testing.T) {
queries := 0
err := runAppSyncBarrier(appProcessIdentity{pid: 1, startedAt: 1}, appProcessController{
discover: func() ([]appProcessIdentity, error) {
queries++
return nil, nil
},
}, appSyncBarrierConfig{killTimeout: time.Second})
if err != nil {
t.Fatal(err)
}
if queries != 2 {
t.Fatalf("discovery queries = %d, want 2", queries)
}
}
func TestContinueAfterBarrierErrorOnlyBlocksNewerInstance(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{name: "success", err: nil, want: true},
{name: "discovery failure", err: errors.New("discover other Ollama app processes"), want: true},
{name: "handoff timeout", err: errors.New("timed out waiting for app instances to exit"), want: true},
{name: "newer instance", err: fmt.Errorf("%w: pid 2", errNewerAppInstance), want: false},
{name: "wrapped newer instance", err: fmt.Errorf("barrier: %w", fmt.Errorf("%w: pid 2", errNewerAppInstance)), want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := continueAfterBarrierError(tt.err); got != tt.want {
t.Fatalf("continueAfterBarrierError(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
func TestRestoreClaudeBeforeQuit(t *testing.T) {
called := false
if err := restoreClaudeBeforeQuit(context.Background(), false, false, func(context.Context) error {
if err := restoreClaudeBeforeQuit(context.Background(), false, func(context.Context) error {
called = true
return nil
}); err != nil {
@@ -2674,7 +2848,7 @@ func TestRestoreClaudeBeforeQuit(t *testing.T) {
t.Fatal("restore called while Claude was not configured")
}
if err := restoreClaudeBeforeQuit(context.Background(), false, true, func(context.Context) error {
if err := restoreClaudeBeforeQuit(context.Background(), true, func(context.Context) error {
called = true
return nil
}); err != nil {
@@ -2685,21 +2859,74 @@ func TestRestoreClaudeBeforeQuit(t *testing.T) {
}
wantErr := errors.New("restore failed")
if err := restoreClaudeBeforeQuit(context.Background(), false, true, func(context.Context) error {
if err := restoreClaudeBeforeQuit(context.Background(), true, func(context.Context) error {
return wantErr
}); !errors.Is(err, wantErr) {
t.Fatalf("restore error = %v, want %v", err, wantErr)
}
}
called = false
if err := restoreClaudeBeforeQuit(context.Background(), true, true, func(context.Context) error {
called = true
return nil
}); err != nil {
func TestRestoreClaudeAppForTerminationPreservesHandoffProfile(t *testing.T) {
previousDesktop := claudeDesktop
fake := &fakeClaudeDesktopController{configured: true}
claudeDesktop = fake
t.Cleanup(func() { claudeDesktop = previousDesktop })
if err := restoreClaudeAppForTermination(context.Background(), true); err != nil {
t.Fatal(err)
}
if fake.restoreCalls != 0 || !fake.configured {
t.Fatalf("handoff restore calls/configured = %d/%v, want 0/true", fake.restoreCalls, fake.configured)
}
}
func TestHandleExistingInstanceSkipsDevelopmentBuild(t *testing.T) {
oldIsApp := isApp
oldKillOtherInstances := killOtherInstances
t.Cleanup(func() {
isApp = oldIsApp
killOtherInstances = oldKillOtherInstances
})
isApp = false
called := false
killOtherInstances = func() bool {
called = true
return false
}
if !handleExistingInstance(false) {
t.Fatal("development instance did not continue startup")
}
if called {
t.Fatal("restore called during an app replacement handoff")
t.Fatal("development instance entered the packaged app handoff barrier")
}
}
func TestHandleExistingInstanceReturnsBarrierResult(t *testing.T) {
oldIsApp := isApp
oldKillOtherInstances := killOtherInstances
t.Cleanup(func() {
isApp = oldIsApp
killOtherInstances = oldKillOtherInstances
})
isApp = true
for _, want := range []bool{true, false} {
killOtherInstances = func() bool { return want }
if got := handleExistingInstance(false); got != want {
t.Fatalf("handleExistingInstance() = %v, want %v", got, want)
}
}
}
func TestDarwinAppProcessRunningReportsExitedProcess(t *testing.T) {
running, err := darwinAppProcessRunning(appProcessIdentity{pid: 1 << 30, startedAt: 1})
if err != nil {
t.Fatal(err)
}
if running {
t.Fatal("nonexistent process reported as running")
}
}
+2 -1
View File
@@ -74,11 +74,12 @@ func maybeMoveAndRestart() appMove {
}
// handleExistingInstance checks for existing instances and optionally focuses them
func handleExistingInstance(startHidden bool) {
func handleExistingInstance(startHidden bool) bool {
if wintray.CheckAndFocusExistingInstance(!startHidden) {
slog.Info("existing instance found, exiting")
os.Exit(0)
}
return true
}
func installSymlink() {}