diff --git a/Makefile b/Makefile index 7e22a4d..c1bf4ca 100644 --- a/Makefile +++ b/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 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) diff --git a/cmd/desktop/main.go b/cmd/desktop/main.go index 7a00c14..497d86a 100644 --- a/cmd/desktop/main.go +++ b/cmd/desktop/main.go @@ -478,41 +478,168 @@ type giteaRelease struct { } `json:"assets"` } +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 { + return nil + } + defer resp.Body.Close() + + var releases []giteaRelease + if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil || len(releases) == 0 { + return nil + } + + 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() + exec.Command("open", scriptPath).Start() + if app != nil { + app.Quit() + } + + return nil +} + func checkUpdates() { for { - resp, err := http.Get(updateURL + "?draft=false&pre-release=false&limit=1") - if err != nil { - time.Sleep(updateInterval) - continue - } - body, _ := io.ReadAll(resp.Body) - resp.Body.Close() - - var releases []giteaRelease - if err := json.Unmarshal(body, &releases); err != nil || len(releases) == 0 { - time.Sleep(updateInterval) - continue - } - - latest := releases[0] - latestVersion := strings.TrimPrefix(latest.TagName, "v") - if latestVersion == version { - time.Sleep(updateInterval) - continue - } - - app := application.Get() - if app == nil { - time.Sleep(updateInterval) - continue - } - - msg := fmt.Sprintf("Version %s is available (you have %s). Download from Gitea releases.", latestVersion, version) - app.Dialog.Info(). - SetTitle("Update Available"). - SetMessage(msg). - Show() time.Sleep(updateInterval) + + latest := fetchLatestRelease() + if latest == nil { + continue + } + + 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() + break + } + } } }