powerful but friendly backup program that runs in your tray, powered by restic devins.page/restray
go restic system-tray
2

Configure Feed

Select the types of activity you want to include in your feed.

feat: updater

only on windows/macos, linux users should just use package manager

maybe ill add linux support and a setting later

does not automatically run update, just a button that you can manually click to run new installer / download macos .app

intergrav (Jun 29, 2026, 1:18 AM EDT) ff072c82 8a5552df

+200
+31
tray.go
··· 191 191 mFolder := mConfigure.AddSubMenuItem("Open Folder", "Open folder in file manager") 192 192 mAbout := mConfigure.AddSubMenuItem("Restray v"+version, "Open repository in browser") 193 193 systray.AddSeparator() 194 + mAppUpdate := systray.AddMenuItem("", "Download and install the latest version") 195 + mAppUpdate.Hide() 194 196 mQuit := systray.AddMenuItem("Quit", "Quit Restray") 195 197 196 198 sched := cron.New() ··· 663 665 openFile(configDir()) 664 666 case <-mAbout.ClickedCh: 665 667 openFile("https://tangled.org/devins.page/restray") 668 + case <-mAppUpdate.ClickedCh: 669 + if v := latestKnownVersion.Load(); v != nil && applyUpdate(*v) { 670 + sched.Stop() 671 + stopAllMounts() 672 + systray.Quit() 673 + } 666 674 case <-mQuit.ClickedCh: 667 675 sched.Stop() 668 676 stopAllMounts() ··· 670 678 } 671 679 } 672 680 }() 681 + 682 + if runtime.GOOS == "windows" || runtime.GOOS == "darwin" { 683 + go func() { 684 + check := func() { 685 + latest, err := latestVersion() 686 + if err != nil { 687 + log.Printf("update check failed: %v", err) 688 + return 689 + } 690 + if compareVersion(latest, version) > 0 && artifactExists(artifactURL(latest)) { 691 + latestKnownVersion.Store(&latest) 692 + mAppUpdate.SetTitle("Update to v" + latest) 693 + mAppUpdate.Show() 694 + } 695 + } 696 + check() 697 + t := time.NewTicker(6 * time.Hour) 698 + defer t.Stop() 699 + for range t.C { 700 + check() 701 + } 702 + }() 703 + } 673 704 674 705 for i := 0; i < maxProfiles; i++ { 675 706 idx := i
+169
update.go
··· 1 + package main 2 + 3 + import ( 4 + "encoding/xml" 5 + "fmt" 6 + "io" 7 + "log" 8 + "net/http" 9 + "os" 10 + "os/exec" 11 + "path/filepath" 12 + "runtime" 13 + "strconv" 14 + "strings" 15 + "sync/atomic" 16 + "time" 17 + ) 18 + 19 + const repoBase = "https://tangled.org/devins.page/restray" 20 + 21 + var latestKnownVersion atomic.Pointer[string] 22 + 23 + var httpClient = &http.Client{Timeout: 10 * time.Second} 24 + 25 + func isHTML(resp *http.Response) bool { 26 + return strings.Contains(resp.Header.Get("Content-Type"), "text/html") 27 + } 28 + 29 + func latestVersion() (string, error) { 30 + resp, err := httpClient.Get(repoBase + "/feed.atom?include=tags") 31 + if err != nil { 32 + return "", err 33 + } 34 + defer resp.Body.Close() 35 + if resp.StatusCode != 200 { 36 + return "", fmt.Errorf("feed fetch failed: %s", resp.Status) 37 + } 38 + 39 + var feed struct { 40 + Entries []struct { 41 + Title string `xml:"title"` 42 + } `xml:"entry"` 43 + } 44 + if err := xml.NewDecoder(resp.Body).Decode(&feed); err != nil { 45 + return "", err 46 + } 47 + 48 + best := "" 49 + for _, e := range feed.Entries { 50 + t := strings.TrimSpace(strings.TrimPrefix(e.Title, "[Tag]")) 51 + v := strings.TrimPrefix(t, "v") 52 + if !looksLikeVersion(v) { 53 + continue 54 + } 55 + if best == "" || compareVersion(v, best) > 0 { 56 + best = v 57 + } 58 + } 59 + if best == "" { 60 + return "", fmt.Errorf("no tags in feed") 61 + } 62 + return best, nil 63 + } 64 + 65 + func looksLikeVersion(v string) bool { 66 + parts := strings.Split(v, ".") 67 + if len(parts) != 3 { 68 + return false 69 + } 70 + for _, p := range parts { 71 + if _, err := strconv.Atoi(p); err != nil { 72 + return false 73 + } 74 + } 75 + return true 76 + } 77 + 78 + func compareVersion(a, b string) int { 79 + pa := strings.Split(a, ".") 80 + pb := strings.Split(b, ".") 81 + for i := 0; i < 3; i++ { 82 + na, _ := strconv.Atoi(pa[i]) 83 + nb, _ := strconv.Atoi(pb[i]) 84 + if na != nb { 85 + return na - nb 86 + } 87 + } 88 + return 0 89 + } 90 + 91 + func artifactURL(ver string) string { 92 + var name string 93 + switch runtime.GOOS { 94 + case "windows": 95 + name = fmt.Sprintf("Restray-%s-windows-%s-setup.exe", ver, runtime.GOARCH) 96 + case "darwin": 97 + name = fmt.Sprintf("Restray-%s-darwin-%s.zip", ver, runtime.GOARCH) 98 + default: 99 + return "" 100 + } 101 + return fmt.Sprintf("%s/tags/v%s/download/%s", repoBase, ver, name) 102 + } 103 + 104 + func artifactExists(url string) bool { 105 + req, err := http.NewRequest(http.MethodGet, url, nil) 106 + if err != nil { 107 + return false 108 + } 109 + req.Header.Set("Range", "bytes=0-0") 110 + resp, err := httpClient.Do(req) 111 + if err != nil { 112 + return false 113 + } 114 + resp.Body.Close() 115 + if resp.StatusCode != 200 && resp.StatusCode != 206 { 116 + return false 117 + } 118 + return !isHTML(resp) 119 + } 120 + 121 + func applyUpdate(ver string) (quit bool) { 122 + url := artifactURL(ver) 123 + if url == "" { 124 + return false 125 + } 126 + 127 + switch runtime.GOOS { 128 + case "darwin": 129 + openFile(url) 130 + case "windows": 131 + path, err := downloadToTemp(url) 132 + if err != nil { 133 + log.Printf("update: download failed: %v", err) 134 + return false 135 + } 136 + if err := exec.Command(path).Start(); err != nil { 137 + log.Printf("update: launch installer failed: %v", err) 138 + return false 139 + } 140 + return true 141 + } 142 + return false 143 + } 144 + 145 + func downloadToTemp(url string) (string, error) { 146 + resp, err := httpClient.Get(url) 147 + if err != nil { 148 + return "", err 149 + } 150 + defer resp.Body.Close() 151 + if resp.StatusCode != 200 { 152 + return "", fmt.Errorf("download failed: %s", resp.Status) 153 + } 154 + if isHTML(resp) { 155 + return "", fmt.Errorf("download failed: artifact not found") 156 + } 157 + path := filepath.Join(os.TempDir(), filepath.Base(url)) 158 + f, err := os.Create(path) 159 + if err != nil { 160 + return "", err 161 + } 162 + if _, err := io.Copy(f, io.LimitReader(resp.Body, 200<<20)); err != nil { 163 + f.Close() 164 + os.Remove(path) 165 + return "", err 166 + } 167 + f.Close() 168 + return path, nil 169 + }