diff --git a/tray.go b/tray.go index 0490da0..af63526 100644 --- a/tray.go +++ b/tray.go @@ -191,6 +191,8 @@ func onReady() { mFolder := mConfigure.AddSubMenuItem("Open Folder", "Open folder in file manager") mAbout := mConfigure.AddSubMenuItem("Restray v"+version, "Open repository in browser") systray.AddSeparator() + mAppUpdate := systray.AddMenuItem("", "Download and install the latest version") + mAppUpdate.Hide() mQuit := systray.AddMenuItem("Quit", "Quit Restray") sched := cron.New() @@ -663,6 +665,12 @@ func onReady() { openFile(configDir()) case <-mAbout.ClickedCh: openFile("https://tangled.org/devins.page/restray") + case <-mAppUpdate.ClickedCh: + if v := latestKnownVersion.Load(); v != nil && applyUpdate(*v) { + sched.Stop() + stopAllMounts() + systray.Quit() + } case <-mQuit.ClickedCh: sched.Stop() stopAllMounts() @@ -671,6 +679,29 @@ func onReady() { } }() + if runtime.GOOS == "windows" || runtime.GOOS == "darwin" { + go func() { + check := func() { + latest, err := latestVersion() + if err != nil { + log.Printf("update check failed: %v", err) + return + } + if compareVersion(latest, version) > 0 && artifactExists(artifactURL(latest)) { + latestKnownVersion.Store(&latest) + mAppUpdate.SetTitle("Update to v" + latest) + mAppUpdate.Show() + } + } + check() + t := time.NewTicker(6 * time.Hour) + defer t.Stop() + for range t.C { + check() + } + }() + } + for i := 0; i < maxProfiles; i++ { idx := i go func() { diff --git a/update.go b/update.go new file mode 100644 index 0000000..850065b --- /dev/null +++ b/update.go @@ -0,0 +1,169 @@ +package main + +import ( + "encoding/xml" + "fmt" + "io" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "sync/atomic" + "time" +) + +const repoBase = "https://tangled.org/devins.page/restray" + +var latestKnownVersion atomic.Pointer[string] + +var httpClient = &http.Client{Timeout: 10 * time.Second} + +func isHTML(resp *http.Response) bool { + return strings.Contains(resp.Header.Get("Content-Type"), "text/html") +} + +func latestVersion() (string, error) { + resp, err := httpClient.Get(repoBase + "/feed.atom?include=tags") + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return "", fmt.Errorf("feed fetch failed: %s", resp.Status) + } + + var feed struct { + Entries []struct { + Title string `xml:"title"` + } `xml:"entry"` + } + if err := xml.NewDecoder(resp.Body).Decode(&feed); err != nil { + return "", err + } + + best := "" + for _, e := range feed.Entries { + t := strings.TrimSpace(strings.TrimPrefix(e.Title, "[Tag]")) + v := strings.TrimPrefix(t, "v") + if !looksLikeVersion(v) { + continue + } + if best == "" || compareVersion(v, best) > 0 { + best = v + } + } + if best == "" { + return "", fmt.Errorf("no tags in feed") + } + return best, nil +} + +func looksLikeVersion(v string) bool { + parts := strings.Split(v, ".") + if len(parts) != 3 { + return false + } + for _, p := range parts { + if _, err := strconv.Atoi(p); err != nil { + return false + } + } + return true +} + +func compareVersion(a, b string) int { + pa := strings.Split(a, ".") + pb := strings.Split(b, ".") + for i := 0; i < 3; i++ { + na, _ := strconv.Atoi(pa[i]) + nb, _ := strconv.Atoi(pb[i]) + if na != nb { + return na - nb + } + } + return 0 +} + +func artifactURL(ver string) string { + var name string + switch runtime.GOOS { + case "windows": + name = fmt.Sprintf("Restray-%s-windows-%s-setup.exe", ver, runtime.GOARCH) + case "darwin": + name = fmt.Sprintf("Restray-%s-darwin-%s.zip", ver, runtime.GOARCH) + default: + return "" + } + return fmt.Sprintf("%s/tags/v%s/download/%s", repoBase, ver, name) +} + +func artifactExists(url string) bool { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return false + } + req.Header.Set("Range", "bytes=0-0") + resp, err := httpClient.Do(req) + if err != nil { + return false + } + resp.Body.Close() + if resp.StatusCode != 200 && resp.StatusCode != 206 { + return false + } + return !isHTML(resp) +} + +func applyUpdate(ver string) (quit bool) { + url := artifactURL(ver) + if url == "" { + return false + } + + switch runtime.GOOS { + case "darwin": + openFile(url) + case "windows": + path, err := downloadToTemp(url) + if err != nil { + log.Printf("update: download failed: %v", err) + return false + } + if err := exec.Command(path).Start(); err != nil { + log.Printf("update: launch installer failed: %v", err) + return false + } + return true + } + return false +} + +func downloadToTemp(url string) (string, error) { + resp, err := httpClient.Get(url) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return "", fmt.Errorf("download failed: %s", resp.Status) + } + if isHTML(resp) { + return "", fmt.Errorf("download failed: artifact not found") + } + path := filepath.Join(os.TempDir(), filepath.Base(url)) + f, err := os.Create(path) + if err != nil { + return "", err + } + if _, err := io.Copy(f, io.LimitReader(resp.Body, 200<<20)); err != nil { + f.Close() + os.Remove(path) + return "", err + } + f.Close() + return path, nil +}