Files
ollama/app/updater/updater_windows.go
Daniel Hiltgen d3b4b9970a app: add code for macOS and Windows apps under 'app' (#12933)
* app: add code for macOS and Windows apps under 'app'

* app: add readme

* app: windows and linux only for now

* ci: fix ui CI validation

---------

Co-authored-by: jmorganca <jmorganca@gmail.com>
2025-11-04 11:40:17 -08:00

239 lines
7.1 KiB
Go

package updater
import (
"errors"
"fmt"
"log/slog"
"os"
"os/exec"
"path"
"path/filepath"
"strings"
"syscall"
"time"
"unsafe"
"golang.org/x/sys/windows"
)
var runningInstaller string
type OSVERSIONINFOEXW struct {
dwOSVersionInfoSize uint32
dwMajorVersion uint32
dwMinorVersion uint32
dwBuildNumber uint32
dwPlatformId uint32
szCSDVersion [128]uint16
wServicePackMajor uint16
wServicePackMinor uint16
wSuiteMask uint16
wProductType uint8
wReserved uint8
}
func init() {
VerifyDownload = verifyDownload
Installer = "Ollama-darwin.zip"
localAppData := os.Getenv("LOCALAPPDATA")
appDataDir := filepath.Join(localAppData, "Ollama")
// Use a distinct update staging directory from the old desktop app
// to avoid double upgrades on the transition
UpdateStageDir = filepath.Join(appDataDir, "updates_v2")
UpgradeLogFile = filepath.Join(appDataDir, "upgrade.log")
Installer = "OllamaSetup.exe"
runningInstaller = filepath.Join(appDataDir, Installer)
UpgradeMarkerFile = filepath.Join(appDataDir, "upgraded")
loadOSVersion()
}
func loadOSVersion() {
UserAgentOS = "Windows"
verInfo := OSVERSIONINFOEXW{}
verInfo.dwOSVersionInfoSize = (uint32)(unsafe.Sizeof(verInfo))
ntdll, err := windows.LoadDLL("ntdll.dll")
if err != nil {
slog.Warn("unable to find ntdll", "error", err)
return
}
defer ntdll.Release()
pRtlGetVersion, err := ntdll.FindProc("RtlGetVersion")
if err != nil {
slog.Warn("unable to locate RtlGetVersion", "error", err)
return
}
status, _, err := pRtlGetVersion.Call(uintptr(unsafe.Pointer(&verInfo)))
if status < 0x80000000 { // Success or Informational
// Note: Windows 11 reports 10.0.22000 or newer
UserAgentOS = fmt.Sprintf("Windows/%d.%d.%d", verInfo.dwMajorVersion, verInfo.dwMinorVersion, verInfo.dwBuildNumber)
} else {
slog.Warn("unable to get OS version", "error", err)
}
}
func getStagedUpdate() string {
// When transitioning from old to new app, cleanup the update from the old staging dir
// This can eventually be removed once enough time has passed since the transition
cleanupOldDownloads(filepath.Join(os.Getenv("LOCALAPPDATA"), "Ollama", "updates"))
files, err := filepath.Glob(filepath.Join(UpdateStageDir, "*", "*.exe"))
if err != nil {
slog.Debug("failed to lookup downloads", "error", err)
return ""
}
if len(files) == 0 {
return ""
} else if len(files) > 1 {
// Shouldn't happen
slog.Warn("multiple update downloads found, using first one", "bundles", files)
}
return files[0]
}
func DoUpgrade(interactive bool) error {
bundle := getStagedUpdate()
if bundle == "" {
return fmt.Errorf("failed to lookup downloads")
}
// We move the installer to ensure we don't race with multiple apps starting in quick succession
if err := os.Rename(bundle, runningInstaller); err != nil {
return fmt.Errorf("unable to rename %s -> %s : %w", bundle, runningInstaller, err)
}
slog.Info("upgrade log file " + UpgradeLogFile)
// make the upgrade show progress, but non interactive
installArgs := []string{
"/CLOSEAPPLICATIONS", // Quit the tray app if it's still running
"/LOG=" + filepath.Base(UpgradeLogFile), // Only relative seems reliable, so set pwd
"/FORCECLOSEAPPLICATIONS", // Force close the tray app - might be needed
"/SP", // Skip the "This will install... Do you wish to continue" prompt
"/NOCANCEL", // Disable the ability to cancel upgrade mid-flight to avoid partially installed upgrades
"/SILENT",
}
if !interactive {
// Add flags to make it totally silent without GUI
installArgs = append(installArgs, "/VERYSILENT", "/SUPPRESSMSGBOXES")
}
slog.Info("starting upgrade", "installer", runningInstaller, "args", installArgs)
os.Chdir(filepath.Dir(UpgradeLogFile)) //nolint:errcheck
cmd := exec.Command(runningInstaller, installArgs...)
if err := cmd.Start(); err != nil {
return fmt.Errorf("unable to start ollama app %w", err)
}
if cmd.Process != nil {
err := cmd.Process.Release()
if err != nil {
slog.Error(fmt.Sprintf("failed to release server process: %s", err))
}
} else {
// TODO - some details about why it didn't start, or is this a pedantic error case?
return errors.New("installer process did not start")
}
// If the install fails to upgrade the system, and leaves a functional
// app, this marker file will cause us to remove the staged upgrade
// bundle, which will prevent trying again until we download again.
// If this becomes looping a problem, we may need to look for failures
// in the upgrade log in DoPostUpgradeCleanup and then not download
// the same version again.
f, err := os.OpenFile(UpgradeMarkerFile, os.O_RDONLY|os.O_CREATE, 0o666)
if err != nil {
slog.Warn("unable to create marker file", "file", UpgradeMarkerFile, "error", err)
}
f.Close()
// TODO should we linger for a moment and check to make sure it's actually running by checking the pid?
slog.Info("Installer started in background, exiting")
os.Exit(0)
// Not reached
return nil
}
func DoPostUpgradeCleanup() error {
cleanupOldDownloads(UpdateStageDir)
err := os.Remove(UpgradeMarkerFile)
if err != nil {
slog.Warn("unable to clean up marker file", "marker", UpgradeMarkerFile, "error", err)
}
err = os.Remove(runningInstaller)
if err != nil {
slog.Debug("failed to remove running installer on first attempt, backgrounding...", "installer", runningInstaller, "error", err)
go func() {
for range 10 {
time.Sleep(5 * time.Second)
if err := os.Remove(runningInstaller); err == nil {
slog.Debug("installer cleaned up")
return
}
slog.Debug("failed to remove running installer on background attempt", "installer", runningInstaller, "error", err)
}
}()
}
return nil
}
func verifyDownload() error {
return nil
}
func IsUpdatePending() bool {
return getStagedUpdate() != ""
}
func DoUpgradeAtStartup() error {
return DoUpgrade(false)
}
func isInstallerRunning() bool {
return len(IsProcRunning(Installer)) > 0
}
func IsProcRunning(procName string) []uint32 {
pids := make([]uint32, 2048)
var ret uint32
if err := windows.EnumProcesses(pids, &ret); err != nil || ret == 0 {
slog.Debug("failed to check for running installers", "error", err)
return nil
}
pids = pids[:ret]
matches := []uint32{}
for _, pid := range pids {
if pid == 0 {
continue
}
hProcess, err := windows.OpenProcess(windows.PROCESS_QUERY_INFORMATION|windows.PROCESS_VM_READ, false, pid)
if err != nil {
continue
}
defer windows.CloseHandle(hProcess)
var module windows.Handle
var cbNeeded uint32
cb := (uint32)(unsafe.Sizeof(module))
if err := windows.EnumProcessModules(hProcess, &module, cb, &cbNeeded); err != nil {
continue
}
var sz uint32 = 1024 * 8
moduleName := make([]uint16, sz)
cb = uint32(len(moduleName)) * (uint32)(unsafe.Sizeof(uint16(0)))
if err := windows.GetModuleBaseName(hProcess, module, &moduleName[0], cb); err != nil && err != syscall.ERROR_INSUFFICIENT_BUFFER {
continue
}
exeFile := path.Base(strings.ToLower(syscall.UTF16ToString(moduleName)))
if strings.EqualFold(exeFile, procName) {
matches = append(matches, pid)
}
}
return matches
}