app: add upgrade configuration to settings page

This commit is contained in:
Eva Ho 2025-12-17 14:37:18 -05:00
parent d087e46bd1
commit e76abac24e
17 changed files with 552 additions and 36 deletions

View File

@ -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

View File

@ -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)

View File

@ -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);

View File

@ -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];

View File

@ -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

View File

@ -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)
}

View File

@ -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 {

View File

@ -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;

View File

@ -414,3 +414,68 @@ export async function fetchHealth(): Promise<boolean> {
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 downloadUpdate(version: string): Promise<void> {
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<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,
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 */}
<div className="relative overflow-hidden rounded-xl bg-white dark:bg-neutral-800">
<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 and restarts the app.
<div className="mt-2 text-xs text-zinc-600 dark:text-zinc-400">
Current version: {updateInfo?.currentVersion || "Loading..."}
</div>
</>
) : (
<>
You must manually check for 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 */}
<Field>
<div className="flex items-start justify-between gap-4">

View File

@ -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"`

View File

@ -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()

View File

@ -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)
}

View File

@ -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"
}

View File

@ -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")

View File

@ -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{}

View File

@ -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")