Auto-update: download + install + restart
- 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:
9
Makefile
9
Makefile
@@ -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
|
BINARY := oikos
|
||||||
GO ?= go
|
GO ?= go
|
||||||
@@ -74,9 +74,10 @@ desktop-package: desktop ## Build + package the desktop app (zip on macOS, tar
|
|||||||
esac
|
esac
|
||||||
@echo "Package: cmd/desktop/build/bin/"
|
@echo "Package: cmd/desktop/build/bin/"
|
||||||
|
|
||||||
desktop-release: ui ## Build desktop app for macOS arm64 + Linux amd64 (CI target)
|
install: desktop-package ## Install to /Applications
|
||||||
@echo "Use 'make desktop-package' for local builds; desktop-release is for CI"
|
rm -rf /Applications/oikos-desktop.app
|
||||||
@exit 1
|
cp -r cmd/desktop/build/bin/oikos-desktop.app /Applications/
|
||||||
|
@echo "Installed to /Applications/oikos-desktop.app"
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -f $(BINARY)
|
rm -f $(BINARY)
|
||||||
|
|||||||
@@ -478,41 +478,168 @@ type giteaRelease struct {
|
|||||||
} `json:"assets"`
|
} `json:"assets"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkUpdates() {
|
type updateState struct {
|
||||||
for {
|
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")
|
resp, err := http.Get(updateURL + "?draft=false&pre-release=false&limit=1")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
time.Sleep(updateInterval)
|
return nil
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
body, _ := io.ReadAll(resp.Body)
|
defer resp.Body.Close()
|
||||||
resp.Body.Close()
|
|
||||||
|
|
||||||
var releases []giteaRelease
|
var releases []giteaRelease
|
||||||
if err := json.Unmarshal(body, &releases); err != nil || len(releases) == 0 {
|
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil || len(releases) == 0 {
|
||||||
time.Sleep(updateInterval)
|
return nil
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
latest := releases[0]
|
r := releases[0]
|
||||||
latestVersion := strings.TrimPrefix(latest.TagName, "v")
|
v := strings.TrimPrefix(r.TagName, "v")
|
||||||
if latestVersion == version {
|
if v == version {
|
||||||
time.Sleep(updateInterval)
|
return nil
|
||||||
continue
|
}
|
||||||
|
|
||||||
|
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()
|
app := application.Get()
|
||||||
if app == nil {
|
exec.Command("open", scriptPath).Start()
|
||||||
|
if app != nil {
|
||||||
|
app.Quit()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkUpdates() {
|
||||||
|
for {
|
||||||
time.Sleep(updateInterval)
|
time.Sleep(updateInterval)
|
||||||
|
|
||||||
|
latest := fetchLatestRelease()
|
||||||
|
if latest == nil {
|
||||||
continue
|
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().
|
app.Dialog.Info().
|
||||||
SetTitle("Update Available").
|
SetTitle("Update Available").
|
||||||
SetMessage(msg).
|
SetMessage(msg).
|
||||||
Show()
|
Show()
|
||||||
time.Sleep(updateInterval)
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user