From 29e90cc13b94d5306db42240042ad6f2bb8add3a Mon Sep 17 00:00:00 2001 From: Daniel Hiltgen Date: Tue, 26 Dec 2023 16:03:45 -0800 Subject: [PATCH] Implement new Go based Desktop app This focuses on Windows first, but coudl be used for Mac and possibly linux in the future. --- .gitignore | 3 +- app/README.md | 22 ++ app/assets/app.ico | Bin 0 -> 6227 bytes app/assets/assets.go | 17 + app/assets/setup.bmp | Bin 0 -> 77418 bytes app/assets/tray.ico | Bin 0 -> 22846 bytes app/assets/tray_upgrade.ico | Bin 0 -> 23698 bytes app/lifecycle/getstarted_nonwindows.go | 9 + app/lifecycle/getstarted_windows.go | 44 +++ app/lifecycle/lifecycle.go | 83 +++++ app/lifecycle/logging.go | 46 +++ app/lifecycle/logging_nonwindows.go | 9 + app/lifecycle/logging_windows.go | 19 + app/lifecycle/paths.go | 79 ++++ app/lifecycle/server.go | 135 +++++++ app/lifecycle/server_unix.go | 12 + app/lifecycle/server_windows.go | 13 + app/lifecycle/updater.go | 216 +++++++++++ app/lifecycle/updater_nonwindows.go | 12 + app/lifecycle/updater_windows.go | 79 ++++ app/main.go | 17 + app/ollama.iss | 150 ++++++++ app/ollama_welcome.ps1 | 8 + app/store/store.go | 98 +++++ app/store/store_darwin.go | 13 + app/store/store_linux.go | 16 + app/store/store_windows.go | 11 + app/tray/commontray/types.go | 24 ++ app/tray/tray.go | 33 ++ app/tray/tray_nonwindows.go | 13 + app/tray/tray_windows.go | 10 + app/tray/wintray/eventloop.go | 189 ++++++++++ app/tray/wintray/menus.go | 75 ++++ app/tray/wintray/messages.go | 15 + app/tray/wintray/notifyicon.go | 66 ++++ app/tray/wintray/tray.go | 485 +++++++++++++++++++++++++ app/tray/wintray/w32api.go | 89 +++++ app/tray/wintray/winclass.go | 45 +++ cmd/cmd.go | 31 +- cmd/start_darwin.go | 30 ++ cmd/start_default.go | 14 + cmd/start_windows.go | 81 +++++ docs/troubleshooting.md | 130 ++++--- docs/windows.md | 46 +++ go.mod | 12 + go.sum | 30 ++ llm/generate/gen_windows.ps1 | 51 ++- scripts/build_remote.py | 12 +- scripts/build_windows.ps1 | 130 +++++++ 49 files changed, 2621 insertions(+), 101 deletions(-) create mode 100644 app/README.md create mode 100644 app/assets/app.ico create mode 100644 app/assets/assets.go create mode 100644 app/assets/setup.bmp create mode 100644 app/assets/tray.ico create mode 100644 app/assets/tray_upgrade.ico create mode 100644 app/lifecycle/getstarted_nonwindows.go create mode 100644 app/lifecycle/getstarted_windows.go create mode 100644 app/lifecycle/lifecycle.go create mode 100644 app/lifecycle/logging.go create mode 100644 app/lifecycle/logging_nonwindows.go create mode 100644 app/lifecycle/logging_windows.go create mode 100644 app/lifecycle/paths.go create mode 100644 app/lifecycle/server.go create mode 100644 app/lifecycle/server_unix.go create mode 100644 app/lifecycle/server_windows.go create mode 100644 app/lifecycle/updater.go create mode 100644 app/lifecycle/updater_nonwindows.go create mode 100644 app/lifecycle/updater_windows.go create mode 100644 app/main.go create mode 100644 app/ollama.iss create mode 100644 app/ollama_welcome.ps1 create mode 100644 app/store/store.go create mode 100644 app/store/store_darwin.go create mode 100644 app/store/store_linux.go create mode 100644 app/store/store_windows.go create mode 100644 app/tray/commontray/types.go create mode 100644 app/tray/tray.go create mode 100644 app/tray/tray_nonwindows.go create mode 100644 app/tray/tray_windows.go create mode 100644 app/tray/wintray/eventloop.go create mode 100644 app/tray/wintray/menus.go create mode 100644 app/tray/wintray/messages.go create mode 100644 app/tray/wintray/notifyicon.go create mode 100644 app/tray/wintray/tray.go create mode 100644 app/tray/wintray/w32api.go create mode 100644 app/tray/wintray/winclass.go create mode 100644 cmd/start_darwin.go create mode 100644 cmd/start_default.go create mode 100644 cmd/start_windows.go create mode 100644 docs/windows.md create mode 100644 scripts/build_windows.ps1 diff --git a/.gitignore b/.gitignore index 97f73481..388175f7 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ ggml-metal.metal .cache *.exe .idea -test_data \ No newline at end of file +test_data +*.crt \ No newline at end of file diff --git a/app/README.md b/app/README.md new file mode 100644 index 00000000..883d7ab7 --- /dev/null +++ b/app/README.md @@ -0,0 +1,22 @@ +# Ollama App + +## Linux + +TODO + +## MacOS + +TODO + +## Windows + +If you want to build the installer, youll need to install +- https://jrsoftware.org/isinfo.php + + +In the top directory of this repo, run the following powershell script +to build the ollama CLI, ollama app, and ollama installer. + +``` +powershell -ExecutionPolicy Bypass -File .\scripts\build_windows.ps1 +``` diff --git a/app/assets/app.ico b/app/assets/app.ico new file mode 100644 index 0000000000000000000000000000000000000000..0c38e3d047e9e98441f961bc1c72075fb187a9e1 GIT binary patch literal 6227 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 I*cO4G0Lng3rT_o{ literal 0 HcmV?d00001 diff --git a/app/assets/tray.ico b/app/assets/tray.ico new file mode 100644 index 0000000000000000000000000000000000000000..c8f3e9f656c2902049ecc976ee44fd95d9a90638 GIT binary patch literal 22846 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 JE{gw({|Ct~wcY># literal 0 HcmV?d00001 diff --git a/app/assets/tray_upgrade.ico b/app/assets/tray_upgrade.ico new file mode 100644 index 0000000000000000000000000000000000000000..ade77fd14f3f879ea7c3a9de696381136c02ff27 GIT binary patch literal 23698 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 MWtsdd&s41VFHRJSrvLx| literal 0 HcmV?d00001 diff --git a/app/lifecycle/getstarted_nonwindows.go b/app/lifecycle/getstarted_nonwindows.go new file mode 100644 index 00000000..c36d14c0 --- /dev/null +++ b/app/lifecycle/getstarted_nonwindows.go @@ -0,0 +1,9 @@ +//go:build !windows + +package lifecycle + +import "fmt" + +func GetStarted() error { + return fmt.Errorf("GetStarted not implemented") +} diff --git a/app/lifecycle/getstarted_windows.go b/app/lifecycle/getstarted_windows.go new file mode 100644 index 00000000..092c3c17 --- /dev/null +++ b/app/lifecycle/getstarted_windows.go @@ -0,0 +1,44 @@ +package lifecycle + +import ( + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "syscall" +) + +func GetStarted() error { + const CREATE_NEW_CONSOLE = 0x00000010 + var err error + bannerScript := filepath.Join(AppDir, "ollama_welcome.ps1") + args := []string{ + // TODO once we're signed, the execution policy bypass should be removed + "powershell", "-noexit", "-ExecutionPolicy", "Bypass", "-nologo", "-file", bannerScript, + } + args[0], err = exec.LookPath(args[0]) + if err != nil { + return err + } + + // Make sure the script actually exists + _, err = os.Stat(bannerScript) + if err != nil { + return fmt.Errorf("getting started banner script error %s", err) + } + + slog.Info(fmt.Sprintf("opening getting started terminal with %v", args)) + attrs := &os.ProcAttr{ + Files: []*os.File{os.Stdin, os.Stdout, os.Stderr}, + Sys: &syscall.SysProcAttr{CreationFlags: CREATE_NEW_CONSOLE, HideWindow: false}, + } + proc, err := os.StartProcess(args[0], args, attrs) + + if err != nil { + return fmt.Errorf("unable to start getting started shell %w", err) + } + + slog.Debug(fmt.Sprintf("getting started terminal PID: %d", proc.Pid)) + return proc.Release() +} diff --git a/app/lifecycle/lifecycle.go b/app/lifecycle/lifecycle.go new file mode 100644 index 00000000..1fa9c7a8 --- /dev/null +++ b/app/lifecycle/lifecycle.go @@ -0,0 +1,83 @@ +package lifecycle + +import ( + "context" + "fmt" + "log" + "log/slog" + + "github.com/jmorganca/ollama/app/store" + "github.com/jmorganca/ollama/app/tray" +) + +func Run() { + InitLogging() + + ctx, cancel := context.WithCancel(context.Background()) + var done chan int + + t, err := tray.NewTray() + if err != nil { + log.Fatalf("Failed to start: %s", err) + } + callbacks := t.GetCallbacks() + + go func() { + slog.Debug("starting callback loop") + for { + select { + case <-callbacks.Quit: + slog.Debug("QUIT called") + t.Quit() + case <-callbacks.Update: + err := DoUpgrade(cancel, done) + if err != nil { + slog.Warn(fmt.Sprintf("upgrade attempt failed: %s", err)) + } + case <-callbacks.ShowLogs: + ShowLogs() + case <-callbacks.DoFirstUse: + err := GetStarted() + if err != nil { + slog.Warn(fmt.Sprintf("Failed to launch getting started shell: %s", err)) + } + } + } + }() + + // Are we first use? + if !store.GetFirstTimeRun() { + slog.Debug("First time run") + err = t.DisplayFirstUseNotification() + if err != nil { + slog.Debug(fmt.Sprintf("XXX failed to display first use notification %v", err)) + } + store.SetFirstTimeRun(true) + } else { + slog.Debug("Not first time, skipping first run notification") + } + + if IsServerRunning(ctx) { + slog.Debug("XXX detected server already running") + // TODO - should we fail fast, try to kill it, or just ignore? + } else { + done, err = SpawnServer(ctx, CLIName) + if err != nil { + // TODO - should we retry in a backoff loop? + // TODO - should we pop up a warning and maybe add a menu item to view application logs? + slog.Error(fmt.Sprintf("Failed to spawn ollama server %s", err)) + done = make(chan int, 1) + done <- 1 + } + } + + StartBackgroundUpdaterChecker(ctx, t.UpdateAvailable) + + t.Run() + cancel() + slog.Info("Waiting for ollama server to shutdown...") + if done != nil { + <-done + } + slog.Info("Ollama app exiting") +} diff --git a/app/lifecycle/logging.go b/app/lifecycle/logging.go new file mode 100644 index 00000000..98df9b41 --- /dev/null +++ b/app/lifecycle/logging.go @@ -0,0 +1,46 @@ +package lifecycle + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" +) + +func InitLogging() { + level := slog.LevelInfo + + if debug := os.Getenv("OLLAMA_DEBUG"); debug != "" { + level = slog.LevelDebug + } + + var logFile *os.File + var err error + // Detect if we're a GUI app on windows, and if not, send logs to console + if os.Stderr.Fd() != 0 { + // Console app detected + logFile = os.Stderr + // TODO - write one-line to the app.log file saying we're running in console mode to help avoid confusion + } else { + logFile, err = os.OpenFile(AppLogFile, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0755) + if err != nil { + slog.Error(fmt.Sprintf("failed to create server log %v", err)) + return + } + } + handler := slog.NewTextHandler(logFile, &slog.HandlerOptions{ + Level: level, + AddSource: true, + ReplaceAttr: func(_ []string, attr slog.Attr) slog.Attr { + if attr.Key == slog.SourceKey { + source := attr.Value.Any().(*slog.Source) + source.File = filepath.Base(source.File) + } + return attr + }, + }) + + slog.SetDefault(slog.New(handler)) + + slog.Info("ollama app started") +} diff --git a/app/lifecycle/logging_nonwindows.go b/app/lifecycle/logging_nonwindows.go new file mode 100644 index 00000000..50b3a638 --- /dev/null +++ b/app/lifecycle/logging_nonwindows.go @@ -0,0 +1,9 @@ +//go:build !windows + +package lifecycle + +import "log/slog" + +func ShowLogs() { + slog.Warn("ShowLogs not yet implemented") +} diff --git a/app/lifecycle/logging_windows.go b/app/lifecycle/logging_windows.go new file mode 100644 index 00000000..367a5274 --- /dev/null +++ b/app/lifecycle/logging_windows.go @@ -0,0 +1,19 @@ +package lifecycle + +import ( + "fmt" + "log/slog" + "os/exec" + "syscall" +) + +func ShowLogs() { + cmd_path := "c:\\Windows\\system32\\cmd.exe" + slog.Debug(fmt.Sprintf("viewing logs with start %s", AppDataDir)) + cmd := exec.Command(cmd_path, "/c", "start", AppDataDir) + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} + err := cmd.Start() + if err != nil { + slog.Error(fmt.Sprintf("Failed to open log dir: %s", err)) + } +} diff --git a/app/lifecycle/paths.go b/app/lifecycle/paths.go new file mode 100644 index 00000000..e4f2dbd9 --- /dev/null +++ b/app/lifecycle/paths.go @@ -0,0 +1,79 @@ +package lifecycle + +import ( + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + "runtime" + "strings" +) + +var ( + AppName = "ollama app" + CLIName = "ollama" + AppDir = "/opt/Ollama" + AppDataDir = "/opt/Ollama" + // TODO - should there be a distinct log dir? + UpdateStageDir = "/tmp" + AppLogFile = "/tmp/ollama_app.log" + ServerLogFile = "/tmp/ollama.log" + UpgradeLogFile = "/tmp/ollama_update.log" + Installer = "OllamaSetup.exe" +) + +func init() { + if runtime.GOOS == "windows" { + AppName += ".exe" + CLIName += ".exe" + // Logs, configs, downloads go to LOCALAPPDATA + localAppData := os.Getenv("LOCALAPPDATA") + AppDataDir = filepath.Join(localAppData, "Ollama") + UpdateStageDir = filepath.Join(AppDataDir, "updates") + AppLogFile = filepath.Join(AppDataDir, "app.log") + ServerLogFile = filepath.Join(AppDataDir, "server.log") + UpgradeLogFile = filepath.Join(AppDataDir, "upgrade.log") + + // Executables are stored in APPDATA + AppDir = filepath.Join(localAppData, "Programs", "Ollama") + + // Make sure we have PATH set correctly for any spawned children + paths := strings.Split(os.Getenv("PATH"), ";") + // Start with whatever we find in the PATH/LD_LIBRARY_PATH + found := false + for _, path := range paths { + d, err := filepath.Abs(path) + if err != nil { + continue + } + if strings.EqualFold(AppDir, d) { + found = true + } + } + if !found { + paths = append(paths, AppDir) + + pathVal := strings.Join(paths, ";") + slog.Debug("setting PATH=" + pathVal) + err := os.Setenv("PATH", pathVal) + if err != nil { + slog.Error(fmt.Sprintf("failed to update PATH: %s", err)) + } + } + + // Make sure our logging dir exists + _, err := os.Stat(AppDataDir) + if errors.Is(err, os.ErrNotExist) { + if err := os.MkdirAll(AppDataDir, 0o755); err != nil { + slog.Error(fmt.Sprintf("create ollama dir %s: %v", AppDataDir, err)) + } + } + + } else if runtime.GOOS == "darwin" { + // TODO + AppName += ".app" + // } else if runtime.GOOS == "linux" { + // TODO + } +} diff --git a/app/lifecycle/server.go b/app/lifecycle/server.go new file mode 100644 index 00000000..bc558d24 --- /dev/null +++ b/app/lifecycle/server.go @@ -0,0 +1,135 @@ +package lifecycle + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "path/filepath" + "time" + + "github.com/jmorganca/ollama/api" +) + +func getCLIFullPath(command string) string { + cmdPath := "" + appExe, err := os.Executable() + if err == nil { + cmdPath = filepath.Join(filepath.Dir(appExe), command) + _, err := os.Stat(cmdPath) + if err == nil { + return cmdPath + } + } + cmdPath, err = exec.LookPath(command) + if err == nil { + _, err := os.Stat(cmdPath) + if err == nil { + return cmdPath + } + } + cmdPath = filepath.Join(".", command) + _, err = os.Stat(cmdPath) + if err == nil { + return cmdPath + } + return command +} + +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) + } + } + + cmd := getCmd(ctx, getCLIFullPath(command)) + // send stdout and stderr to a file + stdout, err := cmd.StdoutPipe() + if err != nil { + return done, fmt.Errorf("failed to spawn server stdout pipe %s", err) + } + stderr, err := cmd.StderrPipe() + if err != nil { + return done, fmt.Errorf("failed to spawn server stderr pipe %s", err) + } + stdin, err := cmd.StdinPipe() + if err != nil { + return done, fmt.Errorf("failed to spawn server stdin pipe %s", 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) + } + go func() { + defer logFile.Close() + io.Copy(logFile, stdout) //nolint:errcheck + }() + go func() { + defer logFile.Close() + io.Copy(logFile, stderr) //nolint:errcheck + }() + + // 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) + } + 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)) + + go func() { + // Keep the server running unless we're shuttind down the app + crashCount := 0 + for { + cmd.Wait() //nolint:errcheck + stdin.Close() + var code int + if cmd.ProcessState != nil { + code = cmd.ProcessState.ExitCode() + } + + select { + case <-ctx.Done(): + slog.Debug(fmt.Sprintf("server shutdown with exit code %d", code)) + done <- code + return + 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) + } + } + } + }() + return done, nil +} + +func IsServerRunning(ctx context.Context) bool { + client, err := api.ClientFromEnvironment() + if err != nil { + slog.Info("unable to connect to server") + return false + } + err = client.Heartbeat(ctx) + if err != nil { + slog.Debug(fmt.Sprintf("heartbeat from server: %s", err)) + slog.Info("unable to connect to server") + return false + } + return true +} diff --git a/app/lifecycle/server_unix.go b/app/lifecycle/server_unix.go new file mode 100644 index 00000000..c35f8b5b --- /dev/null +++ b/app/lifecycle/server_unix.go @@ -0,0 +1,12 @@ +//go:build !windows + +package lifecycle + +import ( + "context" + "os/exec" +) + +func getCmd(ctx context.Context, cmd string) *exec.Cmd { + return exec.CommandContext(ctx, cmd, "serve") +} diff --git a/app/lifecycle/server_windows.go b/app/lifecycle/server_windows.go new file mode 100644 index 00000000..3044e526 --- /dev/null +++ b/app/lifecycle/server_windows.go @@ -0,0 +1,13 @@ +package lifecycle + +import ( + "context" + "os/exec" + "syscall" +) + +func getCmd(ctx context.Context, exePath string) *exec.Cmd { + cmd := exec.CommandContext(ctx, exePath, "serve") + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} + return cmd +} diff --git a/app/lifecycle/updater.go b/app/lifecycle/updater.go new file mode 100644 index 00000000..c1430e28 --- /dev/null +++ b/app/lifecycle/updater.go @@ -0,0 +1,216 @@ +package lifecycle + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "mime" + "net/http" + "os" + "path" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/jmorganca/ollama/auth" + "github.com/jmorganca/ollama/version" +) + +var ( + UpdateCheckURLBase = "https://ollama.ai/api/update" + UpdateDownloaded = false +) + +// TODO - maybe move up to the API package? +type UpdateResponse struct { + UpdateURL string `json:"url"` + UpdateVersion string `json:"version"` +} + +func getClient(req *http.Request) http.Client { + proxyURL, err := http.ProxyFromEnvironment(req) + if err != nil { + slog.Warn(fmt.Sprintf("failed to handle proxy: %s", err)) + return http.Client{} + } + + return http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyURL(proxyURL), + }, + } +} + +func IsNewReleaseAvailable(ctx context.Context) (bool, UpdateResponse) { + var updateResp UpdateResponse + updateCheckURL := UpdateCheckURLBase + "?os=" + runtime.GOOS + "&arch=" + runtime.GOARCH + "&version=" + version.Version + headers := make(http.Header) + err := auth.SignRequest(http.MethodGet, updateCheckURL, nil, headers) + if err != nil { + slog.Info(fmt.Sprintf("failed to sign update request %s", err)) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, updateCheckURL, nil) + if err != nil { + slog.Warn(fmt.Sprintf("failed to check for update: %s", err)) + return false, updateResp + } + req.Header = headers + req.Header.Set("User-Agent", fmt.Sprintf("ollama/%s (%s %s) Go/%s", version.Version, runtime.GOARCH, runtime.GOOS, runtime.Version())) + client := getClient(req) + + slog.Debug(fmt.Sprintf("checking for available update at %s with headers %v", updateCheckURL, headers)) + resp, err := client.Do(req) + if err != nil { + slog.Warn(fmt.Sprintf("failed to check for update: %s", err)) + return false, updateResp + } + defer resp.Body.Close() + + if resp.StatusCode == 204 { + slog.Debug("check update response 204 (current version is up to date)") + return false, updateResp + } + body, err := io.ReadAll(resp.Body) + if err != nil { + slog.Warn(fmt.Sprintf("failed to read body response: %s", err)) + } + err = json.Unmarshal(body, &updateResp) + if err != nil { + slog.Warn(fmt.Sprintf("malformed response checking for update: %s", err)) + return false, updateResp + } + // Extract the version string from the URL in the github release artifact path + updateResp.UpdateVersion = path.Base(path.Dir(updateResp.UpdateURL)) + + slog.Info("New update available at " + updateResp.UpdateURL) + return true, updateResp +} + +// Returns true if we downloaded a new update, false if we already had it +func DownloadNewRelease(ctx context.Context, updateResp UpdateResponse) error { + // Do a head first to check etag info + req, err := http.NewRequestWithContext(ctx, http.MethodHead, updateResp.UpdateURL, nil) + if err != nil { + return err + } + client := getClient(req) + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("error checking update: %w", err) + } + if resp.StatusCode != 200 { + return fmt.Errorf("unexpected status attempting to download update %d", resp.StatusCode) + } + resp.Body.Close() + etag := strings.Trim(resp.Header.Get("etag"), "\"") + if etag == "" { + slog.Debug("no etag detected, falling back to filename based dedup") + etag = "_" + } + filename := Installer + _, params, err := mime.ParseMediaType(resp.Header.Get("content-disposition")) + if err == nil { + filename = params["filename"] + } + + stageFilename := filepath.Join(UpdateStageDir, etag, filename) + + // Check to see if we already have it downloaded + _, err = os.Stat(stageFilename) + if err == nil { + slog.Debug("update already downloaded") + return nil + } + + cleanupOldDownloads() + + req.Method = http.MethodGet + resp, err = client.Do(req) + if err != nil { + return fmt.Errorf("error checking update: %w", err) + } + defer resp.Body.Close() + etag = strings.Trim(resp.Header.Get("etag"), "\"") + if etag == "" { + slog.Debug("no etag detected, falling back to filename based dedup") // TODO probably can get rid of this redundant log + etag = "_" + } + + stageFilename = filepath.Join(UpdateStageDir, etag, filename) + + _, err = os.Stat(filepath.Dir(stageFilename)) + if errors.Is(err, os.ErrNotExist) { + if err := os.MkdirAll(filepath.Dir(stageFilename), 0o755); err != nil { + return fmt.Errorf("create ollama dir %s: %v", filepath.Dir(stageFilename), err) + } + } + + payload, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read body response: %w", err) + } + fp, err := os.OpenFile(stageFilename, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755) + if err != nil { + return fmt.Errorf("write payload %s: %w", stageFilename, err) + } + defer fp.Close() + if n, err := fp.Write(payload); err != nil || n != len(payload) { + return fmt.Errorf("write payload %s: %d vs %d -- %w", stageFilename, n, len(payload), err) + } + slog.Info("new update downloaded " + stageFilename) + + UpdateDownloaded = true + return nil +} + +func cleanupOldDownloads() { + files, err := os.ReadDir(UpdateStageDir) + if err != nil && errors.Is(err, os.ErrNotExist) { + // Expected behavior on first run + return + } else if err != nil { + slog.Warn(fmt.Sprintf("failed to list stage dir: %s", err)) + return + } + for _, file := range files { + fullname := filepath.Join(UpdateStageDir, file.Name()) + slog.Debug("cleaning up old download: " + fullname) + err = os.RemoveAll(fullname) + if err != nil { + slog.Warn(fmt.Sprintf("failed to cleanup stale update download %s", err)) + } + } +} + +func StartBackgroundUpdaterChecker(ctx context.Context, cb func(string) error) { + go func() { + // Don't blast an update message immediately after startup + // time.Sleep(30 * time.Second) + time.Sleep(3 * time.Second) + + for { + available, resp := IsNewReleaseAvailable(ctx) + if available { + err := DownloadNewRelease(ctx, resp) + if err != nil { + slog.Error(fmt.Sprintf("failed to download new release: %s", err)) + } + err = cb(resp.UpdateVersion) + if err != nil { + slog.Warn(fmt.Sprintf("failed to register update available with tray: %s", err)) + } + } + select { + case <-ctx.Done(): + slog.Debug("stopping background update checker") + return + default: + time.Sleep(60 * 60 * time.Second) + } + } + }() +} diff --git a/app/lifecycle/updater_nonwindows.go b/app/lifecycle/updater_nonwindows.go new file mode 100644 index 00000000..0f213b34 --- /dev/null +++ b/app/lifecycle/updater_nonwindows.go @@ -0,0 +1,12 @@ +//go:build !windows + +package lifecycle + +import ( + "context" + "fmt" +) + +func DoUpgrade(cancel context.CancelFunc, done chan int) error { + return fmt.Errorf("DoUpgrade not yet implemented") +} diff --git a/app/lifecycle/updater_windows.go b/app/lifecycle/updater_windows.go new file mode 100644 index 00000000..cc97f686 --- /dev/null +++ b/app/lifecycle/updater_windows.go @@ -0,0 +1,79 @@ +package lifecycle + +import ( + "context" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" +) + +func DoUpgrade(cancel context.CancelFunc, done chan int) error { + files, err := filepath.Glob(filepath.Join(UpdateStageDir, "*", "*.exe")) // TODO generalize for multiplatform + if err != nil { + return fmt.Errorf("failed to lookup downloads: %s", err) + } + if len(files) == 0 { + return fmt.Errorf("no update downloads found") + } else if len(files) > 1 { + // Shouldn't happen + slog.Warn(fmt.Sprintf("multiple downloads found, using first one %v", files)) + } + installerExe := files[0] + + slog.Info("starting upgrade with " + installerExe) + slog.Info("upgrade log file " + UpgradeLogFile) + + // When running in debug mode, we'll be "verbose" and let the installer pop up and prompt + installArgs := []string{ + "/CLOSEAPPLICATIONS", // Quit the tray app if it's still running + "/LOG=" + filepath.Base(UpgradeLogFile), // Only relative seems reliable, so set pwd + "/FORCECLOSEAPPLICATIONS", // Force close the tray app - might be needed + } + // When we're not in debug mode, make the upgrade as quiet as possible (no GUI, no prompts) + // TODO - temporarily disable since we're pinning in debug mode for the preview + // if debug := os.Getenv("OLLAMA_DEBUG"); debug == "" { + installArgs = append(installArgs, + "/SP", // Skip the "This will install... Do you wish to continue" prompt + "/SUPPRESSMSGBOXES", + "/SILENT", + "/VERYSILENT", + ) + // } + + // Safeguard in case we have requests in flight that need to drain... + slog.Info("Waiting for server to shutdown") + cancel() + if done != nil { + <-done + } else { + slog.Warn("XXX done chan was nil, not actually waiting") + } + + slog.Debug(fmt.Sprintf("starting installer: %s %v", installerExe, installArgs)) + os.Chdir(filepath.Dir(UpgradeLogFile)) //nolint:errcheck + cmd := exec.Command(installerExe, installArgs...) + + if err := cmd.Start(); err != nil { + return fmt.Errorf("unable to start ollama app %w", err) + } + + if cmd.Process != nil { + err = cmd.Process.Release() + if err != nil { + slog.Error(fmt.Sprintf("failed to release server process: %s", err)) + } + } else { + // TODO - some details about why it didn't start, or is this a pedantic error case? + return fmt.Errorf("installer process did not start") + } + + // TODO should we linger for a moment and check to make sure it's actually running by checking the pid? + + slog.Info("Installer started in background, exiting") + + os.Exit(0) + // Not reached + return nil +} diff --git a/app/main.go b/app/main.go new file mode 100644 index 00000000..57cfd72e --- /dev/null +++ b/app/main.go @@ -0,0 +1,17 @@ +package main + +// Compile with the following to get rid of the cmd pop up on windows +// go build -ldflags="-H windowsgui" . + +import ( + "os" + + "github.com/jmorganca/ollama/app/lifecycle" +) + +func main() { + // TODO - remove as we end the early access phase + os.Setenv("OLLAMA_DEBUG", "1") // nolint:errcheck + + lifecycle.Run() +} diff --git a/app/ollama.iss b/app/ollama.iss new file mode 100644 index 00000000..5d90176a --- /dev/null +++ b/app/ollama.iss @@ -0,0 +1,150 @@ +; Inno Setup Installer for Ollama +; +; To build the installer use the build script invoked from the top of the source tree +; +; powershell -ExecutionPolicy Bypass -File .\scripts\build_windows.ps + + +#define MyAppName "Ollama" +#if GetEnv("PKG_VERSION") != "" + #define MyAppVersion GetEnv("PKG_VERSION") +#else + #define MyAppVersion "0.0.0" +#endif +#define MyAppPublisher "Ollama, Inc." +#define MyAppURL "https://ollama.ai/" +#define MyAppExeName "ollama app.exe" +#define MyIcon ".\assets\app.ico" + +[Setup] +; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications. +; (To generate a new GUID, click Tools | Generate GUID inside the IDE.) +AppId={{44E83376-CE68-45EB-8FC1-393500EB558C} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +VersionInfoVersion={#MyAppVersion} +;AppVerName={#MyAppName} {#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +AppSupportURL={#MyAppURL} +AppUpdatesURL={#MyAppURL} +ArchitecturesAllowed=x64 +ArchitecturesInstallIn64BitMode=x64 +DefaultDirName={localappdata}\Programs\{#MyAppName} +DefaultGroupName={#MyAppName} +DisableProgramGroupPage=yes +PrivilegesRequired=lowest +OutputBaseFilename="OllamaSetup" +SetupIconFile={#MyIcon} +UninstallDisplayIcon={uninstallexe} +Compression=lzma2 +SolidCompression=no +WizardStyle=modern +ChangesEnvironment=yes +OutputDir=..\dist\ + +; Disable logging once everything's battle tested +; Filename will be %TEMP%\Setup Log*.txt +SetupLogging=yes +CloseApplications=yes +RestartApplications=no + +; Make sure they can at least download llama2 as a minimum +ExtraDiskSpaceRequired=3826806784 + +; https://jrsoftware.org/ishelp/index.php?topic=setup_wizardimagefile +WizardSmallImageFile=.\assets\setup.bmp + +; TODO verifty actual min windows version... +; OG Win 10 +MinVersion=10.0.10240 + +; First release that supports WinRT UI Composition for win32 apps +; MinVersion=10.0.17134 +; First release with XAML Islands - possible UI path forward +; MinVersion=10.0.18362 + +; quiet... +DisableDirPage=yes +DisableFinishedPage=yes +DisableReadyMemo=yes +DisableReadyPage=yes +DisableStartupPrompt=yes +DisableWelcomePage=yes + +; TODO - percentage can't be set less than 100, so how to make it shorter? +; WizardSizePercent=100,80 + +#if GetEnv("KEY_CONTAINER") +SignTool=MySignTool +SignedUninstaller=yes +#endif + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[LangOptions] +DialogFontSize=12 + +[Files] +Source: ".\app.exe"; DestDir: "{app}"; DestName: "{#MyAppExeName}" ; Flags: ignoreversion 64bit +Source: "..\ollama.exe"; DestDir: "{app}"; Flags: ignoreversion 64bit +Source: "..\dist\windeps\*.dll"; DestDir: "{app}"; Flags: ignoreversion 64bit +Source: "..\dist\ollama_welcome.ps1"; DestDir: "{app}"; Flags: ignoreversion +Source: ".\assets\app.ico"; DestDir: "{app}"; Flags: ignoreversion + +[Icons] +Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\app.ico" +Name: "{userstartup}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\app.ico" +Name: "{userprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\app.ico" + +[Run] +Filename: "{cmd}"; Parameters: "/C set PATH={app};%PATH% & ""{app}\{#MyAppExeName}"""; Flags: postinstall nowait runhidden + +[UninstallRun] +; Filename: "{cmd}"; Parameters: "/C ""taskkill /im ''{#MyAppExeName}'' /f /t"; Flags: runhidden +; Filename: "{cmd}"; Parameters: "/C ""taskkill /im ollama.exe /f /t"; Flags: runhidden +Filename: "taskkill"; Parameters: "/im ""{#MyAppExeName}"" /f /t"; Flags: runhidden +Filename: "taskkill"; Parameters: "/im ""ollama.exe"" /f /t"; Flags: runhidden +; HACK! need to give the server and app enough time to exit +; TODO - convert this to a Pascal code script so it waits until they're no longer running, then completes +Filename: "{cmd}"; Parameters: "/c timeout 5"; Flags: runhidden + +[UninstallDelete] +Type: filesandordirs; Name: "{%TEMP}\ollama*" +Type: filesandordirs; Name: "{%LOCALAPPDATA}\Ollama" +Type: filesandordirs; Name: "{%LOCALAPPDATA}\Programs\Ollama" +Type: filesandordirs; Name: "{%USERPROFILE}\.ollama" +; NOTE: if the user has a custom OLLAMA_MODELS it will be preserved + +[Messages] +WizardReady=Welcome to Ollama Windows Preview +ReadyLabel1=%nLet's get you up and running with your own large language models. +;ReadyLabel2b=We'll be installing Ollama in your user account without requiring Admin permissions + +;FinishedHeadingLabel=Run your first model +;FinishedLabel=%nRun this command in a PowerShell or cmd terminal.%n%n%n ollama run llama2 +;ClickFinish=%n + +[Registry] +Root: HKCU; Subkey: "Environment"; \ + ValueType: expandsz; ValueName: "Path"; ValueData: "{olddata};{app}"; \ + Check: NeedsAddPath('{app}') + +[Code] + +function NeedsAddPath(Param: string): boolean; +var + OrigPath: string; +begin + if not RegQueryStringValue(HKEY_CURRENT_USER, + 'Environment', + 'Path', OrigPath) + then begin + Result := True; + exit; + end; + { look for the path with leading and trailing semicolon } + { Pos() returns 0 if not found } + Result := Pos(';' + ExpandConstant(Param) + ';', ';' + OrigPath + ';') = 0; +end; diff --git a/app/ollama_welcome.ps1 b/app/ollama_welcome.ps1 new file mode 100644 index 00000000..e7056952 --- /dev/null +++ b/app/ollama_welcome.ps1 @@ -0,0 +1,8 @@ +# TODO - consider ANSI colors and maybe ASCII art... +write-host "" +write-host "Welcome to Ollama!" +write-host "" +write-host "Run your first model:" +write-host "" +write-host "`tollama run llama2" +write-host "" \ No newline at end of file diff --git a/app/store/store.go b/app/store/store.go new file mode 100644 index 00000000..13a75a60 --- /dev/null +++ b/app/store/store.go @@ -0,0 +1,98 @@ +package store + +import ( + "encoding/json" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + "sync" + + "github.com/google/uuid" +) + +type Store struct { + ID string `json:"id"` + FirstTimeRun bool `json:"first-time-run"` +} + +var ( + lock sync.Mutex + store Store +) + +func GetID() string { + lock.Lock() + defer lock.Unlock() + if store.ID == "" { + initStore() + } + return store.ID + +} + +func GetFirstTimeRun() bool { + lock.Lock() + defer lock.Unlock() + if store.ID == "" { + initStore() + } + return store.FirstTimeRun +} + +func SetFirstTimeRun(val bool) { + lock.Lock() + defer lock.Unlock() + if store.FirstTimeRun == val { + return + } + store.FirstTimeRun = val + writeStore(getStorePath()) +} + +// lock must be held +func initStore() { + storeFile, err := os.Open(getStorePath()) + if err == nil { + defer storeFile.Close() + err = json.NewDecoder(storeFile).Decode(&store) + if err == nil { + slog.Debug(fmt.Sprintf("loaded existing store %s - ID: %s", getStorePath(), store.ID)) + return + } + } else if !errors.Is(err, os.ErrNotExist) { + slog.Debug(fmt.Sprintf("unexpected error searching for store: %s", err)) + } + slog.Debug("initializing new store") + store.ID = uuid.New().String() + writeStore(getStorePath()) +} + +func writeStore(storeFilename string) { + ollamaDir := filepath.Dir(storeFilename) + _, err := os.Stat(ollamaDir) + if errors.Is(err, os.ErrNotExist) { + if err := os.MkdirAll(ollamaDir, 0o755); err != nil { + slog.Error(fmt.Sprintf("create ollama dir %s: %v", ollamaDir, err)) + return + } + } + payload, err := json.Marshal(store) + if err != nil { + slog.Error(fmt.Sprintf("failed to marshal store: %s", err)) + return + } + fp, err := os.OpenFile(storeFilename, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755) + if err != nil { + slog.Error(fmt.Sprintf("write store payload %s: %v", storeFilename, err)) + return + } + defer fp.Close() + if n, err := fp.Write(payload); err != nil || n != len(payload) { + slog.Error(fmt.Sprintf("write store payload %s: %d vs %d -- %v", storeFilename, n, len(payload), err)) + return + } + slog.Debug("Store contents: " + string(payload)) + slog.Info(fmt.Sprintf("wrote store: %s", storeFilename)) +} diff --git a/app/store/store_darwin.go b/app/store/store_darwin.go new file mode 100644 index 00000000..e53d8525 --- /dev/null +++ b/app/store/store_darwin.go @@ -0,0 +1,13 @@ +package store + +import ( + "os" + "path/filepath" +) + +func getStorePath() string { + // TODO - system wide location? + + home := os.Getenv("HOME") + return filepath.Join(home, "Library", "Application Support", "Ollama", "config.json") +} diff --git a/app/store/store_linux.go b/app/store/store_linux.go new file mode 100644 index 00000000..3aac9b01 --- /dev/null +++ b/app/store/store_linux.go @@ -0,0 +1,16 @@ +package store + +import ( + "os" + "path/filepath" +) + +func getStorePath() string { + if os.Geteuid() == 0 { + // TODO where should we store this on linux for system-wide operation? + return "/etc/ollama/config.json" + } + + home := os.Getenv("HOME") + return filepath.Join(home, ".ollama", "config.json") +} diff --git a/app/store/store_windows.go b/app/store/store_windows.go new file mode 100644 index 00000000..ba06b82c --- /dev/null +++ b/app/store/store_windows.go @@ -0,0 +1,11 @@ +package store + +import ( + "os" + "path/filepath" +) + +func getStorePath() string { + localAppData := os.Getenv("LOCALAPPDATA") + return filepath.Join(localAppData, "Ollama", "config.json") +} diff --git a/app/tray/commontray/types.go b/app/tray/commontray/types.go new file mode 100644 index 00000000..ed633dc9 --- /dev/null +++ b/app/tray/commontray/types.go @@ -0,0 +1,24 @@ +package commontray + +var ( + Title = "Ollama" + ToolTip = "Ollama" + + UpdateIconName = "tray_upgrade" + IconName = "tray" +) + +type Callbacks struct { + Quit chan struct{} + Update chan struct{} + DoFirstUse chan struct{} + ShowLogs chan struct{} +} + +type OllamaTray interface { + GetCallbacks() Callbacks + Run() + UpdateAvailable(ver string) error + DisplayFirstUseNotification() error + Quit() +} diff --git a/app/tray/tray.go b/app/tray/tray.go new file mode 100644 index 00000000..47b204d6 --- /dev/null +++ b/app/tray/tray.go @@ -0,0 +1,33 @@ +package tray + +import ( + "fmt" + "runtime" + + "github.com/jmorganca/ollama/app/assets" + "github.com/jmorganca/ollama/app/tray/commontray" +) + +func NewTray() (commontray.OllamaTray, error) { + extension := ".png" + if runtime.GOOS == "windows" { + extension = ".ico" + } + iconName := commontray.UpdateIconName + extension + updateIcon, err := assets.GetIcon(iconName) + if err != nil { + return nil, fmt.Errorf("failed to load icon %s: %w", iconName, err) + } + iconName = commontray.IconName + extension + icon, err := assets.GetIcon(iconName) + if err != nil { + return nil, fmt.Errorf("failed to load icon %s: %w", iconName, err) + } + + tray, err := InitPlatformTray(icon, updateIcon) + if err != nil { + return nil, err + } + + return tray, nil +} diff --git a/app/tray/tray_nonwindows.go b/app/tray/tray_nonwindows.go new file mode 100644 index 00000000..6c30c3c2 --- /dev/null +++ b/app/tray/tray_nonwindows.go @@ -0,0 +1,13 @@ +//go:build !windows + +package tray + +import ( + "fmt" + + "github.com/jmorganca/ollama/app/tray/commontray" +) + +func InitPlatformTray(icon, updateIcon []byte) (commontray.OllamaTray, error) { + return nil, fmt.Errorf("NOT IMPLEMENTED YET") +} diff --git a/app/tray/tray_windows.go b/app/tray/tray_windows.go new file mode 100644 index 00000000..8ac4e478 --- /dev/null +++ b/app/tray/tray_windows.go @@ -0,0 +1,10 @@ +package tray + +import ( + "github.com/jmorganca/ollama/app/tray/commontray" + "github.com/jmorganca/ollama/app/tray/wintray" +) + +func InitPlatformTray(icon, updateIcon []byte) (commontray.OllamaTray, error) { + return wintray.InitTray(icon, updateIcon) +} diff --git a/app/tray/wintray/eventloop.go b/app/tray/wintray/eventloop.go new file mode 100644 index 00000000..958b7871 --- /dev/null +++ b/app/tray/wintray/eventloop.go @@ -0,0 +1,189 @@ +//go:build windows + +package wintray + +import ( + "fmt" + "log/slog" + "sync" + "unsafe" + + "golang.org/x/sys/windows" +) + +var ( + quitOnce sync.Once +) + +func (t *winTray) Run() { + nativeLoop() +} + +func nativeLoop() { + // Main message pump. + slog.Debug("starting event handling loop") + m := &struct { + WindowHandle windows.Handle + Message uint32 + Wparam uintptr + Lparam uintptr + Time uint32 + Pt point + LPrivate uint32 + }{} + for { + ret, _, err := pGetMessage.Call(uintptr(unsafe.Pointer(m)), 0, 0, 0) + + // If the function retrieves a message other than WM_QUIT, the return value is nonzero. + // If the function retrieves the WM_QUIT message, the return value is zero. + // If there is an error, the return value is -1 + // https://msdn.microsoft.com/en-us/library/windows/desktop/ms644936(v=vs.85).aspx + switch int32(ret) { + case -1: + slog.Error(fmt.Sprintf("get message failure: %v", err)) + return + case 0: + return + default: + // slog.Debug(fmt.Sprintf("XXX dispatching message from run loop 0x%x", m.Message)) + pTranslateMessage.Call(uintptr(unsafe.Pointer(m))) //nolint:errcheck + pDispatchMessage.Call(uintptr(unsafe.Pointer(m))) //nolint:errcheck + + } + } +} + +// WindowProc callback function that processes messages sent to a window. +// https://msdn.microsoft.com/en-us/library/windows/desktop/ms633573(v=vs.85).aspx +func (t *winTray) wndProc(hWnd windows.Handle, message uint32, wParam, lParam uintptr) (lResult uintptr) { + const ( + WM_RBUTTONUP = 0x0205 + WM_LBUTTONUP = 0x0202 + WM_COMMAND = 0x0111 + WM_ENDSESSION = 0x0016 + WM_CLOSE = 0x0010 + WM_DESTROY = 0x0002 + WM_MOUSEMOVE = 0x0200 + WM_LBUTTONDOWN = 0x0201 + ) + // slog.Debug(fmt.Sprintf("XXX in wndProc: 0x%x", message)) + switch message { + case WM_COMMAND: + menuItemId := int32(wParam) + // slog.Debug(fmt.Sprintf("XXX Menu Click: %d", menuItemId)) + // https://docs.microsoft.com/en-us/windows/win32/menurc/wm-command#menus + switch menuItemId { + case quitMenuID: + select { + case t.callbacks.Quit <- struct{}{}: + // should not happen but in case not listening + default: + slog.Error("no listener on Quit") + } + case updateMenuID: + select { + case t.callbacks.Update <- struct{}{}: + // should not happen but in case not listening + default: + slog.Error("no listener on Update") + } + case diagLogsMenuID: + select { + case t.callbacks.ShowLogs <- struct{}{}: + // should not happen but in case not listening + default: + slog.Error("no listener on ShowLogs") + } + default: + slog.Debug(fmt.Sprintf("Unexpected menu item id: %d", menuItemId)) + } + case WM_CLOSE: + boolRet, _, err := pDestroyWindow.Call(uintptr(t.window)) + if boolRet == 0 { + slog.Error(fmt.Sprintf("failed to destroy window: %s", err)) + } + err = t.wcex.unregister() + if err != nil { + slog.Error(fmt.Sprintf("failed to uregister windo %s", err)) + } + case WM_DESTROY: + // same as WM_ENDSESSION, but throws 0 exit code after all + defer pPostQuitMessage.Call(uintptr(int32(0))) //nolint:errcheck + fallthrough + case WM_ENDSESSION: + t.muNID.Lock() + if t.nid != nil { + err := t.nid.delete() + if err != nil { + slog.Error(fmt.Sprintf("failed to delete nid: %s", err)) + } + } + t.muNID.Unlock() + case t.wmSystrayMessage: + switch lParam { + case WM_MOUSEMOVE, WM_LBUTTONDOWN: + // Ignore these... + case WM_RBUTTONUP, WM_LBUTTONUP: + err := t.showMenu() + if err != nil { + slog.Error(fmt.Sprintf("failed to show menu: %s", err)) + } + case 0x405: // TODO - how is this magic value derived for the notification left click + if t.pendingUpdate { + select { + case t.callbacks.Update <- struct{}{}: + // should not happen but in case not listening + default: + slog.Error("no listener on Update") + } + } else { + select { + case t.callbacks.DoFirstUse <- struct{}{}: + // should not happen but in case not listening + default: + slog.Error("no listener on DoFirstUse") + } + } + case 0x404: // Middle click or close notification + // slog.Debug("doing nothing on close of first time notification") + default: + // 0x402 also seems common - what is it? + slog.Debug(fmt.Sprintf("unmanaged app message, lParm: 0x%x", lParam)) + } + case t.wmTaskbarCreated: // on explorer.exe restarts + slog.Debug("XXX got taskbar created event") + t.muNID.Lock() + err := t.nid.add() + if err != nil { + slog.Error(fmt.Sprintf("failed to refresh the taskbar on explorer restart: %s", err)) + } + t.muNID.Unlock() + default: + // Calls the default window procedure to provide default processing for any window messages that an application does not process. + // https://msdn.microsoft.com/en-us/library/windows/desktop/ms633572(v=vs.85).aspx + // slog.Debug(fmt.Sprintf("XXX default wndProc handler 0x%x", message)) + lResult, _, _ = pDefWindowProc.Call( + uintptr(hWnd), + uintptr(message), + uintptr(wParam), + uintptr(lParam), + ) + } + return +} + +func (t *winTray) Quit() { + quitOnce.Do(quit) +} + +func quit() { + boolRet, _, err := pPostMessage.Call( + uintptr(wt.window), + WM_CLOSE, + 0, + 0, + ) + if boolRet == 0 { + slog.Error(fmt.Sprintf("failed to post close message on shutdown %s", err)) + } +} diff --git a/app/tray/wintray/menus.go b/app/tray/wintray/menus.go new file mode 100644 index 00000000..efbb8e89 --- /dev/null +++ b/app/tray/wintray/menus.go @@ -0,0 +1,75 @@ +//go:build windows + +package wintray + +import ( + "fmt" + "log/slog" + "os" + "unsafe" + + "golang.org/x/sys/windows" +) + +const ( + updatAvailableMenuID = 1 + updateMenuID = updatAvailableMenuID + 1 + separatorMenuID = updateMenuID + 1 + diagLogsMenuID = separatorMenuID + 1 + diagSeparatorMenuID = diagLogsMenuID + 1 + quitMenuID = diagSeparatorMenuID + 1 +) + +func (t *winTray) initMenus() error { + if debug := os.Getenv("OLLAMA_DEBUG"); debug != "" { + if err := t.addOrUpdateMenuItem(diagLogsMenuID, 0, diagLogsMenuTitle, false); err != nil { + return fmt.Errorf("unable to create menu entries %w\n", err) + } + if err := t.addSeparatorMenuItem(diagSeparatorMenuID, 0); err != nil { + return fmt.Errorf("unable to create menu entries %w", err) + } + + } + if err := t.addOrUpdateMenuItem(quitMenuID, 0, quitMenuTitle, false); err != nil { + return fmt.Errorf("unable to create menu entries %w\n", err) + } + return nil +} + +func (t *winTray) UpdateAvailable(ver string) error { + slog.Debug("updating menu and sending notification for new update") + if err := t.addOrUpdateMenuItem(updatAvailableMenuID, 0, updateAvailableMenuTitle, true); err != nil { + return fmt.Errorf("unable to create menu entries %w", err) + } + if err := t.addOrUpdateMenuItem(updateMenuID, 0, updateMenutTitle, false); err != nil { + return fmt.Errorf("unable to create menu entries %w", err) + } + if err := t.addSeparatorMenuItem(separatorMenuID, 0); err != nil { + return fmt.Errorf("unable to create menu entries %w", err) + } + iconFilePath, err := iconBytesToFilePath(wt.updateIcon) + if err != nil { + return fmt.Errorf("unable to write icon data to temp file: %w", err) + } + if err := wt.setIcon(iconFilePath); err != nil { + return fmt.Errorf("unable to set icon: %w", err) + } + + t.pendingUpdate = true + // Now pop up the notification + if !t.updateNotified { + t.muNID.Lock() + defer t.muNID.Unlock() + copy(t.nid.InfoTitle[:], windows.StringToUTF16(updateTitle)) + copy(t.nid.Info[:], windows.StringToUTF16(fmt.Sprintf(updateMessage, ver))) + t.nid.Flags |= NIF_INFO + t.nid.Timeout = 10 + t.nid.Size = uint32(unsafe.Sizeof(*wt.nid)) + err = t.nid.modify() + if err != nil { + return err + } + t.updateNotified = true + } + return nil +} diff --git a/app/tray/wintray/messages.go b/app/tray/wintray/messages.go new file mode 100644 index 00000000..d364c716 --- /dev/null +++ b/app/tray/wintray/messages.go @@ -0,0 +1,15 @@ +//go:build windows + +package wintray + +const ( + firstTimeTitle = "Ollama is running" + firstTimeMessage = "Click here to get started" + updateTitle = "Update available" + updateMessage = "Ollama version %s is ready to install" + + quitMenuTitle = "Quit Ollama" + updateAvailableMenuTitle = "An update is available" + updateMenutTitle = "Restart to update" + diagLogsMenuTitle = "View logs" +) diff --git a/app/tray/wintray/notifyicon.go b/app/tray/wintray/notifyicon.go new file mode 100644 index 00000000..47071669 --- /dev/null +++ b/app/tray/wintray/notifyicon.go @@ -0,0 +1,66 @@ +//go:build windows + +package wintray + +import ( + "unsafe" + + "golang.org/x/sys/windows" +) + +// Contains information that the system needs to display notifications in the notification area. +// Used by Shell_NotifyIcon. +// https://msdn.microsoft.com/en-us/library/windows/desktop/bb773352(v=vs.85).aspx +// https://msdn.microsoft.com/en-us/library/windows/desktop/bb762159 +type notifyIconData struct { + Size uint32 + Wnd windows.Handle + ID, Flags, CallbackMessage uint32 + Icon windows.Handle + Tip [128]uint16 + State, StateMask uint32 + Info [256]uint16 + // Timeout, Version uint32 + Timeout uint32 + + InfoTitle [64]uint16 + InfoFlags uint32 + GuidItem windows.GUID + BalloonIcon windows.Handle +} + +func (nid *notifyIconData) add() error { + const NIM_ADD = 0x00000000 + res, _, err := pShellNotifyIcon.Call( + uintptr(NIM_ADD), + uintptr(unsafe.Pointer(nid)), + ) + if res == 0 { + return err + } + return nil +} + +func (nid *notifyIconData) modify() error { + const NIM_MODIFY = 0x00000001 + res, _, err := pShellNotifyIcon.Call( + uintptr(NIM_MODIFY), + uintptr(unsafe.Pointer(nid)), + ) + if res == 0 { + return err + } + return nil +} + +func (nid *notifyIconData) delete() error { + const NIM_DELETE = 0x00000002 + res, _, err := pShellNotifyIcon.Call( + uintptr(NIM_DELETE), + uintptr(unsafe.Pointer(nid)), + ) + if res == 0 { + return err + } + return nil +} diff --git a/app/tray/wintray/tray.go b/app/tray/wintray/tray.go new file mode 100644 index 00000000..365cfb82 --- /dev/null +++ b/app/tray/wintray/tray.go @@ -0,0 +1,485 @@ +//go:build windows + +package wintray + +import ( + "crypto/md5" + "encoding/hex" + "fmt" + "log/slog" + "os" + "path/filepath" + "sort" + "sync" + "unsafe" + + "github.com/jmorganca/ollama/app/tray/commontray" + "golang.org/x/sys/windows" +) + +// Helpful sources: https://github.com/golang/exp/blob/master/shiny/driver/internal/win32 + +// Contains information about loaded resources +type winTray struct { + instance, + icon, + cursor, + window windows.Handle + + loadedImages map[string]windows.Handle + muLoadedImages sync.RWMutex + + // menus keeps track of the submenus keyed by the menu item ID, plus 0 + // which corresponds to the main popup menu. + menus map[uint32]windows.Handle + muMenus sync.RWMutex + menuOf map[uint32]windows.Handle + muMenuOf sync.RWMutex + // menuItemIcons maintains the bitmap of each menu item (if applies). It's + // needed to show the icon correctly when showing a previously hidden menu + // item again. + // menuItemIcons map[uint32]windows.Handle + // muMenuItemIcons sync.RWMutex + visibleItems map[uint32][]uint32 + muVisibleItems sync.RWMutex + + nid *notifyIconData + muNID sync.RWMutex + wcex *wndClassEx + + wmSystrayMessage, + wmTaskbarCreated uint32 + + pendingUpdate bool + updateNotified bool // Only pop up the notification once - TODO consider daily nag? + // Callbacks + callbacks commontray.Callbacks + normalIcon []byte + updateIcon []byte +} + +var wt winTray + +func (t *winTray) GetCallbacks() commontray.Callbacks { + return t.callbacks +} + +func InitTray(icon, updateIcon []byte) (*winTray, error) { + wt.callbacks.Quit = make(chan struct{}) + wt.callbacks.Update = make(chan struct{}) + wt.callbacks.ShowLogs = make(chan struct{}) + wt.callbacks.DoFirstUse = make(chan struct{}) + wt.normalIcon = icon + wt.updateIcon = updateIcon + if err := wt.initInstance(); err != nil { + return nil, fmt.Errorf("Unable to init instance: %w\n", err) + } + + if err := wt.createMenu(); err != nil { + return nil, fmt.Errorf("Unable to create menu: %w\n", err) + } + + iconFilePath, err := iconBytesToFilePath(wt.normalIcon) + if err != nil { + return nil, fmt.Errorf("Unable to write icon data to temp file: %w", err) + } + if err := wt.setIcon(iconFilePath); err != nil { + return nil, fmt.Errorf("Unable to set icon: %w", err) + } + + return &wt, wt.initMenus() +} + +func (t *winTray) initInstance() error { + const ( + className = "OllamaClass" + windowName = "" + ) + + t.wmSystrayMessage = WM_USER + 1 + t.visibleItems = make(map[uint32][]uint32) + t.menus = make(map[uint32]windows.Handle) + t.menuOf = make(map[uint32]windows.Handle) + + t.loadedImages = make(map[string]windows.Handle) + + taskbarEventNamePtr, _ := windows.UTF16PtrFromString("TaskbarCreated") + // https://msdn.microsoft.com/en-us/library/windows/desktop/ms644947 + res, _, err := pRegisterWindowMessage.Call( + uintptr(unsafe.Pointer(taskbarEventNamePtr)), + ) + if res == 0 { // success 0xc000-0xfff + return fmt.Errorf("failed to register window: %w", err) + } + t.wmTaskbarCreated = uint32(res) + + instanceHandle, _, err := pGetModuleHandle.Call(0) + if instanceHandle == 0 { + return err + } + t.instance = windows.Handle(instanceHandle) + + // https://msdn.microsoft.com/en-us/library/windows/desktop/ms648072(v=vs.85).aspx + iconHandle, _, err := pLoadIcon.Call(0, uintptr(IDI_APPLICATION)) + if iconHandle == 0 { + return err + } + t.icon = windows.Handle(iconHandle) + + // https://msdn.microsoft.com/en-us/library/windows/desktop/ms648391(v=vs.85).aspx + cursorHandle, _, err := pLoadCursor.Call(0, uintptr(IDC_ARROW)) + if cursorHandle == 0 { + return err + } + t.cursor = windows.Handle(cursorHandle) + + classNamePtr, err := windows.UTF16PtrFromString(className) + if err != nil { + return err + } + + windowNamePtr, err := windows.UTF16PtrFromString(windowName) + if err != nil { + return err + } + + t.wcex = &wndClassEx{ + Style: CS_HREDRAW | CS_VREDRAW, + WndProc: windows.NewCallback(t.wndProc), + Instance: t.instance, + Icon: t.icon, + Cursor: t.cursor, + Background: windows.Handle(6), // (COLOR_WINDOW + 1) + ClassName: classNamePtr, + IconSm: t.icon, + } + if err := t.wcex.register(); err != nil { + return err + } + + windowHandle, _, err := pCreateWindowEx.Call( + uintptr(0), + uintptr(unsafe.Pointer(classNamePtr)), + uintptr(unsafe.Pointer(windowNamePtr)), + uintptr(WS_OVERLAPPEDWINDOW), + uintptr(CW_USEDEFAULT), + uintptr(CW_USEDEFAULT), + uintptr(CW_USEDEFAULT), + uintptr(CW_USEDEFAULT), + uintptr(0), + uintptr(0), + uintptr(t.instance), + uintptr(0), + ) + if windowHandle == 0 { + return err + } + t.window = windows.Handle(windowHandle) + + pShowWindow.Call(uintptr(t.window), uintptr(SW_HIDE)) //nolint:errcheck + + boolRet, _, err := pUpdateWindow.Call(uintptr(t.window)) + if boolRet == 0 { + slog.Error(fmt.Sprintf("failed to update window: %s", err)) + } + + t.muNID.Lock() + defer t.muNID.Unlock() + t.nid = ¬ifyIconData{ + Wnd: windows.Handle(t.window), + ID: 100, + Flags: NIF_MESSAGE, + CallbackMessage: t.wmSystrayMessage, + } + t.nid.Size = uint32(unsafe.Sizeof(*t.nid)) + + return t.nid.add() +} + +func (t *winTray) createMenu() error { + + menuHandle, _, err := pCreatePopupMenu.Call() + if menuHandle == 0 { + return err + } + t.menus[0] = windows.Handle(menuHandle) + + // https://msdn.microsoft.com/en-us/library/windows/desktop/ms647575(v=vs.85).aspx + mi := struct { + Size, Mask, Style, Max uint32 + Background windows.Handle + ContextHelpID uint32 + MenuData uintptr + }{ + Mask: MIM_APPLYTOSUBMENUS, + } + mi.Size = uint32(unsafe.Sizeof(mi)) + + res, _, err := pSetMenuInfo.Call( + uintptr(t.menus[0]), + uintptr(unsafe.Pointer(&mi)), + ) + if res == 0 { + return err + } + return nil +} + +// Contains information about a menu item. +// https://msdn.microsoft.com/en-us/library/windows/desktop/ms647578(v=vs.85).aspx +type menuItemInfo struct { + Size, Mask, Type, State uint32 + ID uint32 + SubMenu, Checked, Unchecked windows.Handle + ItemData uintptr + TypeData *uint16 + Cch uint32 + BMPItem windows.Handle +} + +func (t *winTray) addOrUpdateMenuItem(menuItemId uint32, parentId uint32, title string, disabled bool) error { + titlePtr, err := windows.UTF16PtrFromString(title) + if err != nil { + return err + } + + mi := menuItemInfo{ + Mask: MIIM_FTYPE | MIIM_STRING | MIIM_ID | MIIM_STATE, + Type: MFT_STRING, + ID: uint32(menuItemId), + TypeData: titlePtr, + Cch: uint32(len(title)), + } + mi.Size = uint32(unsafe.Sizeof(mi)) + if disabled { + mi.State |= MFS_DISABLED + } + + var res uintptr + t.muMenus.RLock() + menu := t.menus[parentId] + t.muMenus.RUnlock() + if t.getVisibleItemIndex(parentId, menuItemId) != -1 { + // We set the menu item info based on the menuID + boolRet, _, err := pSetMenuItemInfo.Call( + uintptr(menu), + uintptr(menuItemId), + 0, + uintptr(unsafe.Pointer(&mi)), + ) + if boolRet == 0 { + return fmt.Errorf("failed to set menu item: %w", err) + } + } + + if res == 0 { + // Menu item does not already exist, create it + t.muMenus.RLock() + submenu, exists := t.menus[menuItemId] + t.muMenus.RUnlock() + if exists { + mi.Mask |= MIIM_SUBMENU + mi.SubMenu = submenu + } + t.addToVisibleItems(parentId, menuItemId) + position := t.getVisibleItemIndex(parentId, menuItemId) + res, _, err = pInsertMenuItem.Call( + uintptr(menu), + uintptr(position), + 1, + uintptr(unsafe.Pointer(&mi)), + ) + if res == 0 { + t.delFromVisibleItems(parentId, menuItemId) + return err + } + t.muMenuOf.Lock() + t.menuOf[menuItemId] = menu + t.muMenuOf.Unlock() + } + + return nil +} + +func (t *winTray) addSeparatorMenuItem(menuItemId, parentId uint32) error { + + mi := menuItemInfo{ + Mask: MIIM_FTYPE | MIIM_ID | MIIM_STATE, + Type: MFT_SEPARATOR, + ID: uint32(menuItemId), + } + + mi.Size = uint32(unsafe.Sizeof(mi)) + + t.addToVisibleItems(parentId, menuItemId) + position := t.getVisibleItemIndex(parentId, menuItemId) + t.muMenus.RLock() + menu := uintptr(t.menus[parentId]) + t.muMenus.RUnlock() + res, _, err := pInsertMenuItem.Call( + menu, + uintptr(position), + 1, + uintptr(unsafe.Pointer(&mi)), + ) + if res == 0 { + return err + } + + return nil +} + +// func (t *winTray) hideMenuItem(menuItemId, parentId uint32) error { +// const ERROR_SUCCESS syscall.Errno = 0 + +// t.muMenus.RLock() +// menu := uintptr(t.menus[parentId]) +// t.muMenus.RUnlock() +// res, _, err := pRemoveMenu.Call( +// menu, +// uintptr(menuItemId), +// MF_BYCOMMAND, +// ) +// if res == 0 && err.(syscall.Errno) != ERROR_SUCCESS { +// return err +// } +// t.delFromVisibleItems(parentId, menuItemId) + +// return nil +// } + +func (t *winTray) showMenu() error { + p := point{} + boolRet, _, err := pGetCursorPos.Call(uintptr(unsafe.Pointer(&p))) + if boolRet == 0 { + return err + } + boolRet, _, err = pSetForegroundWindow.Call(uintptr(t.window)) + if boolRet == 0 { + slog.Warn(fmt.Sprintf("failed to bring menu to foreground: %s", err)) + } + + boolRet, _, err = pTrackPopupMenu.Call( + uintptr(t.menus[0]), + TPM_BOTTOMALIGN|TPM_LEFTALIGN, + uintptr(p.X), + uintptr(p.Y), + 0, + uintptr(t.window), + 0, + ) + if boolRet == 0 { + return err + } + + return nil +} + +func (t *winTray) delFromVisibleItems(parent, val uint32) { + t.muVisibleItems.Lock() + defer t.muVisibleItems.Unlock() + visibleItems := t.visibleItems[parent] + for i, itemval := range visibleItems { + if val == itemval { + t.visibleItems[parent] = append(visibleItems[:i], visibleItems[i+1:]...) + break + } + } +} + +func (t *winTray) addToVisibleItems(parent, val uint32) { + t.muVisibleItems.Lock() + defer t.muVisibleItems.Unlock() + if visibleItems, exists := t.visibleItems[parent]; !exists { + t.visibleItems[parent] = []uint32{val} + } else { + newvisible := append(visibleItems, val) + sort.Slice(newvisible, func(i, j int) bool { return newvisible[i] < newvisible[j] }) + t.visibleItems[parent] = newvisible + } +} + +func (t *winTray) getVisibleItemIndex(parent, val uint32) int { + t.muVisibleItems.RLock() + defer t.muVisibleItems.RUnlock() + for i, itemval := range t.visibleItems[parent] { + if val == itemval { + return i + } + } + return -1 +} + +func iconBytesToFilePath(iconBytes []byte) (string, error) { + bh := md5.Sum(iconBytes) + dataHash := hex.EncodeToString(bh[:]) + iconFilePath := filepath.Join(os.TempDir(), "ollama_temp_icon_"+dataHash) + + if _, err := os.Stat(iconFilePath); os.IsNotExist(err) { + if err := os.WriteFile(iconFilePath, iconBytes, 0644); err != nil { + return "", err + } + } + return iconFilePath, nil +} + +// Loads an image from file and shows it in tray. +// Shell_NotifyIcon: https://msdn.microsoft.com/en-us/library/windows/desktop/bb762159(v=vs.85).aspx +func (t *winTray) setIcon(src string) error { + + h, err := t.loadIconFrom(src) + if err != nil { + return err + } + + t.muNID.Lock() + defer t.muNID.Unlock() + t.nid.Icon = h + t.nid.Flags |= NIF_ICON + t.nid.Size = uint32(unsafe.Sizeof(*t.nid)) + + return t.nid.modify() +} + +// Loads an image from file to be shown in tray or menu item. +// LoadImage: https://msdn.microsoft.com/en-us/library/windows/desktop/ms648045(v=vs.85).aspx +func (t *winTray) loadIconFrom(src string) (windows.Handle, error) { + + // Save and reuse handles of loaded images + t.muLoadedImages.RLock() + h, ok := t.loadedImages[src] + t.muLoadedImages.RUnlock() + if !ok { + srcPtr, err := windows.UTF16PtrFromString(src) + if err != nil { + return 0, err + } + res, _, err := pLoadImage.Call( + 0, + uintptr(unsafe.Pointer(srcPtr)), + IMAGE_ICON, + 0, + 0, + LR_LOADFROMFILE|LR_DEFAULTSIZE, + ) + if res == 0 { + return 0, err + } + h = windows.Handle(res) + t.muLoadedImages.Lock() + t.loadedImages[src] = h + t.muLoadedImages.Unlock() + } + return h, nil +} + +func (t *winTray) DisplayFirstUseNotification() error { + t.muNID.Lock() + defer t.muNID.Unlock() + copy(t.nid.InfoTitle[:], windows.StringToUTF16(firstTimeTitle)) + copy(t.nid.Info[:], windows.StringToUTF16(firstTimeMessage)) + t.nid.Flags |= NIF_INFO + t.nid.Size = uint32(unsafe.Sizeof(*wt.nid)) + + return t.nid.modify() +} diff --git a/app/tray/wintray/w32api.go b/app/tray/wintray/w32api.go new file mode 100644 index 00000000..a1e0381d --- /dev/null +++ b/app/tray/wintray/w32api.go @@ -0,0 +1,89 @@ +//go:build windows + +package wintray + +import ( + "runtime" + + "golang.org/x/sys/windows" +) + +var ( + k32 = windows.NewLazySystemDLL("Kernel32.dll") + u32 = windows.NewLazySystemDLL("User32.dll") + s32 = windows.NewLazySystemDLL("Shell32.dll") + + pCreatePopupMenu = u32.NewProc("CreatePopupMenu") + pCreateWindowEx = u32.NewProc("CreateWindowExW") + pDefWindowProc = u32.NewProc("DefWindowProcW") + pDestroyWindow = u32.NewProc("DestroyWindow") + pDispatchMessage = u32.NewProc("DispatchMessageW") + pGetCursorPos = u32.NewProc("GetCursorPos") + pGetMessage = u32.NewProc("GetMessageW") + pGetModuleHandle = k32.NewProc("GetModuleHandleW") + pInsertMenuItem = u32.NewProc("InsertMenuItemW") + pLoadCursor = u32.NewProc("LoadCursorW") + pLoadIcon = u32.NewProc("LoadIconW") + pLoadImage = u32.NewProc("LoadImageW") + pPostMessage = u32.NewProc("PostMessageW") + pPostQuitMessage = u32.NewProc("PostQuitMessage") + pRegisterClass = u32.NewProc("RegisterClassExW") + pRegisterWindowMessage = u32.NewProc("RegisterWindowMessageW") + pSetForegroundWindow = u32.NewProc("SetForegroundWindow") + pSetMenuInfo = u32.NewProc("SetMenuInfo") + pSetMenuItemInfo = u32.NewProc("SetMenuItemInfoW") + pShellNotifyIcon = s32.NewProc("Shell_NotifyIconW") + pShowWindow = u32.NewProc("ShowWindow") + pTrackPopupMenu = u32.NewProc("TrackPopupMenu") + pTranslateMessage = u32.NewProc("TranslateMessage") + pUnregisterClass = u32.NewProc("UnregisterClassW") + pUpdateWindow = u32.NewProc("UpdateWindow") +) + +const ( + CS_HREDRAW = 0x0002 + CS_VREDRAW = 0x0001 + CW_USEDEFAULT = 0x80000000 + IDC_ARROW = 32512 // Standard arrow + IDI_APPLICATION = 32512 + IMAGE_ICON = 1 // Loads an icon + LR_DEFAULTSIZE = 0x00000040 // Loads default-size icon for windows(SM_CXICON x SM_CYICON) if cx, cy are set to zero + LR_LOADFROMFILE = 0x00000010 // Loads the stand-alone image from the file + MF_BYCOMMAND = 0x00000000 + MFS_DISABLED = 0x00000003 + MFT_SEPARATOR = 0x00000800 + MFT_STRING = 0x00000000 + MIIM_BITMAP = 0x00000080 + MIIM_FTYPE = 0x00000100 + MIIM_ID = 0x00000002 + MIIM_STATE = 0x00000001 + MIIM_STRING = 0x00000040 + MIIM_SUBMENU = 0x00000004 + MIM_APPLYTOSUBMENUS = 0x80000000 + NIF_ICON = 0x00000002 + NIF_INFO = 0x00000010 + NIF_MESSAGE = 0x00000001 + SW_HIDE = 0 + TPM_BOTTOMALIGN = 0x0020 + TPM_LEFTALIGN = 0x0000 + WM_CLOSE = 0x0010 + WM_USER = 0x0400 + WS_CAPTION = 0x00C00000 + WS_MAXIMIZEBOX = 0x00010000 + WS_MINIMIZEBOX = 0x00020000 + WS_OVERLAPPED = 0x00000000 + WS_OVERLAPPEDWINDOW = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_THICKFRAME | WS_MINIMIZEBOX | WS_MAXIMIZEBOX + WS_SYSMENU = 0x00080000 + WS_THICKFRAME = 0x00040000 +) + +// Not sure if this is actually needed on windows +func init() { + runtime.LockOSThread() +} + +// The POINT structure defines the x- and y- coordinates of a point. +// https://msdn.microsoft.com/en-us/library/windows/desktop/dd162805(v=vs.85).aspx +type point struct { + X, Y int32 +} diff --git a/app/tray/wintray/winclass.go b/app/tray/wintray/winclass.go new file mode 100644 index 00000000..9ce71d00 --- /dev/null +++ b/app/tray/wintray/winclass.go @@ -0,0 +1,45 @@ +//go:build windows + +package wintray + +import ( + "unsafe" + + "golang.org/x/sys/windows" +) + +// Contains window class information. +// It is used with the RegisterClassEx and GetClassInfoEx functions. +// https://msdn.microsoft.com/en-us/library/ms633577.aspx +type wndClassEx struct { + Size, Style uint32 + WndProc uintptr + ClsExtra, WndExtra int32 + Instance, Icon, Cursor, Background windows.Handle + MenuName, ClassName *uint16 + IconSm windows.Handle +} + +// Registers a window class for subsequent use in calls to the CreateWindow or CreateWindowEx function. +// https://msdn.microsoft.com/en-us/library/ms633587.aspx +func (w *wndClassEx) register() error { + w.Size = uint32(unsafe.Sizeof(*w)) + res, _, err := pRegisterClass.Call(uintptr(unsafe.Pointer(w))) + if res == 0 { + return err + } + return nil +} + +// Unregisters a window class, freeing the memory required for the class. +// https://msdn.microsoft.com/en-us/library/ms644899.aspx +func (w *wndClassEx) unregister() error { + res, _, err := pUnregisterClass.Call( + uintptr(unsafe.Pointer(w.ClassName)), + uintptr(w.Instance), + ) + if res == 0 { + return err + } + return nil +} diff --git a/cmd/cmd.go b/cmd/cmd.go index 66909c2c..1c7eba43 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -14,10 +14,8 @@ import ( "net" "net/http" "os" - "os/exec" "os/signal" "path/filepath" - "runtime" "strings" "syscall" "time" @@ -754,22 +752,8 @@ func initializeKeypair() error { return nil } -func startMacApp(ctx context.Context, client *api.Client) error { - exe, err := os.Executable() - if err != nil { - return err - } - link, err := os.Readlink(exe) - if err != nil { - return err - } - if !strings.Contains(link, "Ollama.app") { - return fmt.Errorf("could not find ollama app") - } - path := strings.Split(link, "Ollama.app") - if err := exec.Command("/usr/bin/open", "-a", path[0]+"Ollama.app").Run(); err != nil { - return err - } +//nolint:unused +func waitForServer(ctx context.Context, client *api.Client) error { // wait for the server to start timeout := time.After(5 * time.Second) tick := time.Tick(500 * time.Millisecond) @@ -783,6 +767,7 @@ func startMacApp(ctx context.Context, client *api.Client) error { } } } + } func checkServerHeartbeat(cmd *cobra.Command, _ []string) error { @@ -791,15 +776,11 @@ func checkServerHeartbeat(cmd *cobra.Command, _ []string) error { return err } if err := client.Heartbeat(cmd.Context()); err != nil { - if !strings.Contains(err.Error(), "connection refused") { + if !strings.Contains(err.Error(), " refused") { return err } - if runtime.GOOS == "darwin" { - if err := startMacApp(cmd.Context(), client); err != nil { - return fmt.Errorf("could not connect to ollama app, is it running?") - } - } else { - return fmt.Errorf("could not connect to ollama server, run 'ollama serve' to start it") + if err := startApp(cmd.Context(), client); err != nil { + return fmt.Errorf("could not connect to ollama app, is it running?") } } return nil diff --git a/cmd/start_darwin.go b/cmd/start_darwin.go new file mode 100644 index 00000000..7e3000f0 --- /dev/null +++ b/cmd/start_darwin.go @@ -0,0 +1,30 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "os/exec" + "strings" + + "github.com/jmorganca/ollama/api" +) + +func startApp(ctx context.Context, client *api.Client) error { + exe, err := os.Executable() + if err != nil { + return err + } + link, err := os.Readlink(exe) + if err != nil { + return err + } + if !strings.Contains(link, "Ollama.app") { + return fmt.Errorf("could not find ollama app") + } + path := strings.Split(link, "Ollama.app") + if err := exec.Command("/usr/bin/open", "-a", path[0]+"Ollama.app").Run(); err != nil { + return err + } + return waitForServer(ctx, client) +} diff --git a/cmd/start_default.go b/cmd/start_default.go new file mode 100644 index 00000000..664c2d1f --- /dev/null +++ b/cmd/start_default.go @@ -0,0 +1,14 @@ +//go:build !windows && !darwin + +package cmd + +import ( + "context" + "fmt" + + "github.com/jmorganca/ollama/api" +) + +func startApp(ctx context.Context, client *api.Client) error { + return fmt.Errorf("could not connect to ollama server, run 'ollama serve' to start it") +} diff --git a/cmd/start_windows.go b/cmd/start_windows.go new file mode 100644 index 00000000..a24f1c19 --- /dev/null +++ b/cmd/start_windows.go @@ -0,0 +1,81 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + + "golang.org/x/sys/windows" + + "github.com/jmorganca/ollama/api" +) + +func init() { + var inMode uint32 + var outMode uint32 + var errMode uint32 + + in := windows.Handle(os.Stdin.Fd()) + if err := windows.GetConsoleMode(in, &inMode); err == nil { + windows.SetConsoleMode(in, inMode|windows.ENABLE_VIRTUAL_TERMINAL_INPUT) //nolint:errcheck + } + + out := windows.Handle(os.Stdout.Fd()) + if err := windows.GetConsoleMode(out, &outMode); err == nil { + windows.SetConsoleMode(out, outMode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING) //nolint:errcheck + } + + errf := windows.Handle(os.Stderr.Fd()) + if err := windows.GetConsoleMode(errf, &errMode); err == nil { + windows.SetConsoleMode(errf, errMode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING) //nolint:errcheck + } +} + +func startApp(ctx context.Context, client *api.Client) error { + // log.Printf("XXX Attempting to find and start ollama app") + AppName := "ollama app.exe" + exe, err := os.Executable() + if err != nil { + return err + } + appExe := filepath.Join(filepath.Dir(exe), AppName) + _, err = os.Stat(appExe) + if errors.Is(err, os.ErrNotExist) { + // Try the standard install location + localAppData := os.Getenv("LOCALAPPDATA") + appExe = filepath.Join(localAppData, "Ollama", AppName) + _, err := os.Stat(appExe) + if errors.Is(err, os.ErrNotExist) { + // Finally look in the path + appExe, err = exec.LookPath(AppName) + if err != nil { + return fmt.Errorf("could not locate ollama app") + } + } + } + // log.Printf("XXX attempting to start app %s", appExe) + + cmd_path := "c:\\Windows\\system32\\cmd.exe" + cmd := exec.Command(cmd_path, "/c", appExe) + // TODO - these hide flags aren't working - still pops up a command window for some reason + cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: 0x08000000, HideWindow: true} + + // TODO this didn't help either... + cmd.Stdin = strings.NewReader("") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Start(); err != nil { + return fmt.Errorf("unable to start ollama app %w", err) + } + + if cmd.Process != nil { + defer cmd.Process.Release() //nolint:errcheck + } + return waitForServer(ctx, client) +} diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 1367194e..a5fb301f 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,60 +1,72 @@ -# How to troubleshoot issues - -Sometimes Ollama may not perform as expected. One of the best ways to figure out what happened is to take a look at the logs. Find the logs on Mac by running the command: - -```shell -cat ~/.ollama/logs/server.log -``` - -On Linux systems with systemd, the logs can be found with this command: - -```shell -journalctl -u ollama -``` - -When you run Ollama in a container, the logs go to stdout/stderr in the container: - -```shell -docker logs -``` -(Use `docker ps` to find the container name) - -If manually running `ollama serve` in a terminal, the logs will be on that terminal. - -Join the [Discord](https://discord.gg/ollama) for help interpreting the logs. - -## LLM libraries - -Ollama includes multiple LLM libraries compiled for different GPUs and CPU -vector features. Ollama tries to pick the best one based on the capabilities of -your system. If this autodetection has problems, or you run into other problems -(e.g. crashes in your GPU) you can workaround this by forcing a specific LLM -library. `cpu_avx2` will perform the best, followed by `cpu_avx` an the slowest -but most compatible is `cpu`. Rosetta emulation under MacOS will work with the -`cpu` library. - -In the server log, you will see a message that looks something like this (varies -from release to release): - -``` -Dynamic LLM libraries [rocm_v6 cpu cpu_avx cpu_avx2 cuda_v11 rocm_v5] -``` - -**Experimental LLM Library Override** - -You can set OLLAMA_LLM_LIBRARY to any of the available LLM libraries to bypass -autodetection, so for example, if you have a CUDA card, but want to force the -CPU LLM library with AVX2 vector support, use: - -``` -OLLAMA_LLM_LIBRARY="cpu_avx2" ollama serve -``` - -You can see what features your CPU has with the following. -``` -cat /proc/cpuinfo| grep flags | head -1 -``` - -## Known issues - +# How to troubleshoot issues + +Sometimes Ollama may not perform as expected. One of the best ways to figure out what happened is to take a look at the logs. Find the logs on **Mac** by running the command: + +```shell +cat ~/.ollama/logs/server.log +``` + +On **Linux** systems with systemd, the logs can be found with this command: + +```shell +journalctl -u ollama +``` + +When you run Ollama in a **container**, the logs go to stdout/stderr in the container: + +```shell +docker logs +``` +(Use `docker ps` to find the container name) + +If manually running `ollama serve` in a terminal, the logs will be on that terminal. + +When you run Ollama on **Windows**, there are a few different locations. You can view them in the explorer window by hitting `+R` and type in: +- `explorer %LOCALAPPDATA%\Ollama` to view logs +- `explorer %LOCALAPPDATA%\Programs\Ollama` to browse the binaries (The installer adds this to your user PATH) +- `explorer %HOMEPATH%\.ollama` to browse where models and configuration is stored +- `explorer %TEMP%` where temporary executable files are stored in one or more `ollama*` directories + +To enable additional debug logging to help troubleshoot problems, first **Quit the running app from the tray menu** then in a powershell terminal +```powershell +$env:OLLAMA_DEBUG="1" +& "ollama app.exe" +``` + +Join the [Discord](https://discord.gg/ollama) for help interpreting the logs. + +## LLM libraries + +Ollama includes multiple LLM libraries compiled for different GPUs and CPU +vector features. Ollama tries to pick the best one based on the capabilities of +your system. If this autodetection has problems, or you run into other problems +(e.g. crashes in your GPU) you can workaround this by forcing a specific LLM +library. `cpu_avx2` will perform the best, followed by `cpu_avx` an the slowest +but most compatible is `cpu`. Rosetta emulation under MacOS will work with the +`cpu` library. + +In the server log, you will see a message that looks something like this (varies +from release to release): + +``` +Dynamic LLM libraries [rocm_v6 cpu cpu_avx cpu_avx2 cuda_v11 rocm_v5] +``` + +**Experimental LLM Library Override** + +You can set OLLAMA_LLM_LIBRARY to any of the available LLM libraries to bypass +autodetection, so for example, if you have a CUDA card, but want to force the +CPU LLM library with AVX2 vector support, use: + +``` +OLLAMA_LLM_LIBRARY="cpu_avx2" ollama serve +``` + +You can see what features your CPU has with the following. +``` +cat /proc/cpuinfo| grep flags | head -1 +``` + +## Known issues + * N/A \ No newline at end of file diff --git a/docs/windows.md b/docs/windows.md new file mode 100644 index 00000000..b43470c8 --- /dev/null +++ b/docs/windows.md @@ -0,0 +1,46 @@ +# Ollama Windows Preview + +Welcome to the Ollama Windows preview. + +No more WSL required! + +Ollama now runs as a native Windows application, including NVIDIA GPU support. +After installing Ollama Windows Preview, Ollama will run in the background and +the `ollama` command line is available in `cmd`, `powershell` or your favorite +terminal application. As usual the Ollama [api](./api.md) will be served on +`http://localhost:11434`. + +As this is a preview release, you should expect a few bugs here and there. If +you run into a problem you can reach out on +[Discord](https://discord.gg/ollama), or file an +[issue](https://github.com/ollama/ollama/issues). +Logs will be often be helpful in dianosing the problem (see +[Troubleshooting](#troubleshooting) below) + +## System Requirements + +* Windows 10 or newer, Home or Pro +* NVIDIA 452.39 or newer Drivers if you have an NVIDIA card + +## API Access + +Here's a quick example showing API access from `powershell` +```powershell +(Invoke-WebRequest -method POST -Body '{"model":"llama2", "prompt":"Why is the sky blue?", "stream": false}' -uri http://localhost:11434/api/generate ).Content | ConvertFrom-json +``` + +## Troubleshooting + +While we're in preview, `OLLAMA_DEBUG` is always enabled, which adds +a "view logs" menu item to the app, and increses logging for the GUI app and +server. + +Ollama on Windows stores files in a few different locations. You can view them in +the explorer window by hitting `+R` and type in: +- `explorer %LOCALAPPDATA%\Ollama` contains logs, and downloaded updates + - *app.log* contains logs from the GUI application + - *server.log* contains the server logs + - *upgrade.log* contains log output for upgrades +- `explorer %LOCALAPPDATA%\Programs\Ollama` contains the binaries (The installer adds this to your user PATH) +- `explorer %HOMEPATH%\.ollama` contains models and configuration +- `explorer %TEMP%` contains temporary executable files in one or more `ollama*` directories diff --git a/go.mod b/go.mod index 57ec2495..1118de66 100644 --- a/go.mod +++ b/go.mod @@ -12,8 +12,20 @@ require ( ) require ( + github.com/cratonica/2goarray v0.0.0-20190331194516-514510793eaa // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect + github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect + github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect + github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 // indirect + github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect + github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect + github.com/getlantern/systray v1.2.2 // indirect + github.com/go-stack/stack v1.8.0 // indirect + github.com/google/uuid v1.0.0 // indirect github.com/mattn/go-runewidth v0.0.14 // indirect + github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect + github.com/pborman/uuid v1.2.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.2.0 // indirect ) diff --git a/go.sum b/go.sum index ff6bcbd9..a256b0be 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,8 @@ github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cratonica/2goarray v0.0.0-20190331194516-514510793eaa h1:Wg+722vs7a2zQH5lR9QWYsVbplKeffaQFIs5FTdfNNo= +github.com/cratonica/2goarray v0.0.0-20190331194516-514510793eaa/go.mod h1:6Arca19mRx58CA7OWEd7Wu1NpC1rd3uDnNs6s1pj/DI= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -13,6 +15,20 @@ github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= +github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4= +github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY= +github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So= +github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A= +github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 h1:guBYzEaLz0Vfc/jv0czrr2z7qyzTOGC9hiQ0VC+hKjk= +github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc= +github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0= +github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o= +github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 h1:XYzSdCbkzOC0FDNrgJqGRo8PCMFOBFL9py72DRs7bmc= +github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA= +github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f h1:wrYrQttPS8FHIRSlsrcuKazukx/xqO/PpLZzZXsF+EA= +github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA= +github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sThoEBE= +github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE= github.com/gin-contrib/cors v1.4.0 h1:oJ6gwtUl3lqV0WEIwM/LxPF1QZ5qe2lGWdY2+bz7y0g= github.com/gin-contrib/cors v1.4.0/go.mod h1:bs9pNM0x/UsmHPBWT2xZz9ROh8xYjYkiURUfmBoMlcs= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= @@ -31,6 +47,8 @@ github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91 github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos= github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= @@ -39,6 +57,8 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.0.0 h1:b4Gk+7WdP/d3HZH8EJsZpvV7EtDOgaZLtnaNGIu1adA= +github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -57,6 +77,8 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ= +github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= @@ -70,8 +92,12 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw= +github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0= github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0= github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y= +github.com/pborman/uuid v1.2.1 h1:+ZZIw58t/ozdjRaXh/3awHfmWRbzYxJoAdNJxe/3pvw= +github.com/pborman/uuid v1.2.1/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo= github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= @@ -84,6 +110,7 @@ github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTE github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= @@ -120,11 +147,13 @@ golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -141,6 +170,7 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0 google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/llm/generate/gen_windows.ps1 b/llm/generate/gen_windows.ps1 index f7a241cc..ba0f954c 100644 --- a/llm/generate/gen_windows.ps1 +++ b/llm/generate/gen_windows.ps1 @@ -4,7 +4,7 @@ $ErrorActionPreference = "Stop" function init_vars { $script:llamacppDir = "../llama.cpp" - $script:cmakeDefs = @("-DBUILD_SHARED_LIBS=on", "-DLLAMA_NATIVE=off", "-A","x64") + $script:cmakeDefs = @("-DBUILD_SHARED_LIBS=on", "-DLLAMA_NATIVE=off", "-A", "x64") $script:cmakeTargets = @("ext_server") $script:ARCH = "amd64" # arm not yet supported. if ($env:CGO_CFLAGS -contains "-g") { @@ -19,6 +19,7 @@ function init_vars { $d=(get-command -ea 'silentlycontinue' nvcc).path if ($d -ne $null) { $script:CUDA_LIB_DIR=($d| split-path -parent) + $script:CUDA_INCLUDE_DIR=($script:CUDA_LIB_DIR|split-path -parent)+"\include" } } else { $script:CUDA_LIB_DIR=$env:CUDA_LIB_DIR @@ -30,6 +31,8 @@ function init_vars { } else { $script:CMAKE_CUDA_ARCHITECTURES=$env:CMAKE_CUDA_ARCHITECTURES } + # Note: 10 Windows Kit signtool crashes with GCP's plugin + ${script:SignTool}="C:\Program Files (x86)\Windows Kits\8.1\bin\x64\signtool.exe" } function git_module_setup { @@ -56,8 +59,8 @@ function apply_patches { } # Checkout each file + Set-Location -Path ${script:llamacppDir} foreach ($file in $filePaths) { - Set-Location -Path ${script:llamacppDir} git checkout $file } } @@ -89,13 +92,23 @@ function install { md "${script:buildDir}/lib" -ea 0 > $null cp "${script:buildDir}/bin/${script:config}/ext_server.dll" "${script:buildDir}/lib" cp "${script:buildDir}/bin/${script:config}/llama.dll" "${script:buildDir}/lib" - # Display the dll dependencies in the build log if ($script:DUMPBIN -ne $null) { & "$script:DUMPBIN" /dependents "${script:buildDir}/bin/${script:config}/ext_server.dll" | select-string ".dll" } } +function sign { + if ("${env:KEY_CONTAINER}") { + write-host "Signing ${script:buildDir}/lib/*.dll" + foreach ($file in (get-childitem "${script:buildDir}/lib/*.dll")){ + & "${script:SignTool}" sign /v /fd sha256 /t http://timestamp.digicert.com /f "${env:OLLAMA_CERT}" ` + /csp "Google Cloud KMS Provider" /kc "${env:KEY_CONTAINER}" $file + if ($LASTEXITCODE -ne 0) { exit($LASTEXITCODE)} + } + } +} + function compress_libs { if ($script:GZIP -eq $null) { write-host "gzip not installed, not compressing files" @@ -109,8 +122,23 @@ function compress_libs { } function cleanup { + $patches = Get-ChildItem "../patches/*.diff" + foreach ($patch in $patches) { + # Extract file paths from the patch file + $filePaths = Get-Content $patch.FullName | Where-Object { $_ -match '^\+\+\+ ' } | ForEach-Object { + $parts = $_ -split ' ' + ($parts[1] -split '/', 2)[1] + } + + # Checkout each file + Set-Location -Path ${script:llamacppDir} + foreach ($file in $filePaths) { + git checkout $file + } + } Set-Location "${script:llamacppDir}/examples/server" git checkout CMakeLists.txt server.cpp + } init_vars @@ -129,6 +157,7 @@ $script:buildDir="${script:llamacppDir}/build/windows/${script:ARCH}/cpu" write-host "Building LCD CPU" build install +sign compress_libs $script:cmakeDefs = $script:commonCpuDefs + @("-DLLAMA_AVX=on", "-DLLAMA_AVX2=off", "-DLLAMA_AVX512=off", "-DLLAMA_FMA=off", "-DLLAMA_F16C=off") + $script:cmakeDefs @@ -136,6 +165,7 @@ $script:buildDir="${script:llamacppDir}/build/windows/${script:ARCH}/cpu_avx" write-host "Building AVX CPU" build install +sign compress_libs $script:cmakeDefs = $script:commonCpuDefs + @("-DLLAMA_AVX=on", "-DLLAMA_AVX2=on", "-DLLAMA_AVX512=off", "-DLLAMA_FMA=on", "-DLLAMA_F16C=on") + $script:cmakeDefs @@ -143,25 +173,22 @@ $script:buildDir="${script:llamacppDir}/build/windows/${script:ARCH}/cpu_avx2" write-host "Building AVX2 CPU" build install +sign compress_libs if ($null -ne $script:CUDA_LIB_DIR) { # Then build cuda as a dynamically loaded library - $nvcc = (get-command -ea 'silentlycontinue' nvcc) - if ($null -ne $nvcc) { - $script:CUDA_VERSION=(get-item ($nvcc | split-path | split-path)).Basename - } + $nvcc = "$script:CUDA_LIB_DIR\nvcc.exe" + $script:CUDA_VERSION=(get-item ($nvcc | split-path | split-path)).Basename if ($null -ne $script:CUDA_VERSION) { $script:CUDA_VARIANT="_"+$script:CUDA_VERSION } init_vars $script:buildDir="${script:llamacppDir}/build/windows/${script:ARCH}/cuda$script:CUDA_VARIANT" - $script:cmakeDefs += @("-DLLAMA_CUBLAS=ON", "-DLLAMA_AVX=on", "-DCMAKE_CUDA_ARCHITECTURES=${script:CMAKE_CUDA_ARCHITECTURES}") + $script:cmakeDefs += @("-DLLAMA_CUBLAS=ON", "-DLLAMA_AVX=on", "-DCUDAToolkit_INCLUDE_DIR=$script:CUDA_INCLUDE_DIR", "-DCMAKE_CUDA_ARCHITECTURES=${script:CMAKE_CUDA_ARCHITECTURES}") build install - cp "${script:CUDA_LIB_DIR}/cudart64_*.dll" "${script:buildDir}/lib" - cp "${script:CUDA_LIB_DIR}/cublas64_*.dll" "${script:buildDir}/lib" - cp "${script:CUDA_LIB_DIR}/cublasLt64_*.dll" "${script:buildDir}/lib" + sign compress_libs } # TODO - actually implement ROCm support on windows @@ -172,4 +199,4 @@ md "${script:buildDir}/lib" -ea 0 > $null echo $null >> "${script:buildDir}/lib/.generated" cleanup -write-host "`ngo generate completed" \ No newline at end of file +write-host "`ngo generate completed" diff --git a/scripts/build_remote.py b/scripts/build_remote.py index 314232ac..2ab58ad7 100755 --- a/scripts/build_remote.py +++ b/scripts/build_remote.py @@ -60,13 +60,17 @@ subprocess.check_call(['ssh', netloc, 'cd', path, ';', 'git', 'checkout', branch # subprocess.check_call(['ssh', netloc, 'cd', path, ';', 'env']) # TODO - or consider paramiko maybe -print("Performing generate") -subprocess.check_call(['ssh', netloc, 'cd', path, ';', GoCmd, 'generate', './...']) +print("Running Windows Build Script") +subprocess.check_call(['ssh', netloc, 'cd', path, ';', "powershell", "-ExecutionPolicy", "Bypass", "-File", "./scripts/build_windows.ps1"]) -print("Building") -subprocess.check_call(['ssh', netloc, 'cd', path, ';', GoCmd, 'build', '.']) +# print("Building") +# subprocess.check_call(['ssh', netloc, 'cd', path, ';', GoCmd, 'build', '.']) print("Copying built result") subprocess.check_call(['scp', netloc +":"+ path + "/ollama.exe", './dist/']) +print("Copying installer") +subprocess.check_call(['scp', netloc +":"+ path + "/dist/Ollama Setup.exe", './dist/']) + + diff --git a/scripts/build_windows.ps1 b/scripts/build_windows.ps1 new file mode 100644 index 00000000..5da898ac --- /dev/null +++ b/scripts/build_windows.ps1 @@ -0,0 +1,130 @@ +#!powershell +# +# powershell -ExecutionPolicy Bypass -File .\scripts\build_windows.ps1 +# +# gcloud auth application-default login + +$ErrorActionPreference = "Stop" + +function checkEnv() { + write-host "Locating required tools and paths" + $script:SRC_DIR=$PWD + if (!$env:VCToolsRedistDir) { + $MSVC_INSTALL=(Get-CimInstance MSFT_VSInstance -Namespace root/cimv2/vs)[0].InstallLocation + $env:VCToolsRedistDir=(get-item "${MSVC_INSTALL}\VC\Redist\MSVC\*")[0] + } + $script:NVIDIA_DIR=(get-item "C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v*\bin\")[0] + $script:INNO_SETUP_DIR=(get-item "C:\Program Files*\Inno Setup*\")[0] + + $script:DEPS_DIR="${script:SRC_DIR}\dist\windeps" + $env:CGO_ENABLED="1" + echo "Checking version" + if (!$env:VERSION) { + $data=(git describe --tags --first-parent --abbrev=7 --long --dirty --always) + $pattern="v(.+)" + if ($data -match $pattern) { + $script:VERSION=$matches[1] + } + } else { + $script:VERSION=$env:VERSION + } + $pattern = "(\d+[.]\d+[.]\d+)-(\d+)-" + if ($script:VERSION -match $pattern) { + $script:PKG_VERSION=$matches[1] + "." + $matches[2] + } else { + $script:PKG_VERSION=$script:VERSION + } + write-host "Building Ollama $script:VERSION with package version $script:PKG_VERSION" + + # Check for signing key + if ("${env:KEY_CONTAINER}") { + ${env:OLLAMA_CERT}=$(resolve-path "${script:SRC_DIR}\ollama_inc.crt") + Write-host "Code signing enabled" + # Note: 10 Windows Kit signtool crashes with GCP's plugin + ${script:SignTool}="C:\Program Files (x86)\Windows Kits\8.1\bin\x64\signtool.exe" + } else { + write-host "Code signing disabled - please set KEY_CONTAINERS to sign and copy ollama_inc.crt to the top of the source tree" + } + +} + + +function buildOllama() { + write-host "Building ollama CLI" + & go generate ./... + if ($LASTEXITCODE -ne 0) { exit($LASTEXITCODE)} + & go build "-ldflags=-w -s ""-X=github.com/jmorganca/ollama/version.Version=$script:VERSION"" ""-X=github.com/jmorganca/ollama/server.mode=release""" . + if ($LASTEXITCODE -ne 0) { exit($LASTEXITCODE)} + if ("${env:KEY_CONTAINER}") { + & "${script:SignTool}" sign /v /fd sha256 /t http://timestamp.digicert.com /f "${env:OLLAMA_CERT}" ` + /csp "Google Cloud KMS Provider" /kc ${env:KEY_CONTAINER} ollama.exe + if ($LASTEXITCODE -ne 0) { exit($LASTEXITCODE)} + } +} + +function buildApp() { + write-host "Building Ollama App" + cd "${script:SRC_DIR}\app" + & go build "-ldflags=-H windowsgui -w -s ""-X=github.com/jmorganca/ollama/version.Version=$script:VERSION"" ""-X=github.com/jmorganca/ollama/server.mode=release""" . + if ($LASTEXITCODE -ne 0) { exit($LASTEXITCODE)} + if ("${env:KEY_CONTAINER}") { + & "${script:SignTool}" sign /v /fd sha256 /t http://timestamp.digicert.com /f "${env:OLLAMA_CERT}" ` + /csp "Google Cloud KMS Provider" /kc ${env:KEY_CONTAINER} app.exe + if ($LASTEXITCODE -ne 0) { exit($LASTEXITCODE)} + } +} + +function gatherDependencies() { + write-host "Gathering runtime dependencies" + cd "${script:SRC_DIR}" + rm -ea 0 -recurse -force -path "${script:DEPS_DIR}" + md "${script:DEPS_DIR}" -ea 0 > $null + + # TODO - this varies based on host build system and MSVC version - drive from dumpbin output + # currently works for Win11 + MSVC 2019 + Cuda V11 + cp "${env:VCToolsRedistDir}\x64\Microsoft.VC*.CRT\msvcp140.dll" "${script:DEPS_DIR}\" + cp "${env:VCToolsRedistDir}\x64\Microsoft.VC*.CRT\vcruntime140.dll" "${script:DEPS_DIR}\" + cp "${env:VCToolsRedistDir}\x64\Microsoft.VC*.CRT\vcruntime140_1.dll" "${script:DEPS_DIR}\" + + cp "${script:NVIDIA_DIR}\cudart64_*.dll" "${script:DEPS_DIR}\" + cp "${script:NVIDIA_DIR}\cublas64_*.dll" "${script:DEPS_DIR}\" + cp "${script:NVIDIA_DIR}\cublasLt64_*.dll" "${script:DEPS_DIR}\" + + cp "${script:SRC_DIR}\app\ollama_welcome.ps1" "${script:SRC_DIR}\dist\" + if ("${env:KEY_CONTAINER}") { + write-host "about to sign" + foreach ($file in (get-childitem "${script:DEPS_DIR}/cu*.dll") + @("${script:SRC_DIR}\dist\ollama_welcome.ps1")){ + write-host "signing $file" + & "${script:SignTool}" sign /v /fd sha256 /t http://timestamp.digicert.com /f "${env:OLLAMA_CERT}" ` + /csp "Google Cloud KMS Provider" /kc ${env:KEY_CONTAINER} $file + if ($LASTEXITCODE -ne 0) { exit($LASTEXITCODE)} + } + } + +} + +function buildInstaller() { + write-host "Building Ollama Installer" + cd "${script:SRC_DIR}\app" + $env:PKG_VERSION=$script:PKG_VERSION + if ("${env:KEY_CONTAINER}") { + & "${script:INNO_SETUP_DIR}\ISCC.exe" /SMySignTool="${script:SignTool} sign /fd sha256 /t http://timestamp.digicert.com /f ${env:OLLAMA_CERT} /csp `$qGoogle Cloud KMS Provider`$q /kc ${env:KEY_CONTAINER} `$f" .\ollama.iss + } else { + & "${script:INNO_SETUP_DIR}\ISCC.exe" .\ollama.iss + } + if ($LASTEXITCODE -ne 0) { exit($LASTEXITCODE)} +} + +try { + checkEnv + buildOllama + buildApp + gatherDependencies + buildInstaller +} catch { + write-host "Build Failed" + write-host $_ +} finally { + set-location $script:SRC_DIR + $env:PKG_VERSION="" +} \ No newline at end of file