Skip to content

Commit

Permalink
Implement new Go based Desktop app
Browse files Browse the repository at this point in the history
This focuses on Windows first, but coudl be used for Mac
and possibly linux in the future.
  • Loading branch information
dhiltgen authored and jmorganca committed Feb 15, 2024
1 parent f397e0e commit 29e90cc
Show file tree
Hide file tree
Showing 49 changed files with 2,621 additions and 101 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ ggml-metal.metal
.cache
*.exe
.idea
test_data
test_data
*.crt
22 changes: 22 additions & 0 deletions app/README.md
Original file line number Diff line number Diff line change
@@ -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
```
Binary file added app/assets/app.ico
Binary file not shown.
17 changes: 17 additions & 0 deletions app/assets/assets.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package assets

import (
"embed"
"io/fs"
)

//go:embed *.ico
var icons embed.FS

func ListIcons() ([]string, error) {
return fs.Glob(icons, "*")
}

func GetIcon(filename string) ([]byte, error) {
return icons.ReadFile(filename)
}
Binary file added app/assets/setup.bmp
Binary file not shown.
Binary file added app/assets/tray.ico
Binary file not shown.
Binary file added app/assets/tray_upgrade.ico
Binary file not shown.
9 changes: 9 additions & 0 deletions app/lifecycle/getstarted_nonwindows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//go:build !windows

package lifecycle

import "fmt"

func GetStarted() error {
return fmt.Errorf("GetStarted not implemented")
}
44 changes: 44 additions & 0 deletions app/lifecycle/getstarted_windows.go
Original file line number Diff line number Diff line change
@@ -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()
}
83 changes: 83 additions & 0 deletions app/lifecycle/lifecycle.go
Original file line number Diff line number Diff line change
@@ -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")
}
46 changes: 46 additions & 0 deletions app/lifecycle/logging.go
Original file line number Diff line number Diff line change
@@ -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")
}
9 changes: 9 additions & 0 deletions app/lifecycle/logging_nonwindows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//go:build !windows

package lifecycle

import "log/slog"

func ShowLogs() {
slog.Warn("ShowLogs not yet implemented")
}
19 changes: 19 additions & 0 deletions app/lifecycle/logging_windows.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
79 changes: 79 additions & 0 deletions app/lifecycle/paths.go
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading

0 comments on commit 29e90cc

Please sign in to comment.