From e76abac24ee23561d2ac287a4f290d9a4b156da2 Mon Sep 17 00:00:00 2001 From: Eva Ho Date: Wed, 17 Dec 2025 14:37:18 -0500 Subject: [PATCH] app: add upgrade configuration to settings page --- app/cmd/app/app.go | 22 +++- app/cmd/app/app_darwin.go | 8 ++ app/cmd/app/app_darwin.h | 1 + app/cmd/app/app_darwin.m | 12 ++ app/cmd/app/app_windows.go | 4 + app/store/database.go | 32 ++++- app/store/store.go | 3 + app/ui/app/codegen/gotypes.gen.ts | 42 +++++++ app/ui/app/src/api.ts | 65 ++++++++++ app/ui/app/src/components/Settings.tsx | 75 +++++++++++- app/ui/responses/types.go | 11 ++ app/ui/ui.go | 157 +++++++++++++++++++++++-- app/updater/updater.go | 60 +++++++++- app/version/version.go | 35 ++++++ app/wintray/menus.go | 28 +++++ app/wintray/tray.go | 32 ++--- app/wintray/w32api.go | 1 + 17 files changed, 552 insertions(+), 36 deletions(-) diff --git a/app/cmd/app/app.go b/app/cmd/app/app.go index 7e183b8df..0b1880234 100644 --- a/app/cmd/app/app.go +++ b/app/cmd/app/app.go @@ -253,6 +253,8 @@ func main() { done <- osrv.Run(octx) }() + upd := &updater.Updater{Store: st} + uiServer := ui.Server{ Token: token, Restart: func() { @@ -267,6 +269,13 @@ func main() { ToolRegistry: toolRegistry, Dev: devMode, Logger: slog.Default(), + Updater: upd, + UpdateAvailableFunc: func() { + UpdateAvailable("") + }, + ClearUpdateAvailableFunc: func() { + ClearUpdateAvailable() + }, } srv := &http.Server{ @@ -284,8 +293,7 @@ func main() { slog.Debug("background desktop server done") }() - updater := &updater.Updater{Store: st} - updater.StartBackgroundUpdaterChecker(ctx, UpdateAvailable) + upd.StartBackgroundUpdaterChecker(ctx, UpdateAvailable) hasCompletedFirstRun, err := st.HasCompletedFirstRun() if err != nil { @@ -348,6 +356,16 @@ func startHiddenTasks() { // CLI triggered app startup use-case slog.Info("deferring pending update for fast startup") } else { + // Check if auto-update is enabled before upgrading + st := &store.Store{} + settings, err := st.Settings() + if err != nil { + slog.Warn("failed to load settings for upgrade check", "error", err) + } else if !settings.AutoUpdateEnabled { + slog.Info("auto-update disabled, skipping automatic upgrade at startup") + return + } + if err := updater.DoUpgradeAtStartup(); err != nil { slog.Info("unable to perform upgrade at startup", "error", err) // Make sure the restart to upgrade menu shows so we can attempt an interactive upgrade to get authorization diff --git a/app/cmd/app/app_darwin.go b/app/cmd/app/app_darwin.go index 8e886a124..5d5138304 100644 --- a/app/cmd/app/app_darwin.go +++ b/app/cmd/app/app_darwin.go @@ -172,6 +172,14 @@ func UpdateAvailable(ver string) error { return nil } +func ClearUpdateAvailable() error { + slog.Debug("clearing update notification") + if updater.BundlePath != "" { + C.clearUpdateAvailable() + } + return nil +} + func osRun(_ func(), hasCompletedFirstRun, startHidden bool) { registerLaunchAgent(hasCompletedFirstRun) diff --git a/app/cmd/app/app_darwin.h b/app/cmd/app/app_darwin.h index 4a5ba055f..c9c510872 100644 --- a/app/cmd/app/app_darwin.h +++ b/app/cmd/app/app_darwin.h @@ -30,6 +30,7 @@ void StartUpdate(); void darwinStartHiddenTasks(); void launchApp(const char *appPath); void updateAvailable(); +void clearUpdateAvailable(); void quit(); void uiRequest(char *path); void registerSelfAsLoginItem(bool firstTimeRun); diff --git a/app/cmd/app/app_darwin.m b/app/cmd/app/app_darwin.m index d5095ab25..dd817ce18 100644 --- a/app/cmd/app/app_darwin.m +++ b/app/cmd/app/app_darwin.m @@ -241,6 +241,12 @@ bool firstTimeRun,startHidden; // Set in run before initialization [self showIcon]; } +- (void)clearUpdateAvailable { + self.updateAvailable = NO; + [self.statusItem.menu.itemArray[3] setHidden:YES]; + [self.statusItem.menu.itemArray[4] setHidden:YES]; +} + - (void)aboutOllama { [[NSApplication sharedApplication] orderFrontStandardAboutPanel:nil]; [NSApp activateIgnoringOtherApps:YES]; @@ -973,6 +979,12 @@ void updateAvailable() { }); } +void clearUpdateAvailable() { + dispatch_async(dispatch_get_main_queue(), ^{ + [appDelegate clearUpdateAvailable]; + }); +} + void quit() { dispatch_async(dispatch_get_main_queue(), ^{ [appDelegate quit]; diff --git a/app/cmd/app/app_windows.go b/app/cmd/app/app_windows.go index 9caeb178a..76af39f19 100644 --- a/app/cmd/app/app_windows.go +++ b/app/cmd/app/app_windows.go @@ -157,6 +157,10 @@ func UpdateAvailable(ver string) error { return app.t.UpdateAvailable(ver) } +func ClearUpdateAvailable() error { + return app.t.ClearUpdateAvailable() +} + func osRun(shutdown func(), hasCompletedFirstRun, startHidden bool) { var err error app.shutdown = shutdown diff --git a/app/store/database.go b/app/store/database.go index 0f268c6fa..3443f974b 100644 --- a/app/store/database.go +++ b/app/store/database.go @@ -14,7 +14,7 @@ import ( // currentSchemaVersion defines the current database schema version. // Increment this when making schema changes that require migrations. -const currentSchemaVersion = 12 +const currentSchemaVersion = 13 // database wraps the SQLite connection. // SQLite handles its own locking for concurrent access: @@ -85,6 +85,7 @@ func (db *database) init() error { think_enabled BOOLEAN NOT NULL DEFAULT 0, think_level TEXT NOT NULL DEFAULT '', remote TEXT NOT NULL DEFAULT '', -- deprecated + auto_update_enabled BOOLEAN NOT NULL DEFAULT 1, schema_version INTEGER NOT NULL DEFAULT %d ); @@ -244,6 +245,12 @@ func (db *database) migrate() error { return fmt.Errorf("migrate v11 to v12: %w", err) } version = 12 + case 12: + // add auto_update_enabled column to settings table + if err := db.migrateV12ToV13(); err != nil { + return fmt.Errorf("migrate v12 to v13: %w", err) + } + version = 13 default: // If we have a version we don't recognize, just set it to current // This might happen during development @@ -452,6 +459,21 @@ func (db *database) migrateV11ToV12() error { return nil } +// migrateV12ToV13 adds the auto_update_enabled column to the settings table +func (db *database) migrateV12ToV13() error { + _, err := db.conn.Exec(`ALTER TABLE settings ADD COLUMN auto_update_enabled BOOLEAN NOT NULL DEFAULT 1`) + if err != nil && !duplicateColumnError(err) { + return fmt.Errorf("add auto_update_enabled column: %w", err) + } + + _, err = db.conn.Exec(`UPDATE settings SET schema_version = 13`) + if err != nil { + return fmt.Errorf("update schema version: %w", err) + } + + return nil +} + // cleanupOrphanedData removes orphaned records that may exist due to the foreign key bug func (db *database) cleanupOrphanedData() error { _, err := db.conn.Exec(` @@ -1108,9 +1130,9 @@ func (db *database) getSettings() (Settings, error) { var s Settings err := db.conn.QueryRow(` - SELECT expose, survey, browser, models, agent, tools, working_dir, context_length, airplane_mode, turbo_enabled, websearch_enabled, selected_model, sidebar_open, think_enabled, think_level + SELECT expose, survey, browser, models, agent, tools, working_dir, context_length, airplane_mode, turbo_enabled, websearch_enabled, selected_model, sidebar_open, think_enabled, think_level, auto_update_enabled FROM settings - `).Scan(&s.Expose, &s.Survey, &s.Browser, &s.Models, &s.Agent, &s.Tools, &s.WorkingDir, &s.ContextLength, &s.AirplaneMode, &s.TurboEnabled, &s.WebSearchEnabled, &s.SelectedModel, &s.SidebarOpen, &s.ThinkEnabled, &s.ThinkLevel) + `).Scan(&s.Expose, &s.Survey, &s.Browser, &s.Models, &s.Agent, &s.Tools, &s.WorkingDir, &s.ContextLength, &s.AirplaneMode, &s.TurboEnabled, &s.WebSearchEnabled, &s.SelectedModel, &s.SidebarOpen, &s.ThinkEnabled, &s.ThinkLevel, &s.AutoUpdateEnabled) if err != nil { return Settings{}, fmt.Errorf("get settings: %w", err) } @@ -1121,8 +1143,8 @@ func (db *database) getSettings() (Settings, error) { func (db *database) setSettings(s Settings) error { _, err := db.conn.Exec(` UPDATE settings - SET expose = ?, survey = ?, browser = ?, models = ?, agent = ?, tools = ?, working_dir = ?, context_length = ?, airplane_mode = ?, turbo_enabled = ?, websearch_enabled = ?, selected_model = ?, sidebar_open = ?, think_enabled = ?, think_level = ? - `, s.Expose, s.Survey, s.Browser, s.Models, s.Agent, s.Tools, s.WorkingDir, s.ContextLength, s.AirplaneMode, s.TurboEnabled, s.WebSearchEnabled, s.SelectedModel, s.SidebarOpen, s.ThinkEnabled, s.ThinkLevel) + SET expose = ?, survey = ?, browser = ?, models = ?, agent = ?, tools = ?, working_dir = ?, context_length = ?, airplane_mode = ?, turbo_enabled = ?, websearch_enabled = ?, selected_model = ?, sidebar_open = ?, think_enabled = ?, think_level = ?, auto_update_enabled = ? + `, s.Expose, s.Survey, s.Browser, s.Models, s.Agent, s.Tools, s.WorkingDir, s.ContextLength, s.AirplaneMode, s.TurboEnabled, s.WebSearchEnabled, s.SelectedModel, s.SidebarOpen, s.ThinkEnabled, s.ThinkLevel, s.AutoUpdateEnabled) if err != nil { return fmt.Errorf("set settings: %w", err) } diff --git a/app/store/store.go b/app/store/store.go index 052fcd617..ac3111dd5 100644 --- a/app/store/store.go +++ b/app/store/store.go @@ -169,6 +169,9 @@ type Settings struct { // SidebarOpen indicates if the chat sidebar is open SidebarOpen bool + + // AutoUpdateEnabled indicates if automatic updates should be downloaded + AutoUpdateEnabled bool } type Store struct { diff --git a/app/ui/app/codegen/gotypes.gen.ts b/app/ui/app/codegen/gotypes.gen.ts index 0bf86f2b4..347e2c157 100644 --- a/app/ui/app/codegen/gotypes.gen.ts +++ b/app/ui/app/codegen/gotypes.gen.ts @@ -413,6 +413,7 @@ export class Settings { ThinkLevel: string; SelectedModel: string; SidebarOpen: boolean; + AutoUpdateEnabled: boolean; constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); @@ -431,6 +432,7 @@ export class Settings { this.ThinkLevel = source["ThinkLevel"]; this.SelectedModel = source["SelectedModel"]; this.SidebarOpen = source["SidebarOpen"]; + this.AutoUpdateEnabled = source["AutoUpdateEnabled"]; } } export class SettingsResponse { @@ -467,6 +469,46 @@ export class HealthResponse { this.healthy = source["healthy"]; } } +export class UpdateInfo { + currentVersion: string; + availableVersion: string; + updateAvailable: boolean; + updateDownloaded: boolean; + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.currentVersion = source["currentVersion"]; + this.availableVersion = source["availableVersion"]; + this.updateAvailable = source["updateAvailable"]; + this.updateDownloaded = source["updateDownloaded"]; + } +} +export class UpdateCheckResponse { + updateInfo: UpdateInfo; + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.updateInfo = this.convertValues(source["updateInfo"], UpdateInfo); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (Array.isArray(a)) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } +} export class User { id: string; email: string; diff --git a/app/ui/app/src/api.ts b/app/ui/app/src/api.ts index 273850d6b..80debe508 100644 --- a/app/ui/app/src/api.ts +++ b/app/ui/app/src/api.ts @@ -414,3 +414,68 @@ export async function fetchHealth(): Promise { return false; } } + +export async function getCurrentVersion(): Promise { + try { + const response = await fetch(`${API_BASE}/api/version`, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + if (response.ok) { + const data = await response.json(); + return data.version || "Unknown"; + } + return "Unknown"; + } catch (error) { + console.error("Error fetching version:", error); + return "Unknown"; + } +} + +export async function checkForUpdate(): Promise<{ + currentVersion: string; + availableVersion: string; + updateAvailable: boolean; + updateDownloaded: boolean; +}> { + const response = await fetch(`${API_BASE}/api/v1/update/check`, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + if (!response.ok) { + throw new Error("Failed to check for update"); + } + const data = await response.json(); + return data.updateInfo; +} + +export async function downloadUpdate(version: string): Promise { + const response = await fetch(`${API_BASE}/api/v1/update/download`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ version }), + }); + if (!response.ok) { + const error = await response.text(); + throw new Error(error || "Failed to download update"); + } +} + +export async function installUpdate(): Promise { + const response = await fetch(`${API_BASE}/api/v1/update/install`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + }); + if (!response.ok) { + const error = await response.text(); + throw new Error(error || "Failed to install update"); + } +} diff --git a/app/ui/app/src/components/Settings.tsx b/app/ui/app/src/components/Settings.tsx index 057f7477d..e628f25a7 100644 --- a/app/ui/app/src/components/Settings.tsx +++ b/app/ui/app/src/components/Settings.tsx @@ -14,12 +14,13 @@ import { XMarkIcon, CogIcon, ArrowLeftIcon, + ArrowDownTrayIcon, } from "@heroicons/react/20/solid"; import { Settings as SettingsType } from "@/gotypes"; import { useNavigate } from "@tanstack/react-router"; import { useUser } from "@/hooks/useUser"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { getSettings, updateSettings } from "@/api"; +import { getSettings, updateSettings, checkForUpdate } from "@/api"; function AnimatedDots() { return ( @@ -39,6 +40,12 @@ export default function Settings() { const queryClient = useQueryClient(); const [showSaved, setShowSaved] = useState(false); const [restartMessage, setRestartMessage] = useState(false); + const [updateInfo, setUpdateInfo] = useState<{ + currentVersion: string; + availableVersion: string; + updateAvailable: boolean; + updateDownloaded: boolean; + } | null>(null); const { user, isAuthenticated, @@ -76,8 +83,22 @@ export default function Settings() { useEffect(() => { refetchUser(); + // Check for updates + checkForUpdate() + .then(setUpdateInfo) + .catch((err) => console.error("Error checking for update:", err)); }, []); // eslint-disable-line react-hooks/exhaustive-deps + // Refresh update info when auto-update toggle changes + useEffect(() => { + if (settings?.AutoUpdateEnabled !== undefined) { + checkForUpdate() + .then(setUpdateInfo) + .catch((err) => console.error("Error checking for update:", err)); + } + }, [settings?.AutoUpdateEnabled]); + + useEffect(() => { const handleFocus = () => { if (isAwaitingConnection && pollingInterval) { @@ -344,6 +365,58 @@ export default function Settings() { {/* Local Configuration */}
+ {/* Auto Update */} + +
+
+ +
+ + + {settings.AutoUpdateEnabled ? ( + <> + Automatically downloads updates and restarts the app. +
+ Current version: {updateInfo?.currentVersion || "Loading..."} +
+ + ) : ( + <> + You must manually check for updates. +
+
+
+ Current version: {updateInfo?.currentVersion || "Loading..."} +
+ {updateInfo?.availableVersion && ( +
+ Available version: {updateInfo?.availableVersion} +
+ )} +
+ + Download new version → + +
+ + )} +
+
+
+
+ handleChange("AutoUpdateEnabled", checked)} + /> +
+
+
+ {/* Expose Ollama */}
diff --git a/app/ui/responses/types.go b/app/ui/responses/types.go index 2da6623fd..edbf08049 100644 --- a/app/ui/responses/types.go +++ b/app/ui/responses/types.go @@ -100,6 +100,17 @@ type HealthResponse struct { Healthy bool `json:"healthy"` } +type UpdateInfo struct { + CurrentVersion string `json:"currentVersion"` + AvailableVersion string `json:"availableVersion"` + UpdateAvailable bool `json:"updateAvailable"` + UpdateDownloaded bool `json:"updateDownloaded"` +} + +type UpdateCheckResponse struct { + UpdateInfo UpdateInfo `json:"updateInfo"` +} + type User struct { ID string `json:"id"` Email string `json:"email"` diff --git a/app/ui/ui.go b/app/ui/ui.go index 26de71422..cdd006c98 100644 --- a/app/ui/ui.go +++ b/app/ui/ui.go @@ -28,6 +28,7 @@ import ( "github.com/ollama/ollama/app/tools" "github.com/ollama/ollama/app/types/not" "github.com/ollama/ollama/app/ui/responses" + "github.com/ollama/ollama/app/updater" "github.com/ollama/ollama/app/version" ollamaAuth "github.com/ollama/ollama/auth" "github.com/ollama/ollama/envconfig" @@ -94,18 +95,30 @@ const ( ) type Server struct { - Logger *slog.Logger - Restart func() - Token string - Store *store.Store - ToolRegistry *tools.Registry - Tools bool // if true, the server will use single-turn tools to fulfill the user's request - WebSearch bool // if true, the server will use single-turn browser tool to fulfill the user's request - Agent bool // if true, the server will use multi-turn tools to fulfill the user's request - WorkingDir string // Working directory for all agent operations + Logger *slog.Logger + Restart func() + Token string + Store *store.Store + ToolRegistry *tools.Registry + Tools bool // if true, the server will use single-turn tools to fulfill the user's request + WebSearch bool // if true, the server will use single-turn browser tool to fulfill the user's request + Agent bool // if true, the server will use multi-turn tools to fulfill the user's request + WorkingDir string // Working directory for all agent operations // Dev is true if the server is running in development mode Dev bool + + // Updater for checking and downloading updates + Updater UpdaterInterface + UpdateAvailableFunc func() + ClearUpdateAvailableFunc func() +} + +// UpdaterInterface defines the methods we need from the updater +type UpdaterInterface interface { + CheckForUpdate(ctx context.Context) (bool, string, error) + DownloadUpdate(ctx context.Context, updateVersion string) error + InstallAndRestart() error } func (s *Server) log() *slog.Logger { @@ -250,7 +263,7 @@ func (s *Server) Handler() http.Handler { }() w.Header().Set("X-Frame-Options", "DENY") - w.Header().Set("X-Version", version.Version) + w.Header().Set("X-Version", version.GetVersion()) w.Header().Set("X-Request-ID", requestID) ctx := r.Context() @@ -284,6 +297,9 @@ func (s *Server) Handler() http.Handler { mux.Handle("POST /api/v1/model/upstream", handle(s.modelUpstream)) mux.Handle("GET /api/v1/settings", handle(s.getSettings)) mux.Handle("POST /api/v1/settings", handle(s.settings)) + mux.Handle("GET /api/v1/update/check", handle(s.checkForUpdate)) + mux.Handle("POST /api/v1/update/download", handle(s.downloadUpdate)) + mux.Handle("POST /api/v1/update/install", handle(s.installUpdate)) // Ollama proxy endpoints ollamaProxy := s.ollamaProxy() @@ -1448,6 +1464,19 @@ func (s *Server) settings(w http.ResponseWriter, r *http.Request) error { return fmt.Errorf("failed to save settings: %w", err) } + // Update tray notification based on auto-update toggle + if old.AutoUpdateEnabled && !settings.AutoUpdateEnabled { + // Auto-update disabled: clear tray notification + if s.ClearUpdateAvailableFunc != nil { + s.ClearUpdateAvailableFunc() + } + } else if !old.AutoUpdateEnabled && settings.AutoUpdateEnabled { + // Auto-update enabled: show tray notification if update is pending + if (updater.IsUpdatePending() || updater.UpdateDownloaded) && s.UpdateAvailableFunc != nil { + s.UpdateAvailableFunc() + } + } + if old.ContextLength != settings.ContextLength || old.Models != settings.Models || old.Expose != settings.Expose { @@ -1524,6 +1553,114 @@ func (s *Server) modelUpstream(w http.ResponseWriter, r *http.Request) error { return json.NewEncoder(w).Encode(response) } +func (s *Server) checkForUpdate(w http.ResponseWriter, r *http.Request) error { + if s.Updater == nil { + return fmt.Errorf("updater not available") + } + + updateAvailable, updateVersion, err := s.Updater.CheckForUpdate(r.Context()) + if err != nil { + s.log().Warn("failed to check for update", "error", err) + // Don't return error, just log it and continue with no update available + } + + // Get current version with fallbacks for development + currentVersion := version.GetVersion() + + response := responses.UpdateCheckResponse{ + UpdateInfo: responses.UpdateInfo{ + CurrentVersion: currentVersion, + AvailableVersion: updateVersion, + UpdateAvailable: updateAvailable, + UpdateDownloaded: updater.UpdateDownloaded, + }, + } + + w.Header().Set("Content-Type", "application/json") + return json.NewEncoder(w).Encode(response) +} + +func (s *Server) downloadUpdate(w http.ResponseWriter, r *http.Request) error { + if r.Method != "POST" { + return fmt.Errorf("method not allowed") + } + + if s.Updater == nil { + return fmt.Errorf("updater not available") + } + + var req struct { + Version string `json:"version"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + return fmt.Errorf("invalid request body: %w", err) + } + + if req.Version == "" { + return fmt.Errorf("version is required") + } + + err := s.Updater.DownloadUpdate(r.Context(), req.Version) + if err != nil { + s.log().Error("failed to download update", "error", err, "version", req.Version) + return fmt.Errorf("failed to download update: %w", err) + } + + response := map[string]any{ + "success": true, + "message": "Update downloaded successfully", + } + + w.Header().Set("Content-Type", "application/json") + return json.NewEncoder(w).Encode(response) +} + +func (s *Server) installUpdate(w http.ResponseWriter, r *http.Request) error { + if r.Method != "POST" { + return fmt.Errorf("method not allowed") + } + + if s.Updater == nil { + s.log().Error("install failed: updater not available") + return fmt.Errorf("updater not available") + } + + // Check if update is downloaded + if !updater.UpdateDownloaded { + s.log().Error("install failed: no update downloaded") + return fmt.Errorf("no update downloaded") + } + + // Check if we can actually upgrade (not in dev mode) + if updater.BundlePath == "" { + return fmt.Errorf("cannot install updates in development mode") + } + + // Send response before restarting + response := map[string]any{ + "success": true, + "message": "Installing update and restarting...", + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + return err + } + + // Give the response time to be sent + time.Sleep(500 * time.Millisecond) + + // Trigger the upgrade and restart + go func() { + time.Sleep(500 * time.Millisecond) + if err := s.Updater.InstallAndRestart(); err != nil { + s.log().Error("failed to install update", "error", err) + } + }() + + return nil +} + func userAgent() string { buildinfo, _ := debug.ReadBuildInfo() diff --git a/app/updater/updater.go b/app/updater/updater.go index 473ecf466..4e10d71d6 100644 --- a/app/updater/updater.go +++ b/app/updater/updater.go @@ -58,8 +58,11 @@ func (u *Updater) checkForUpdate(ctx context.Context) (bool, UpdateResponse) { query := requestURL.Query() query.Add("os", runtime.GOOS) query.Add("arch", runtime.GOARCH) - query.Add("version", version.Version) + currentVersion := version.GetVersion() + query.Add("version", currentVersion) query.Add("ts", strconv.FormatInt(time.Now().Unix(), 10)) + + slog.Debug("checking for update", "current_version", currentVersion, "os", runtime.GOOS, "arch", runtime.GOARCH) // The original macOS app used to use the device ID // to check for updates so include it if present @@ -94,7 +97,7 @@ func (u *Updater) checkForUpdate(ctx context.Context) (bool, UpdateResponse) { if signature != "" { req.Header.Set("Authorization", signature) } - ua := fmt.Sprintf("ollama/%s %s Go/%s %s", version.Version, runtime.GOARCH, runtime.Version(), UserAgentOS) + ua := fmt.Sprintf("ollama/%s %s Go/%s %s", version.GetVersion(), runtime.GOARCH, runtime.Version(), UserAgentOS) req.Header.Set("User-Agent", ua) slog.Debug("checking for available update", "requestURL", requestURL, "User-Agent", ua) @@ -176,6 +179,7 @@ func (u *Updater) DownloadNewRelease(ctx context.Context, updateResp UpdateRespo _, err = os.Stat(stageFilename) if err == nil { slog.Info("update already downloaded", "bundle", stageFilename) + UpdateDownloaded = true return nil } @@ -253,6 +257,22 @@ func (u *Updater) StartBackgroundUpdaterChecker(ctx context.Context, cb func(str time.Sleep(UpdateCheckInitialDelay) slog.Info("beginning update checker", "interval", UpdateCheckInterval) for { + // Check if auto-update is enabled + settings, err := u.Store.Settings() + if err != nil { + slog.Error("failed to load settings", "error", err) + time.Sleep(UpdateCheckInterval) + continue + } + + if !settings.AutoUpdateEnabled { + // When auto-update is disabled, don't check or download anything + slog.Debug("auto-update disabled, skipping check") + time.Sleep(UpdateCheckInterval) + continue + } + + // Auto-update is enabled - proceed with normal flow available, resp := u.checkForUpdate(ctx) if available { err := u.DownloadNewRelease(ctx, resp) @@ -275,3 +295,39 @@ func (u *Updater) StartBackgroundUpdaterChecker(ctx context.Context, cb func(str } }() } + +func (u *Updater) CheckForUpdate(ctx context.Context) (bool, string, error) { + available, resp := u.checkForUpdate(ctx) + return available, resp.UpdateVersion, nil +} + +func (u *Updater) DownloadUpdate(ctx context.Context, updateVersion string) error { + // First check for update to get the actual download URL + available, updateResp := u.checkForUpdate(ctx) + + if !available { + return fmt.Errorf("no update available") + } + if updateResp.UpdateVersion != updateVersion { + slog.Error("version mismatch", "requested", updateVersion, "available", updateResp.UpdateVersion) + return fmt.Errorf("version mismatch: requested %s, available %s", updateVersion, updateResp.UpdateVersion) + } + + slog.Info("downloading update", "version", updateVersion) + err := u.DownloadNewRelease(ctx, updateResp) + if err != nil { + return err + } + + slog.Info("update downloaded successfully", "version", updateVersion) + return nil +} + +func (u *Updater) InstallAndRestart() error { + if !UpdateDownloaded { + return fmt.Errorf("no update downloaded") + } + + slog.Info("installing update and restarting") + return DoUpgrade(true) +} diff --git a/app/version/version.go b/app/version/version.go index 5955d601c..7c0bdc5ac 100644 --- a/app/version/version.go +++ b/app/version/version.go @@ -2,4 +2,39 @@ package version +import ( + "os/exec" + "runtime/debug" + "strings" +) + var Version string = "0.0.0" + +// GetVersion returns the version, with fallback to git or build info +func GetVersion() string { + // If version is set via ldflags, use it + if Version != "" && Version != "0.0.0" { + return Version + } + + // Try to get from build info + if buildinfo, ok := debug.ReadBuildInfo(); ok { + if buildinfo.Main.Version != "" && buildinfo.Main.Version != "(devel)" { + return buildinfo.Main.Version + } + } + + // In development, try to get from git + if cmd := exec.Command("git", "describe", "--tags", "--first-parent", "--abbrev=7", "--long", "--dirty", "--always"); cmd != nil { + if output, err := cmd.Output(); err == nil { + version := strings.TrimSpace(string(output)) + version = strings.TrimPrefix(version, "v") + if version != "" { + return version + } + } + } + + // Fallback + return "dev" +} diff --git a/app/wintray/menus.go b/app/wintray/menus.go index 106953b7a..6e752ef57 100644 --- a/app/wintray/menus.go +++ b/app/wintray/menus.go @@ -47,6 +47,34 @@ func (t *winTray) initMenus() error { return nil } +func (t *winTray) ClearUpdateAvailable() error { + if t.updateNotified { + slog.Debug("clearing update notification and menu items") + if err := t.removeMenuItem(updateSeparatorMenuID, 0); err != nil { + return fmt.Errorf("unable to remove menu entries %w", err) + } + if err := t.removeMenuItem(updateAvailableMenuID, 0); err != nil { + return fmt.Errorf("unable to remove menu entries %w", err) + } + if err := t.removeMenuItem(updateMenuID, 0); err != nil { + return fmt.Errorf("unable to remove menu entries %w", err) + } + if err := t.removeMenuItem(separatorMenuID, 0); err != nil { + return fmt.Errorf("unable to remove menu entries %w", err) + } + iconFilePath, err := iconBytesToFilePath(wt.normalIcon) + if err != nil { + return fmt.Errorf("unable to write icon data to temp file: %w", err) + } + if err := t.setIcon(iconFilePath); err != nil { + return fmt.Errorf("unable to set icon: %w", err) + } + t.updateNotified = false + t.pendingUpdate = false + } + return nil +} + func (t *winTray) UpdateAvailable(ver string) error { if !t.updateNotified { slog.Debug("updating menu and sending notification for new update") diff --git a/app/wintray/tray.go b/app/wintray/tray.go index 71d4bc767..0c1a2ed0f 100644 --- a/app/wintray/tray.go +++ b/app/wintray/tray.go @@ -369,24 +369,24 @@ func (t *winTray) addSeparatorMenuItem(menuItemId, parentId uint32) error { return nil } -// func (t *winTray) hideMenuItem(menuItemId, parentId uint32) error { -// const ERROR_SUCCESS syscall.Errno = 0 +func (t *winTray) removeMenuItem(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) + 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 -// } + return nil +} func (t *winTray) showMenu() error { p := point{} diff --git a/app/wintray/w32api.go b/app/wintray/w32api.go index 10e255816..792813a34 100644 --- a/app/wintray/w32api.go +++ b/app/wintray/w32api.go @@ -30,6 +30,7 @@ var ( pPostQuitMessage = u32.NewProc("PostQuitMessage") pRegisterClass = u32.NewProc("RegisterClassExW") pRegisterWindowMessage = u32.NewProc("RegisterWindowMessageW") + pRemoveMenu = u32.NewProc("RemoveMenu") pSendMessage = u32.NewProc("SendMessageW") pSetForegroundWindow = u32.NewProc("SetForegroundWindow") pSetMenuInfo = u32.NewProc("SetMenuInfo")