Compare commits
13 Commits
main
...
hoyyeva/up
| Author | SHA1 | Date |
|---|---|---|
|
|
68a3414761 | |
|
|
9a5c14c58b | |
|
|
391fb88bce | |
|
|
75500c8855 | |
|
|
e55fbf2475 | |
|
|
c6f941adb3 | |
|
|
0eb320e74c | |
|
|
880b4f95b4 | |
|
|
ba25f4a898 | |
|
|
dc573715c4 | |
|
|
5a5d3260f4 | |
|
|
cf7e5e88bc | |
|
|
e76abac24e |
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
100
app/ui/ui.go
100
app/ui/ui.go
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -244,34 +259,95 @@ 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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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{}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue