From 2d8125042a103d6ec9b7ad3fcb7679c59ed2ed0d Mon Sep 17 00:00:00 2001 From: jmorganca Date: Sat, 27 Apr 2024 22:42:38 -0400 Subject: [PATCH] Touch ID for cli install; server restarts --- app/AppDelegate.m | 170 --------------------- app/app_darwin.go | 15 +- app/app_darwin.h | 11 +- app/app_darwin.m | 173 +++++++++++++++++++++- app/darwin/Ollama.app/Contents/Info.plist | 4 +- app/server.go | 55 +++---- 6 files changed, 225 insertions(+), 203 deletions(-) delete mode 100644 app/AppDelegate.m diff --git a/app/AppDelegate.m b/app/AppDelegate.m deleted file mode 100644 index 009bd3aa..00000000 --- a/app/AppDelegate.m +++ /dev/null @@ -1,170 +0,0 @@ -#import -#import -#import -#import "AppDelegate.h" -#import "app_darwin.h" - -@interface AppDelegate () - -@property (strong, nonatomic) NSStatusItem *statusItem; -@property (strong) NSWindow *settingsWindow; - -@end - -@implementation AppDelegate - -- (void)applicationDidFinishLaunching:(NSNotification *)aNotification { - // Ask to move to applications directory - askToMoveToApplications(); - - // Once in the desired directory, offer to create a symlink - // TODO (jmorganca): find a way to provide more context to the - // user about what this is doing, and ideally use Touch ID. - // or add an alias in the current shell environment, - // which wouldn't require any special privileges - // dispatch_async(dispatch_get_main_queue(), ^{ - // createSymlinkWithAuthorization(); - // }); - - // show status menu - NSMenu *menu = [[NSMenu alloc] init]; - [menu addItemWithTitle:@"Quit Ollama" action:@selector(quit) keyEquivalent:@"q"]; - self.statusItem = [[NSStatusBar systemStatusBar] statusItemWithLength:NSVariableStatusItemLength]; - [self.statusItem addObserver:self forKeyPath:@"button.effectiveAppearance" options:NSKeyValueObservingOptionNew|NSKeyValueObservingOptionInitial context:nil]; - - self.statusItem.menu = menu; - [self showIcon]; -} - --(void) showIcon { - NSAppearance* appearance = self.statusItem.button.effectiveAppearance; - NSString* appearanceName = (NSString*)(appearance.name); - NSString* iconName = [[appearanceName lowercaseString] containsString:@"dark"] ? @"iconDark" : @"icon"; - NSImage* statusImage = [NSImage imageNamed:iconName]; - [statusImage setTemplate:YES]; - self.statusItem.button.image = statusImage; -} - --(void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context { - [self showIcon]; -} - -- (void)openSettingsWindow { - if (!self.settingsWindow) { - // Create the settings window centered on the screen - self.settingsWindow = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 420, 460) - styleMask:(NSWindowStyleMaskTitled | NSClosableWindowMask | NSWindowStyleMaskFullSizeContentView) - backing:NSBackingStoreBuffered - defer:NO]; - [self.settingsWindow setTitle:@"Settings"]; - [self.settingsWindow makeKeyAndOrderFront:nil]; - [self.settingsWindow center]; - - // Create and configure the toolbar - NSToolbar *toolbar = [[NSToolbar alloc] initWithIdentifier:@"SettingsToolbar"]; - toolbar.delegate = self; - // toolbar.showsBaselineSeparator - toolbar.displayMode = NSToolbarDisplayModeIconAndLabel; - self.settingsWindow.toolbar = toolbar; - self.settingsWindow.toolbarStyle = NSWindowToolbarStylePreference; - - // Necessary to make the toolbar display immediately - [self.settingsWindow makeKeyAndOrderFront:nil]; - } else { - [self.settingsWindow makeKeyAndOrderFront:nil]; - } -} - -- (void)quit { - [NSApp stop:nil]; -} - -@end - -int askToMoveToApplications() { - NSString *bundlePath = [[NSBundle mainBundle] bundlePath]; - if ([bundlePath hasPrefix:@"/Applications"]) { - return 0; - } - - NSAlert *alert = [[NSAlert alloc] init]; - [alert setMessageText:@"Move to Applications?"]; - [alert setInformativeText:@"Ollama works best when run from the Applications directory."]; - [alert addButtonWithTitle:@"Move to Applications"]; - [alert addButtonWithTitle:@"Don't move"]; - - [NSApp activateIgnoringOtherApps:YES]; - - if ([alert runModal] != NSAlertFirstButtonReturn) { - return 0; - } - - // move to applications - NSString *applicationsPath = @"/Applications"; - NSString *newPath = [applicationsPath stringByAppendingPathComponent:@"Ollama.app"]; - NSFileManager *fileManager = [NSFileManager defaultManager]; - - // Check if the newPath already exists - if ([fileManager fileExistsAtPath:newPath]) { - NSError *removeError = nil; - [fileManager removeItemAtPath:newPath error:&removeError]; - if (removeError) { - NSLog(@"Error removing file at %@: %@", newPath, removeError); - return -1; // or handle the error - } - } - - NSError *moveError = nil; - [fileManager moveItemAtPath:bundlePath toPath:newPath error:&moveError]; - if (moveError) { - NSLog(@"Error moving file from %@ to %@: %@", bundlePath, newPath, moveError); - return -1; // or handle the error - } - - NSLog(@"Opening %@", newPath); - NSError *error = nil; - NSWorkspace *workspace = [NSWorkspace sharedWorkspace]; - [workspace launchApplicationAtURL:[NSURL fileURLWithPath:newPath] - options:NSWorkspaceLaunchNewInstance | NSWorkspaceLaunchDefault - configuration:@{} - error:&error]; - return 0; -} - -int createSymlinkWithAuthorization() { - NSString *linkPath = @"/usr/local/bin/ollama"; - NSError *error = nil; - - NSFileManager *fileManager = [NSFileManager defaultManager]; - NSString *symlinkPath = [fileManager destinationOfSymbolicLinkAtPath:linkPath error:&error]; - NSString *bundlePath = [[NSBundle mainBundle] bundlePath]; - NSString *execPath = [[NSBundle mainBundle] executablePath]; - NSString *resPath = [[NSBundle mainBundle] pathForResource:@"ollama" ofType:nil]; - - // if the symlink already exists and points to the right place, don't prompt - if ([symlinkPath isEqualToString:resPath]) { - return 0; - } - - OSStatus status; - AuthorizationRef authorizationRef; - status = AuthorizationCreate(NULL, kAuthorizationEmptyEnvironment, kAuthorizationFlagDefaults, &authorizationRef); - if (status != errAuthorizationSuccess) { - NSLog(@"Failed to create authorization"); - return -1; - } - - const char *toolPath = "/bin/ln"; - const char *args[] = {"-s", "-F", [resPath UTF8String], "/usr/local/bin/ollama", NULL}; - FILE *pipe = NULL; - - status = AuthorizationExecuteWithPrivileges(authorizationRef, toolPath, kAuthorizationFlagDefaults, (char *const *)args, &pipe); - if (status != errAuthorizationSuccess) { - NSLog(@"Failed to create symlink"); - return -1; - } - - AuthorizationFree(authorizationRef, kAuthorizationFlagDestroyRights); - - return 0; -} diff --git a/app/app_darwin.go b/app/app_darwin.go index 2f6e5dbb..d7d8fa6b 100644 --- a/app/app_darwin.go +++ b/app/app_darwin.go @@ -1,7 +1,7 @@ package main -// #cgo CFLAGS: -x objective-c -Wno-deprecated-declarations -// #cgo LDFLAGS: -framework Cocoa -framework LocalAuthentication +// #cgo CFLAGS: -x objective-c +// #cgo LDFLAGS: -framework Cocoa -framework LocalAuthentication -framework ServiceManagement // #include "app_darwin.h" import "C" import ( @@ -26,8 +26,19 @@ func run() { initLogging() slog.Info("ollama macOS app started") + // Ask to move to applications directory + moving := C.askToMoveToApplications() + if moving { + return + } + C.killOtherInstances() + code := C.installSymlink() + if code != 0 { + slog.Error("Failed to install symlink") + } + exe, err := os.Executable() if err != nil { panic(err) diff --git a/app/app_darwin.h b/app/app_darwin.h index bb958c0a..14dbb05c 100644 --- a/app/app_darwin.h +++ b/app/app_darwin.h @@ -1,6 +1,13 @@ +#import + +@interface AppDelegate : NSObject +- (void)applicationDidFinishLaunching:(NSNotification *)aNotification; +@end + void run(); void killOtherInstances(); -int askToMoveToApplications(); +bool askToMoveToApplications(); int createSymlinkWithAuthorization(); +int installSymlink(); extern void Restart(); -extern void Quit(); \ No newline at end of file +extern void Quit(); diff --git a/app/app_darwin.m b/app/app_darwin.m index 34ce69d1..76ead9d5 100644 --- a/app/app_darwin.m +++ b/app/app_darwin.m @@ -1,7 +1,48 @@ +#import #import -#import "AppDelegate.h" +#import +#import +#import #import "app_darwin.h" +@interface AppDelegate () + +@property (strong, nonatomic) NSStatusItem *statusItem; + +@end + +@implementation AppDelegate + +- (void)applicationDidFinishLaunching:(NSNotification *)aNotification { + // show status menu + NSMenu *menu = [[NSMenu alloc] init]; + [menu addItemWithTitle:@"Quit Ollama" action:@selector(quit) keyEquivalent:@"q"]; + self.statusItem = [[NSStatusBar systemStatusBar] statusItemWithLength:NSVariableStatusItemLength]; + [self.statusItem addObserver:self forKeyPath:@"button.effectiveAppearance" options:NSKeyValueObservingOptionNew|NSKeyValueObservingOptionInitial context:nil]; + + self.statusItem.menu = menu; + [self showIcon]; +} + +-(void) showIcon { + NSAppearance* appearance = self.statusItem.button.effectiveAppearance; + NSString* appearanceName = (NSString*)(appearance.name); + NSString* iconName = [[appearanceName lowercaseString] containsString:@"dark"] ? @"iconDark" : @"icon"; + NSImage* statusImage = [NSImage imageNamed:iconName]; + [statusImage setTemplate:YES]; + self.statusItem.button.image = statusImage; +} + +-(void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context { + [self showIcon]; +} + +- (void)quit { + [NSApp stop:nil]; +} + +@end + void run() { @autoreleasepool { [NSApplication sharedApplication]; @@ -33,9 +74,139 @@ void killOtherInstances() { kill(app.processIdentifier, SIGTERM); } + NSDate *startTime = [NSDate date]; for (NSRunningApplication *app in apps) { while (!app.terminated) { + if (-[startTime timeIntervalSinceNow] >= 5) { + kill(app.processIdentifier, SIGKILL); + break; + } + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.1]]; } } } + +bool askToMoveToApplications() { + NSString *bundlePath = [[NSBundle mainBundle] bundlePath]; + if ([bundlePath hasPrefix:@"/Applications"]) { + return false; + } + + NSAlert *alert = [[NSAlert alloc] init]; + [alert setMessageText:@"Move to Applications?"]; + [alert setInformativeText:@"Ollama works best when run from the Applications directory."]; + [alert addButtonWithTitle:@"Move to Applications"]; + [alert addButtonWithTitle:@"Don't move"]; + + [NSApp activateIgnoringOtherApps:YES]; + + if ([alert runModal] != NSAlertFirstButtonReturn) { + return false; + } + + // move to applications + NSString *applicationsPath = @"/Applications"; + NSString *newPath = [applicationsPath stringByAppendingPathComponent:@"Ollama.app"]; + NSFileManager *fileManager = [NSFileManager defaultManager]; + + // Check if the newPath already exists + if ([fileManager fileExistsAtPath:newPath]) { + NSError *removeError = nil; + [fileManager removeItemAtPath:newPath error:&removeError]; + if (removeError) { + NSLog(@"Error removing file at %@: %@", newPath, removeError); + return false; // or handle the error + } + } + + NSError *moveError = nil; + [fileManager moveItemAtPath:bundlePath toPath:newPath error:&moveError]; + if (moveError) { + NSLog(@"Error moving file from %@ to %@: %@", bundlePath, newPath, moveError); + return false; + } + + NSLog(@"Opening %@", newPath); + NSError *error = nil; + NSWorkspace *workspace = [NSWorkspace sharedWorkspace]; +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + [workspace launchApplicationAtURL:[NSURL fileURLWithPath:newPath] + options:NSWorkspaceLaunchNewInstance | NSWorkspaceLaunchDefault + configuration:@{} + error:&error]; + + return true; +} + +int installSymlink() { + NSString *linkPath = @"/usr/local/bin/ollama"; + NSError *error = nil; + + NSFileManager *fileManager = [NSFileManager defaultManager]; + NSString *symlinkPath = [fileManager destinationOfSymbolicLinkAtPath:linkPath error:&error]; + NSString *bundlePath = [[NSBundle mainBundle] bundlePath]; + NSString *execPath = [[NSBundle mainBundle] executablePath]; + NSString *resPath = [[NSBundle mainBundle] pathForResource:@"ollama" ofType:nil]; + + // if the symlink already exists and points to the right place, don't prompt + if ([symlinkPath isEqualToString:resPath]) { + NSLog(@"symbolic link already exists and points to the right place"); + return 0; + } + + NSString *authorizationPrompt = @"Ollama is trying to install its command line interface (CLI) tool."; + + AuthorizationRef auth = NULL; + OSStatus createStatus = AuthorizationCreate(NULL, kAuthorizationEmptyEnvironment, kAuthorizationFlagDefaults, &auth); + if (createStatus != errAuthorizationSuccess) { + NSLog(@"Error creating authorization"); + return -1; + } + + NSString * bundleIdentifier = [[NSBundle mainBundle] bundleIdentifier]; + NSString *rightNameString = [NSString stringWithFormat:@"%@.%@", bundleIdentifier, @"auth3"]; + const char *rightName = rightNameString.UTF8String; + + OSStatus getRightResult = AuthorizationRightGet(rightName, NULL); + if (getRightResult == errAuthorizationDenied) { + if (AuthorizationRightSet(auth, rightName, (__bridge CFTypeRef _Nonnull)(@(kAuthorizationRuleAuthenticateAsAdmin)), (__bridge CFStringRef _Nullable)(authorizationPrompt), NULL, NULL) != errAuthorizationSuccess) { + NSLog(@"Failed to set right"); + return -1; + } + } + + AuthorizationItem right = { .name = rightName, .valueLength = 0, .value = NULL, .flags = 0 }; + AuthorizationRights rights = { .count = 1, .items = &right }; + AuthorizationFlags flags = (AuthorizationFlags)(kAuthorizationFlagExtendRights | kAuthorizationFlagInteractionAllowed); + AuthorizationItem iconAuthorizationItem = {.name = kAuthorizationEnvironmentIcon, .valueLength = 0, .value = NULL, .flags = 0}; + AuthorizationEnvironment authorizationEnvironment = {.count = 0, .items = NULL}; + + BOOL failedToUseSystemDomain = NO; + OSStatus copyStatus = AuthorizationCopyRights(auth, &rights, &authorizationEnvironment, flags, NULL); + if (copyStatus != errAuthorizationSuccess) { + failedToUseSystemDomain = YES; + + if (copyStatus == errAuthorizationCanceled) { + NSLog(@"User cancelled authorization"); + return -1; + } else { + NSLog(@"Failed copying system domain rights: %d", copyStatus); + return -1; + } + } + + const char *toolPath = "/bin/ln"; + const char *args[] = {"-s", "-F", [resPath UTF8String], "/usr/local/bin/ollama", NULL}; + FILE *pipe = NULL; + +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + OSStatus status = AuthorizationExecuteWithPrivileges(auth, toolPath, kAuthorizationFlagDefaults, (char *const *)args, &pipe); + if (status != errAuthorizationSuccess) { + NSLog(@"Failed to create symlink"); + return -1; + } + + AuthorizationFree(auth, kAuthorizationFlagDestroyRights); + return 0; +} diff --git a/app/darwin/Ollama.app/Contents/Info.plist b/app/darwin/Ollama.app/Contents/Info.plist index 3801b9ae..c2676a5e 100644 --- a/app/darwin/Ollama.app/Contents/Info.plist +++ b/app/darwin/Ollama.app/Contents/Info.plist @@ -9,7 +9,7 @@ CFBundleIconFile icon.icns CFBundleIdentifier - ai.ollama.ollama + com.ollama.ollama CFBundleInfoDictionaryVersion 6.0 CFBundleName @@ -37,4 +37,4 @@ LSUIElement - \ No newline at end of file + diff --git a/app/server.go b/app/server.go index 67e58179..bf27d31d 100644 --- a/app/server.go +++ b/app/server.go @@ -7,41 +7,28 @@ import ( "io" "log/slog" "os" + "os/exec" "path/filepath" "time" "github.com/ollama/ollama/api" ) -func SpawnServer(ctx context.Context, command string) (chan int, error) { - done := make(chan int) - - logDir := filepath.Dir(ServerLogFile) - _, err := os.Stat(logDir) - if errors.Is(err, os.ErrNotExist) { - if err := os.MkdirAll(logDir, 0o755); err != nil { - return done, fmt.Errorf("create ollama server log dir %s: %v", logDir, err) - } - } - +func start(ctx context.Context, command string) (*exec.Cmd, error) { cmd := getCmd(ctx, command) stdout, err := cmd.StdoutPipe() if err != nil { - return done, fmt.Errorf("failed to spawn server stdout pipe: %w", err) + return nil, fmt.Errorf("failed to spawn server stdout pipe: %w", err) } stderr, err := cmd.StderrPipe() if err != nil { - return done, fmt.Errorf("failed to spawn server stderr pipe: %w", err) - } - stdin, err := cmd.StdinPipe() - if err != nil { - return done, fmt.Errorf("failed to spawn server stdin pipe: %w", err) + return nil, fmt.Errorf("failed to spawn server stderr pipe: %w", err) } // TODO - rotation logFile, err := os.OpenFile(ServerLogFile, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0755) if err != nil { - return done, fmt.Errorf("failed to create server log: %w", err) + return nil, fmt.Errorf("failed to create server log: %w", err) } go func() { defer logFile.Close() @@ -86,19 +73,38 @@ func SpawnServer(ctx context.Context, command string) (chan int, error) { // run the command and wait for it to finish if err := cmd.Start(); err != nil { - return done, fmt.Errorf("failed to start server %w", err) + return nil, fmt.Errorf("failed to start server %w", err) } if cmd.Process != nil { slog.Info(fmt.Sprintf("started ollama server with pid %d", cmd.Process.Pid)) } slog.Info(fmt.Sprintf("ollama server logs %s", ServerLogFile)) + return cmd, nil +} + +func SpawnServer(ctx context.Context, command string) (chan int, error) { + logDir := filepath.Dir(ServerLogFile) + _, err := os.Stat(logDir) + if errors.Is(err, os.ErrNotExist) { + if err := os.MkdirAll(logDir, 0o755); err != nil { + return nil, fmt.Errorf("create ollama server log dir %s: %v", logDir, err) + } + } + + done := make(chan int) + go func() { // Keep the server running unless we're shuttind down the app crashCount := 0 for { + slog.Info(fmt.Sprintf("starting server...")) + cmd, err := start(ctx, command) + if err != nil { + slog.Error(fmt.Sprintf("failed to start server %s", err)) + } + cmd.Wait() //nolint:errcheck - stdin.Close() var code int if cmd.ProcessState != nil { code = cmd.ProcessState.ExitCode() @@ -112,15 +118,12 @@ func SpawnServer(ctx context.Context, command string) (chan int, error) { default: crashCount++ slog.Warn(fmt.Sprintf("server crash %d - exit code %d - respawning", crashCount, code)) - time.Sleep(500 * time.Millisecond) - if err := cmd.Start(); err != nil { - slog.Error(fmt.Sprintf("failed to restart server %s", err)) - // Keep trying, but back off if we keep failing - time.Sleep(time.Duration(crashCount) * time.Second) - } + time.Sleep(500 * time.Millisecond * time.Duration(crashCount)) + break } } }() + return done, nil }