Auto-update: download + install + restart
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

- CheckForUpdates binding returns version string if newer available
- InstallUpdate binding downloads zip, extracts, replaces app, restarts
- checkUpdates goroutine polls every 6h, shows dialog with version
- make install copies .app to /Applications
- Update script: quit app → sleep → replace .app → relaunch
This commit is contained in:
2026-07-14 00:22:15 +02:00
parent bcf2b265c5
commit 23535eac25
2 changed files with 164 additions and 36 deletions

View File

@@ -1,4 +1,4 @@
.PHONY: build webhook test test-db lint generate generate-check dev migrate seed export clean tidy ui desktop desktop-package desktop-release
.PHONY: build webhook test test-db lint generate generate-check dev migrate seed export clean tidy ui desktop desktop-package install
BINARY := oikos
GO ?= go
@@ -74,9 +74,10 @@ desktop-package: desktop ## Build + package the desktop app (zip on macOS, tar
esac
@echo "Package: cmd/desktop/build/bin/"
desktop-release: ui ## Build desktop app for macOS arm64 + Linux amd64 (CI target)
@echo "Use 'make desktop-package' for local builds; desktop-release is for CI"
@exit 1
install: desktop-package ## Install to /Applications
rm -rf /Applications/oikos-desktop.app
cp -r cmd/desktop/build/bin/oikos-desktop.app /Applications/
@echo "Installed to /Applications/oikos-desktop.app"
clean:
rm -f $(BINARY)

View File

@@ -478,41 +478,168 @@ type giteaRelease struct {
} `json:"assets"`
}
func checkUpdates() {
for {
type updateState struct {
mu sync.Mutex
latestURL string
}
var updater = &updateState{}
// CheckForUpdates checks Gitea releases for a newer version. If found, stores
// the download URL and returns the latest version string (empty if current).
func (c *ConfigService) CheckForUpdates() string {
latest := fetchLatestRelease()
if latest == nil || latest.Version == version {
return ""
}
for _, a := range latest.Assets {
if strings.Contains(a.Name, "darwin") {
updater.mu.Lock()
updater.latestURL = a.BrowserDownloadURL
updater.mu.Unlock()
return latest.Version
}
}
return ""
}
// InstallUpdate downloads the stored update, replaces the app, and restarts.
func (c *ConfigService) InstallUpdate() error {
updater.mu.Lock()
url := updater.latestURL
updater.mu.Unlock()
if url == "" {
return fmt.Errorf("no update available")
}
return doUpdate(url)
}
type latestRelease struct {
Version string
Assets []struct {
Name string
BrowserDownloadURL string
}
}
func fetchLatestRelease() *latestRelease {
resp, err := http.Get(updateURL + "?draft=false&pre-release=false&limit=1")
if err != nil {
time.Sleep(updateInterval)
continue
return nil
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
defer resp.Body.Close()
var releases []giteaRelease
if err := json.Unmarshal(body, &releases); err != nil || len(releases) == 0 {
time.Sleep(updateInterval)
continue
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil || len(releases) == 0 {
return nil
}
latest := releases[0]
latestVersion := strings.TrimPrefix(latest.TagName, "v")
if latestVersion == version {
time.Sleep(updateInterval)
continue
r := releases[0]
v := strings.TrimPrefix(r.TagName, "v")
if v == version {
return nil
}
lr := &latestRelease{Version: v}
for _, a := range r.Assets {
lr.Assets = append(lr.Assets, struct {
Name string
BrowserDownloadURL string
}{a.Name, a.BrowserDownloadURL})
}
return lr
}
func doUpdate(downloadURL string) error {
tmp, err := os.CreateTemp("", "oikos-update-*.zip")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
resp, err := http.Get(downloadURL)
if err != nil {
return err
}
defer resp.Body.Close()
if _, err := io.Copy(tmp, resp.Body); err != nil {
return err
}
tmp.Close()
extractDir, err := os.MkdirTemp("", "oikos-extract")
if err != nil {
return err
}
defer os.RemoveAll(extractDir)
cmd := exec.Command("unzip", "-o", tmp.Name(), "-d", extractDir)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("unzip: %w: %s", err, out)
}
newApp := filepath.Join(extractDir, "oikos-desktop.app")
if _, err := os.Stat(newApp); err != nil {
return fmt.Errorf("extracted app not found: %w", err)
}
currentApp := "/Applications/oikos-desktop.app"
if _, err := os.Stat(currentApp); os.IsNotExist(err) {
if exe, err := os.Executable(); err == nil {
currentApp = filepath.Dir(filepath.Dir(filepath.Dir(exe)))
}
}
script := fmt.Sprintf(`#!/bin/bash
sleep 2
rm -rf "%s"
mv "%s" "%s"
open "%s"
rm "$0"
`, currentApp, newApp, currentApp, currentApp)
scriptPath := filepath.Join(os.TempDir(), "oikos-update.sh")
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
return err
}
app := application.Get()
if app == nil {
exec.Command("open", scriptPath).Start()
if app != nil {
app.Quit()
}
return nil
}
func checkUpdates() {
for {
time.Sleep(updateInterval)
latest := fetchLatestRelease()
if latest == nil {
continue
}
msg := fmt.Sprintf("Version %s is available (you have %s). Download from Gitea releases.", latestVersion, version)
for _, a := range latest.Assets {
if strings.Contains(a.Name, "darwin") {
updater.mu.Lock()
updater.latestURL = a.BrowserDownloadURL
updater.mu.Unlock()
app := application.Get()
if app == nil {
continue
}
msg := fmt.Sprintf("Version %s is available (you have %s).", latest.Version, version)
app.Dialog.Info().
SetTitle("Update Available").
SetMessage(msg).
Show()
time.Sleep(updateInterval)
break
}
}
}
}