//go:build windows || darwin package updater import ( "context" "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "log/slog" "mime" "net/http" "net/url" "os" "path" "path/filepath" "runtime" "strconv" "strings" "sync" "time" "github.com/ollama/ollama/app/store" "github.com/ollama/ollama/app/version" "github.com/ollama/ollama/auth" ) var ( UpdateCheckURLBase = "https://ollama.com/api/update" UpdateDownloaded = false UpdateCheckInterval = 60 * 60 * time.Second UpdateCheckInitialDelay = 3 * time.Second // 30 * time.Second UpdateStageDir string UpgradeLogFile string UpgradeMarkerFile string Installer string UserAgentOS string VerifyDownload func() error ) // TODO - maybe move up to the API package? type UpdateResponse struct { UpdateURL string `json:"url"` UpdateVersion string `json:"version"` } func (u *Updater) checkForUpdate(ctx context.Context) (bool, UpdateResponse) { var updateResp UpdateResponse requestURL, err := url.Parse(UpdateCheckURLBase) if err != nil { return false, updateResp } query := requestURL.Query() query.Add("os", runtime.GOOS) query.Add("arch", runtime.GOARCH) currentVersion := version.Version query.Add("version", currentVersion) query.Add("ts", strconv.FormatInt(time.Now().Unix(), 10)) // The original macOS app used to use the device ID // to check for updates so include it if present if runtime.GOOS == "darwin" { if id, err := u.Store.ID(); err == nil && id != "" { query.Add("id", id) } } var signature string nonce, err := auth.NewNonce(rand.Reader, 16) if err != nil { // Don't sign if we haven't yet generated a key pair for the server slog.Debug("unable to generate nonce for update check request", "error", err) } else { query.Add("nonce", nonce) requestURL.RawQuery = query.Encode() data := []byte(fmt.Sprintf("%s,%s", http.MethodGet, requestURL.RequestURI())) signature, err = auth.Sign(ctx, data) if err != nil { slog.Debug("unable to generate signature for update check request", "error", err) } } req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL.String(), nil) if err != nil { slog.Warn(fmt.Sprintf("failed to check for update: %s", err)) return false, updateResp } if signature != "" { req.Header.Set("Authorization", signature) } ua := fmt.Sprintf("ollama/%s %s Go/%s %s", version.Version, runtime.GOARCH, runtime.Version(), UserAgentOS) req.Header.Set("User-Agent", ua) slog.Debug("checking for available update", "requestURL", requestURL, "User-Agent", ua) resp, err := http.DefaultClient.Do(req) if err != nil { slog.Warn(fmt.Sprintf("failed to check for update: %s", err)) return false, updateResp } defer resp.Body.Close() if resp.StatusCode == http.StatusNoContent { slog.Debug("check update response 204 (current version is up to date)") return false, updateResp } body, err := io.ReadAll(resp.Body) if err != nil { slog.Warn(fmt.Sprintf("failed to read body response: %s", err)) } if resp.StatusCode != http.StatusOK { slog.Info(fmt.Sprintf("check update error %d - %.96s", resp.StatusCode, string(body))) return false, updateResp } err = json.Unmarshal(body, &updateResp) if err != nil { slog.Warn(fmt.Sprintf("malformed response checking for update: %s", err)) return false, updateResp } // Extract the version string from the URL in the github release artifact path updateResp.UpdateVersion = path.Base(path.Dir(updateResp.UpdateURL)) slog.Info("New update available at " + updateResp.UpdateURL) return true, updateResp } 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 req, err := http.NewRequestWithContext(downloadCtx, http.MethodHead, updateResp.UpdateURL, nil) if err != nil { return err } // In case of slow downloads, continue the update check in the background bgctx, bgcancel := context.WithCancel(downloadCtx) defer bgcancel() go func() { for { select { case <-bgctx.Done(): return case <-time.After(UpdateCheckInterval): u.checkForUpdate(bgctx) } } }() resp, err := http.DefaultClient.Do(req) if err != nil { return fmt.Errorf("error checking update: %w", err) } resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("unexpected status attempting to download update %d", resp.StatusCode) } filename := Installer _, params, err := mime.ParseMediaType(resp.Header.Get("content-disposition")) if err == nil && params["filename"] != "" { filename = params["filename"] } stageFilename, err := updateStagePath(UpdateStageDir, resp.Header.Get("etag"), filename) if err != nil { return err } // Check to see if we already have it downloaded _, err = os.Stat(stageFilename) if err == nil { slog.Info("update already downloaded", "bundle", stageFilename) UpdateDownloaded = true return nil } cleanupOldDownloads(UpdateStageDir) req.Method = http.MethodGet resp, err = http.DefaultClient.Do(req) if err != nil { return fmt.Errorf("error checking update: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("unexpected status attempting to download update %d", resp.StatusCode) } stageFilename, err = updateStagePath(UpdateStageDir, resp.Header.Get("etag"), filename) if err != nil { return err } _, err = os.Stat(filepath.Dir(stageFilename)) if errors.Is(err, os.ErrNotExist) { if err := os.MkdirAll(filepath.Dir(stageFilename), 0o755); err != nil { return fmt.Errorf("create ollama dir %s: %v", filepath.Dir(stageFilename), err) } } payload, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("failed to read body response: %w", err) } fp, err := os.OpenFile(stageFilename, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755) if err != nil { return fmt.Errorf("write payload %s: %w", stageFilename, err) } if n, err := fp.Write(payload); err != nil || n != len(payload) { _ = fp.Close() return fmt.Errorf("write payload %s: %d vs %d -- %w", stageFilename, n, len(payload), err) } if err := fp.Close(); err != nil { return fmt.Errorf("close payload %s: %w", stageFilename, err) } slog.Info("new update downloaded " + stageFilename) if err := VerifyDownload(); err != nil { _ = os.Remove(stageFilename) return fmt.Errorf("%s - %s", resp.Request.URL.String(), err) } UpdateDownloaded = true return nil } func updateStagePath(stageDir, etag, filename string) (string, error) { filename, err := safeUpdateFilename(filename) if err != nil { return "", err } stageDir, err = filepath.Abs(stageDir) if err != nil { return "", fmt.Errorf("resolve update stage dir: %w", err) } stageFilename := filepath.Join(stageDir, updateStageETagDir(etag), filename) if err := ensurePathInDir(stageDir, stageFilename); err != nil { return "", err } return stageFilename, nil } func safeUpdateFilename(filename string) (string, error) { filename = strings.TrimSpace(filename) if filename == "" { return "", errors.New("missing update filename") } if filename == "." || filename == ".." || filepath.IsAbs(filename) || path.IsAbs(filename) || strings.ContainsAny(filename, `/\:`) || filepath.Base(filename) != filename || path.Base(filename) != filename { return "", fmt.Errorf("unsafe update filename %q", filename) } return filename, nil } func updateStageETagDir(etag string) string { etag = strings.Trim(strings.TrimSpace(etag), "\"") if etag == "" { slog.Debug("no etag detected, falling back to filename based dedup") return "_" } sum := sha256.Sum256([]byte(etag)) return hex.EncodeToString(sum[:]) } func ensurePathInDir(dir, name string) error { rel, err := filepath.Rel(dir, name) if err != nil { return fmt.Errorf("resolve update staging path: %w", err) } if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) { return fmt.Errorf("update staging path escapes stage dir: %s", name) } return nil } func cleanupOldDownloads(stageDir string) { files, err := os.ReadDir(stageDir) if err != nil && errors.Is(err, os.ErrNotExist) { // Expected behavior on first run return } else if err != nil { slog.Warn(fmt.Sprintf("failed to list stage dir: %s", err)) return } for _, file := range files { fullname := filepath.Join(stageDir, file.Name()) slog.Debug("cleaning up old download: " + fullname) err = os.RemoveAll(fullname) if err != nil { slog.Warn(fmt.Sprintf("failed to cleanup stale update download %s", err)) } } } type Updater struct { 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 { select { case u.checkNow <- struct{}{}: default: // Check already pending, no need to queue another } } } func (u *Updater) StartBackgroundUpdaterChecker(ctx context.Context, cb func(string) error) { u.checkNow = make(chan struct{}, 1) u.checkNow <- struct{}{} // Trigger first check after initial delay go func() { // Don't blast an update message immediately after startup time.Sleep(UpdateCheckInitialDelay) slog.Info("beginning update checker", "interval", UpdateCheckInterval) ticker := time.NewTicker(UpdateCheckInterval) defer ticker.Stop() for { select { case <-ctx.Done(): slog.Debug("stopping background update checker") return case <-u.checkNow: // 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 err = cb(resp.UpdateVersion) if err != nil { slog.Warn("failed to register update available with tray", "error", err) } } }() }