Compare commits

...

13 Commits

Author SHA1 Message Date
Eva Ho 68a3414761 fix test 2026-01-05 13:24:41 -05:00
Eva Ho 9a5c14c58b address comments 2026-01-05 09:38:44 -05:00
Eva Ho 391fb88bce address comment 2026-01-05 09:38:44 -05:00
Eva Ho 75500c8855 address comment 2026-01-05 09:38:44 -05:00
Eva Ho e55fbf2475 fix: gofmt formatting in updater_test.go 2026-01-05 09:38:44 -05:00
Eva Ho c6f941adb3 fix test 2026-01-05 09:38:44 -05:00
Eva Ho 0eb320e74c fix format 2026-01-05 09:38:44 -05:00
Eva Ho 880b4f95b4 fix test 2026-01-05 09:38:44 -05:00
Eva Ho ba25f4a898 fix test 2026-01-05 09:38:44 -05:00
Eva Ho dc573715c4 clean up 2026-01-05 09:38:44 -05:00
Eva Ho 5a5d3260f4 fix behaviour when switching between enabled and disabled 2026-01-05 09:38:44 -05:00
Eva Ho cf7e5e88bc fix test 2026-01-05 09:38:44 -05:00
Eva Ho e76abac24e app: add upgrade configuration to settings page 2026-01-05 09:38:44 -05:00
13 changed files with 463 additions and 55 deletions

View File

@ -253,6 +253,8 @@ func main() {
done <- osrv.Run(octx) done <- osrv.Run(octx)
}() }()
upd := &updater.Updater{Store: st}
uiServer := ui.Server{ uiServer := ui.Server{
Token: token, Token: token,
Restart: func() { Restart: func() {
@ -267,6 +269,10 @@ func main() {
ToolRegistry: toolRegistry, ToolRegistry: toolRegistry,
Dev: devMode, Dev: devMode,
Logger: slog.Default(), Logger: slog.Default(),
Updater: upd,
UpdateAvailableFunc: func() {
UpdateAvailable("")
},
} }
srv := &http.Server{ srv := &http.Server{
@ -284,8 +290,13 @@ func main() {
slog.Debug("background desktop server done") slog.Debug("background desktop server done")
}() }()
updater := &updater.Updater{Store: st} upd.StartBackgroundUpdaterChecker(ctx, UpdateAvailable)
updater.StartBackgroundUpdaterChecker(ctx, UpdateAvailable)
// Check for pending updates on startup (show tray notification if update is ready)
if updater.IsUpdatePending() {
slog.Debug("update pending on startup, showing tray notification")
UpdateAvailable("")
}
hasCompletedFirstRun, err := st.HasCompletedFirstRun() hasCompletedFirstRun, err := st.HasCompletedFirstRun()
if err != nil { if err != nil {
@ -348,6 +359,18 @@ func startHiddenTasks() {
// CLI triggered app startup use-case // CLI triggered app startup use-case
slog.Info("deferring pending update for fast startup") slog.Info("deferring pending update for fast startup")
} else { } else {
// Check if auto-update is enabled before automatically 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")
// Still show tray notification so user knows update is ready
UpdateAvailable("")
return
}
if err := updater.DoUpgradeAtStartup(); err != nil { if err := updater.DoUpgradeAtStartup(); err != nil {
slog.Info("unable to perform upgrade at startup", "error", err) 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 // Make sure the restart to upgrade menu shows so we can attempt an interactive upgrade to get authorization

View File

@ -157,6 +157,10 @@ func UpdateAvailable(ver string) error {
return app.t.UpdateAvailable(ver) return app.t.UpdateAvailable(ver)
} }
func ClearUpdateAvailable() error {
return app.t.ClearUpdateAvailable()
}
func osRun(shutdown func(), hasCompletedFirstRun, startHidden bool) { func osRun(shutdown func(), hasCompletedFirstRun, startHidden bool) {
var err error var err error
app.shutdown = shutdown app.shutdown = shutdown

View File

@ -9,12 +9,12 @@ import (
"strings" "strings"
"time" "time"
sqlite3 "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
) )
// currentSchemaVersion defines the current database schema version. // currentSchemaVersion defines the current database schema version.
// Increment this when making schema changes that require migrations. // Increment this when making schema changes that require migrations.
const currentSchemaVersion = 12 const currentSchemaVersion = 13
// database wraps the SQLite connection. // database wraps the SQLite connection.
// SQLite handles its own locking for concurrent access: // 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_enabled BOOLEAN NOT NULL DEFAULT 0,
think_level TEXT NOT NULL DEFAULT '', think_level TEXT NOT NULL DEFAULT '',
remote TEXT NOT NULL DEFAULT '', -- deprecated remote TEXT NOT NULL DEFAULT '', -- deprecated
auto_update_enabled BOOLEAN NOT NULL DEFAULT 1,
schema_version INTEGER NOT NULL DEFAULT %d 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) return fmt.Errorf("migrate v11 to v12: %w", err)
} }
version = 12 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: default:
// If we have a version we don't recognize, just set it to current // If we have a version we don't recognize, just set it to current
// This might happen during development // This might happen during development
@ -452,6 +459,21 @@ func (db *database) migrateV11ToV12() error {
return nil 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 // cleanupOrphanedData removes orphaned records that may exist due to the foreign key bug
func (db *database) cleanupOrphanedData() error { func (db *database) cleanupOrphanedData() error {
_, err := db.conn.Exec(` _, err := db.conn.Exec(`
@ -482,19 +504,11 @@ func (db *database) cleanupOrphanedData() error {
} }
func duplicateColumnError(err error) bool { func duplicateColumnError(err error) bool {
if sqlite3Err, ok := err.(sqlite3.Error); ok { return err != nil && strings.Contains(err.Error(), "duplicate column name")
return sqlite3Err.Code == sqlite3.ErrError &&
strings.Contains(sqlite3Err.Error(), "duplicate column name")
}
return false
} }
func columnNotExists(err error) bool { func columnNotExists(err error) bool {
if sqlite3Err, ok := err.(sqlite3.Error); ok { return err != nil && strings.Contains(err.Error(), "no such column")
return sqlite3Err.Code == sqlite3.ErrError &&
strings.Contains(sqlite3Err.Error(), "no such column")
}
return false
} }
func (db *database) getAllChats() ([]Chat, error) { func (db *database) getAllChats() ([]Chat, error) {
@ -1108,9 +1122,9 @@ func (db *database) getSettings() (Settings, error) {
var s Settings var s Settings
err := db.conn.QueryRow(` 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 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 { if err != nil {
return Settings{}, fmt.Errorf("get settings: %w", err) return Settings{}, fmt.Errorf("get settings: %w", err)
} }
@ -1121,8 +1135,8 @@ func (db *database) getSettings() (Settings, error) {
func (db *database) setSettings(s Settings) error { func (db *database) setSettings(s Settings) error {
_, err := db.conn.Exec(` _, err := db.conn.Exec(`
UPDATE settings 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 = ? 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.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 { if err != nil {
return fmt.Errorf("set settings: %w", err) return fmt.Errorf("set settings: %w", err)
} }

View File

@ -169,6 +169,9 @@ type Settings struct {
// SidebarOpen indicates if the chat sidebar is open // SidebarOpen indicates if the chat sidebar is open
SidebarOpen bool SidebarOpen bool
// AutoUpdateEnabled indicates if automatic updates should be downloaded
AutoUpdateEnabled bool
} }
type Store struct { type Store struct {

View File

@ -413,6 +413,7 @@ export class Settings {
ThinkLevel: string; ThinkLevel: string;
SelectedModel: string; SelectedModel: string;
SidebarOpen: boolean; SidebarOpen: boolean;
AutoUpdateEnabled: boolean;
constructor(source: any = {}) { constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
@ -431,6 +432,7 @@ export class Settings {
this.ThinkLevel = source["ThinkLevel"]; this.ThinkLevel = source["ThinkLevel"];
this.SelectedModel = source["SelectedModel"]; this.SelectedModel = source["SelectedModel"];
this.SidebarOpen = source["SidebarOpen"]; this.SidebarOpen = source["SidebarOpen"];
this.AutoUpdateEnabled = source["AutoUpdateEnabled"];
} }
} }
export class SettingsResponse { export class SettingsResponse {
@ -467,6 +469,46 @@ export class HealthResponse {
this.healthy = source["healthy"]; 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 { export class User {
id: string; id: string;
email: string; email: string;

View File

@ -414,3 +414,54 @@ export async function fetchHealth(): Promise<boolean> {
return false; return false;
} }
} }
export async function getCurrentVersion(): Promise<string> {
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 installUpdate(): Promise<void> {
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");
}
}

View File

@ -14,12 +14,13 @@ import {
XMarkIcon, XMarkIcon,
CogIcon, CogIcon,
ArrowLeftIcon, ArrowLeftIcon,
ArrowDownTrayIcon,
} from "@heroicons/react/20/solid"; } from "@heroicons/react/20/solid";
import { Settings as SettingsType } from "@/gotypes"; import { Settings as SettingsType } from "@/gotypes";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { useUser } from "@/hooks/useUser"; import { useUser } from "@/hooks/useUser";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getSettings, updateSettings } from "@/api"; import { getSettings, updateSettings, checkForUpdate } from "@/api";
function AnimatedDots() { function AnimatedDots() {
return ( return (
@ -39,6 +40,12 @@ export default function Settings() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [showSaved, setShowSaved] = useState(false); const [showSaved, setShowSaved] = useState(false);
const [restartMessage, setRestartMessage] = useState(false); const [restartMessage, setRestartMessage] = useState(false);
const [updateInfo, setUpdateInfo] = useState<{
currentVersion: string;
availableVersion: string;
updateAvailable: boolean;
updateDownloaded: boolean;
} | null>(null);
const { const {
user, user,
isAuthenticated, isAuthenticated,
@ -76,8 +83,22 @@ export default function Settings() {
useEffect(() => { useEffect(() => {
refetchUser(); refetchUser();
// Check for updates
checkForUpdate()
.then(setUpdateInfo)
.catch((err) => console.error("Error checking for update:", err));
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // 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(() => { useEffect(() => {
const handleFocus = () => { const handleFocus = () => {
if (isAwaitingConnection && pollingInterval) { if (isAwaitingConnection && pollingInterval) {
@ -344,6 +365,58 @@ export default function Settings() {
{/* Local Configuration */} {/* Local Configuration */}
<div className="relative overflow-hidden rounded-xl bg-white dark:bg-neutral-800"> <div className="relative overflow-hidden rounded-xl bg-white dark:bg-neutral-800">
<div className="space-y-4 p-4"> <div className="space-y-4 p-4">
{/* Auto Update */}
<Field>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start space-x-3 flex-1">
<ArrowDownTrayIcon className="mt-1 h-5 w-5 flex-shrink-0 text-black dark:text-neutral-100" />
<div className="flex-1">
<Label>Auto-download updates</Label>
<Description>
{settings.AutoUpdateEnabled ? (
<>
Automatically downloads updates when available.
<div className="mt-2 text-xs text-zinc-600 dark:text-zinc-400">
Current version: {updateInfo?.currentVersion || "Loading..."}
</div>
</>
) : (
<>
Manually download updates.
<div className="mt-3 p-3 bg-zinc-50 dark:bg-zinc-900 rounded-lg border border-zinc-200 dark:border-zinc-800">
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-zinc-600 dark:text-zinc-400">Current version: {updateInfo?.currentVersion || "Loading..."}</span>
</div>
{updateInfo?.availableVersion && (
<div className="flex justify-between">
<span className="text-zinc-600 dark:text-zinc-400">Available version: {updateInfo?.availableVersion}</span>
</div>
)}
</div>
<a
href="https://ollama.com/download"
target="_blank"
rel="noopener noreferrer"
className="mt-3 inline-block text-sm text-neutral-600 dark:text-neutral-400 underline"
>
Download new version
</a>
</div>
</>
)}
</Description>
</div>
</div>
<div className="flex-shrink-0">
<Switch
checked={settings.AutoUpdateEnabled}
onChange={(checked) => handleChange("AutoUpdateEnabled", checked)}
/>
</div>
</div>
</Field>
{/* Expose Ollama */} {/* Expose Ollama */}
<Field> <Field>
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">

View File

@ -100,6 +100,17 @@ type HealthResponse struct {
Healthy bool `json:"healthy"` 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 { type User struct {
ID string `json:"id"` ID string `json:"id"`
Email string `json:"email"` Email string `json:"email"`

View File

@ -28,6 +28,7 @@ import (
"github.com/ollama/ollama/app/tools" "github.com/ollama/ollama/app/tools"
"github.com/ollama/ollama/app/types/not" "github.com/ollama/ollama/app/types/not"
"github.com/ollama/ollama/app/ui/responses" "github.com/ollama/ollama/app/ui/responses"
"github.com/ollama/ollama/app/updater"
"github.com/ollama/ollama/app/version" "github.com/ollama/ollama/app/version"
ollamaAuth "github.com/ollama/ollama/auth" ollamaAuth "github.com/ollama/ollama/auth"
"github.com/ollama/ollama/envconfig" "github.com/ollama/ollama/envconfig"
@ -106,6 +107,18 @@ type Server struct {
// Dev is true if the server is running in development mode // Dev is true if the server is running in development mode
Dev bool Dev bool
// Updater for checking and downloading updates
Updater UpdaterInterface
UpdateAvailableFunc func()
}
// UpdaterInterface defines the methods we need from the updater
type UpdaterInterface interface {
CheckForUpdate(ctx context.Context) (bool, string, error)
InstallAndRestart() error
CancelOngoingDownload()
TriggerImmediateCheck()
} }
func (s *Server) log() *slog.Logger { func (s *Server) log() *slog.Logger {
@ -284,6 +297,8 @@ func (s *Server) Handler() http.Handler {
mux.Handle("POST /api/v1/model/upstream", handle(s.modelUpstream)) mux.Handle("POST /api/v1/model/upstream", handle(s.modelUpstream))
mux.Handle("GET /api/v1/settings", handle(s.getSettings)) mux.Handle("GET /api/v1/settings", handle(s.getSettings))
mux.Handle("POST /api/v1/settings", handle(s.settings)) 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/install", handle(s.installUpdate))
// Ollama proxy endpoints // Ollama proxy endpoints
ollamaProxy := s.ollamaProxy() ollamaProxy := s.ollamaProxy()
@ -1448,6 +1463,24 @@ func (s *Server) settings(w http.ResponseWriter, r *http.Request) error {
return fmt.Errorf("failed to save settings: %w", err) return fmt.Errorf("failed to save settings: %w", err)
} }
// Handle auto-update toggle changes
if old.AutoUpdateEnabled != settings.AutoUpdateEnabled {
if !settings.AutoUpdateEnabled {
// Auto-update disabled: cancel any ongoing download
if s.Updater != nil {
s.Updater.CancelOngoingDownload()
}
} else {
// Auto-update re-enabled: show notification if update is already staged, or trigger immediate check
if (updater.IsUpdatePending() || updater.UpdateDownloaded) && s.UpdateAvailableFunc != nil {
s.UpdateAvailableFunc()
} else if s.Updater != nil {
// Trigger the background checker to run immediately
s.Updater.TriggerImmediateCheck()
}
}
}
if old.ContextLength != settings.ContextLength || if old.ContextLength != settings.ContextLength ||
old.Models != settings.Models || old.Models != settings.Models ||
old.Expose != settings.Expose { old.Expose != settings.Expose {
@ -1524,6 +1557,73 @@ func (s *Server) modelUpstream(w http.ResponseWriter, r *http.Request) error {
return json.NewEncoder(w).Encode(response) return json.NewEncoder(w).Encode(response)
} }
func (s *Server) checkForUpdate(w http.ResponseWriter, r *http.Request) error {
currentVersion := version.Version
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
}
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) 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")
}
// 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 { func userAgent() string {
buildinfo, _ := debug.ReadBuildInfo() buildinfo, _ := debug.ReadBuildInfo()

View File

@ -19,6 +19,7 @@ import (
"runtime" "runtime"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"github.com/ollama/ollama/app/store" "github.com/ollama/ollama/app/store"
@ -58,7 +59,8 @@ func (u *Updater) checkForUpdate(ctx context.Context) (bool, UpdateResponse) {
query := requestURL.Query() query := requestURL.Query()
query.Add("os", runtime.GOOS) query.Add("os", runtime.GOOS)
query.Add("arch", runtime.GOARCH) query.Add("arch", runtime.GOARCH)
query.Add("version", version.Version) currentVersion := version.Version
query.Add("version", currentVersion)
query.Add("ts", strconv.FormatInt(time.Now().Unix(), 10)) query.Add("ts", strconv.FormatInt(time.Now().Unix(), 10))
// The original macOS app used to use the device ID // The original macOS app used to use the device ID
@ -131,15 +133,27 @@ func (u *Updater) checkForUpdate(ctx context.Context) (bool, UpdateResponse) {
} }
func (u *Updater) DownloadNewRelease(ctx context.Context, updateResp UpdateResponse) error { func (u *Updater) DownloadNewRelease(ctx context.Context, updateResp UpdateResponse) error {
// Create a cancellable context for this download
downloadCtx, cancel := context.WithCancel(ctx)
u.cancelDownloadLock.Lock()
u.cancelDownload = cancel
u.cancelDownloadLock.Unlock()
defer func() {
u.cancelDownloadLock.Lock()
u.cancelDownload = nil
u.cancelDownloadLock.Unlock()
cancel()
}()
// Do a head first to check etag info // Do a head first to check etag info
req, err := http.NewRequestWithContext(ctx, http.MethodHead, updateResp.UpdateURL, nil) req, err := http.NewRequestWithContext(downloadCtx, http.MethodHead, updateResp.UpdateURL, nil)
if err != nil { if err != nil {
return err return err
} }
// In case of slow downloads, continue the update check in the background // In case of slow downloads, continue the update check in the background
bgctx, cancel := context.WithCancel(ctx) bgctx, bgcancel := context.WithCancel(downloadCtx)
defer cancel() defer bgcancel()
go func() { go func() {
for { for {
select { select {
@ -176,6 +190,7 @@ func (u *Updater) DownloadNewRelease(ctx context.Context, updateResp UpdateRespo
_, err = os.Stat(stageFilename) _, err = os.Stat(stageFilename)
if err == nil { if err == nil {
slog.Info("update already downloaded", "bundle", stageFilename) slog.Info("update already downloaded", "bundle", stageFilename)
UpdateDownloaded = true
return nil return nil
} }
@ -245,33 +260,94 @@ func cleanupOldDownloads(stageDir string) {
type Updater struct { type Updater struct {
Store *store.Store Store *store.Store
cancelDownload context.CancelFunc
cancelDownloadLock sync.Mutex
checkNow chan struct{}
}
// CancelOngoingDownload cancels any currently running download
func (u *Updater) CancelOngoingDownload() {
u.cancelDownloadLock.Lock()
defer u.cancelDownloadLock.Unlock()
if u.cancelDownload != nil {
slog.Info("cancelling ongoing update download")
u.cancelDownload()
u.cancelDownload = nil
}
}
// TriggerImmediateCheck signals the background checker to check for updates immediately
func (u *Updater) TriggerImmediateCheck() {
if u.checkNow != nil {
u.checkNow <- struct{}{}
}
} }
func (u *Updater) StartBackgroundUpdaterChecker(ctx context.Context, cb func(string) error) { func (u *Updater) StartBackgroundUpdaterChecker(ctx context.Context, cb func(string) error) {
u.checkNow = make(chan struct{}, 1)
go func() { go func() {
// Don't blast an update message immediately after startup // Don't blast an update message immediately after startup
time.Sleep(UpdateCheckInitialDelay) time.Sleep(UpdateCheckInitialDelay)
slog.Info("beginning update checker", "interval", UpdateCheckInterval) slog.Info("beginning update checker", "interval", UpdateCheckInterval)
ticker := time.NewTicker(UpdateCheckInterval)
defer ticker.Stop()
for { for {
available, resp := u.checkForUpdate(ctx)
if available {
err := u.DownloadNewRelease(ctx, resp)
if err != nil {
slog.Error(fmt.Sprintf("failed to download new release: %s", err))
} else {
err = cb(resp.UpdateVersion)
if err != nil {
slog.Warn(fmt.Sprintf("failed to register update available with tray: %s", err))
}
}
}
select { select {
case <-ctx.Done(): case <-ctx.Done():
slog.Debug("stopping background update checker") slog.Debug("stopping background update checker")
return return
default: case <-u.checkNow:
time.Sleep(UpdateCheckInterval) // Immediate check triggered
case <-ticker.C:
// Regular interval check
}
// Always check for updates
available, resp := u.checkForUpdate(ctx)
if !available {
continue
}
// Update is available - check if auto-update is enabled for downloading
settings, err := u.Store.Settings()
if err != nil {
slog.Error("failed to load settings", "error", err)
continue
}
if !settings.AutoUpdateEnabled {
// Auto-update disabled - don't download, just log
slog.Debug("update available but auto-update disabled", "version", resp.UpdateVersion)
continue
}
// Auto-update is enabled - download
err = u.DownloadNewRelease(ctx, resp)
if err != nil {
slog.Error("failed to download new release", "error", err)
continue
}
// Download successful - show tray notification (regardless of toggle state)
err = cb(resp.UpdateVersion)
if err != nil {
slog.Warn("failed to register update available with tray", "error", err)
} }
} }
}() }()
} }
func (u *Updater) CheckForUpdate(ctx context.Context) (bool, string, error) {
available, resp := u.checkForUpdate(ctx)
return available, resp.UpdateVersion, nil
}
func (u *Updater) InstallAndRestart() error {
if !UpdateDownloaded {
return fmt.Errorf("no update downloaded")
}
slog.Info("installing update and restarting")
return DoUpgrade(true)
}

View File

@ -85,7 +85,17 @@ func TestBackgoundChecker(t *testing.T) {
UpdateCheckURLBase = server.URL + "/update.json" UpdateCheckURLBase = server.URL + "/update.json"
updater := &Updater{Store: &store.Store{}} updater := &Updater{Store: &store.Store{}}
defer updater.Store.Close() // Ensure database is closed defer updater.Store.Close()
settings, err := updater.Store.Settings()
if err != nil {
t.Fatal(err)
}
settings.AutoUpdateEnabled = true
if err := updater.Store.SetSettings(settings); err != nil {
t.Fatal(err)
}
updater.StartBackgroundUpdaterChecker(ctx, cb) updater.StartBackgroundUpdaterChecker(ctx, cb)
select { select {
case <-stallTimer.C: case <-stallTimer.C:

View File

@ -369,24 +369,24 @@ func (t *winTray) addSeparatorMenuItem(menuItemId, parentId uint32) error {
return nil return nil
} }
// func (t *winTray) hideMenuItem(menuItemId, parentId uint32) error { func (t *winTray) removeMenuItem(menuItemId, parentId uint32) error {
// const ERROR_SUCCESS syscall.Errno = 0 const ERROR_SUCCESS syscall.Errno = 0
// t.muMenus.RLock() t.muMenus.RLock()
// menu := uintptr(t.menus[parentId]) menu := uintptr(t.menus[parentId])
// t.muMenus.RUnlock() t.muMenus.RUnlock()
// res, _, err := pRemoveMenu.Call( res, _, err := pRemoveMenu.Call(
// menu, menu,
// uintptr(menuItemId), uintptr(menuItemId),
// MF_BYCOMMAND, MF_BYCOMMAND,
// ) )
// if res == 0 && err.(syscall.Errno) != ERROR_SUCCESS { if res == 0 && err.(syscall.Errno) != ERROR_SUCCESS {
// return err return err
// } }
// t.delFromVisibleItems(parentId, menuItemId) t.delFromVisibleItems(parentId, menuItemId)
// return nil return nil
// } }
func (t *winTray) showMenu() error { func (t *winTray) showMenu() error {
p := point{} p := point{}

View File

@ -30,6 +30,7 @@ var (
pPostQuitMessage = u32.NewProc("PostQuitMessage") pPostQuitMessage = u32.NewProc("PostQuitMessage")
pRegisterClass = u32.NewProc("RegisterClassExW") pRegisterClass = u32.NewProc("RegisterClassExW")
pRegisterWindowMessage = u32.NewProc("RegisterWindowMessageW") pRegisterWindowMessage = u32.NewProc("RegisterWindowMessageW")
pRemoveMenu = u32.NewProc("RemoveMenu")
pSendMessage = u32.NewProc("SendMessageW") pSendMessage = u32.NewProc("SendMessageW")
pSetForegroundWindow = u32.NewProc("SetForegroundWindow") pSetForegroundWindow = u32.NewProc("SetForegroundWindow")
pSetMenuInfo = u32.NewProc("SetMenuInfo") pSetMenuInfo = u32.NewProc("SetMenuInfo")