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.

fix: remove restic management on macos/linux

on MacOS, self-managed copy gets killed by code-signing requirement. So just install it with package manager manager, or have it pulled in by package manager as a dependency (Nix package now does this, and if I were to ever make a homebrew tap or something I'll do it there too)

intergrav (Jun 29, 2026, 2:38 AM EDT) a7ef0964 ff072c82

+227 -225
+6 -10
README.md
··· 20 20 - Multiple backup profiles with different schedules and different settings 21 21 - Optional automatic pruning and checks after backups 22 22 - Optional automatic retry and removal of stale locks 23 - - Optional restic binary download and updates 24 - - First tries to find restic in user's PATH, but if not found, user can manually click "download"/"update" restic and it'll manage it for them 25 - - This can also be automated daily by enabling "manage_restic" in config 23 + - On Windows it can also download and update it's own self-managed restic if not found in PATH 26 24 - Open a shell with your profile's environment and restic binary loaded, for restoring or other CLI operations without friction 27 25 - Mount repository snapshots on macOS/Linux 28 26 - Requires [FUSE-T](https://www.fuse-t.org) or [macFUSE](https://macfuse.github.io) on macOS. Requires [FUSE](https://github.com/libfuse/libfuse) on Linux ··· 32 30 33 31 ## Installation 34 32 35 - Check this repository's [tags](https://tangled.org/devins.page/restray/tags) for build artifacts. I currently build+package for MacOS/Windows and build for Linux. 36 - 37 - On MacOS you will need to [disable Gatekeeper/remove quarantine](https://disable-gatekeeper.github.io) from the `.app` as I cannot pay Apple $100 a year to get my app signed. Sorry. 33 + Grab a build from this repository's [tags](https://tangled.org/devins.page/restray/tags) (MacOS/Windows packaged, Linux as a binary). On MacOS you'll need to [disable Gatekeeper/remove quarantine](https://disable-gatekeeper.github.io) from the `.app`, since I can't pay Apple $100/year to sign it. Sorry. 38 34 39 - ## Compiling 35 + Restray needs [restic](https://restic.net) installed to your PATH on MacOS/Linux (on MacOS, self-managed copy gets killed by code-signing requriment), so install it with your package manager (e.g. `brew install restic` or `apt install restic`). Package managers should also just pull it in as a dependency. On Windows, Restray can download and manage it's own restic binary itself if it isn't already in PATH. 40 36 41 - If you're on [Nix(OS)](https://nixos.org)/nix-darwin, just run `nix build`. Or add this repo to your flake and install the package :) 37 + ### Building from source 42 38 43 - If you aren't using Nix, install the dependencies from `flake.nix`'s devshell and use `just build` or `just package` to build/package for your current platform. You can also cross-compile to other platforms, for example `just build linux amd64` or `just package windows`. 39 + On [Nix(OS)](https://nixos.org)/nix-darwin, run `nix build` (or add this repo to your flake and simply install the package). Otherwise, install the dependencies from `flake.nix`'s devshell and run `just build` or `just package`. Both default to your current platform. You can also pass a target to cross-compile (e.g. `just build linux amd64`, `just package windows`). 44 40 45 41 ## Configuration 46 42 ··· 48 44 49 45 | Key | Type | Default | Description | 50 46 | ------------------ | ------ | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | 51 - | `manage_restic` | bool | `false` | If restic is not found in PATH, automatically downloads and updates a self-managed restic binary daily from GitHub | 47 + | `manage_restic` | bool | `false` | **Windows only.** If restic is not found in PATH, automatically downloads and updates a self-managed restic binary daily from GitHub. | 52 48 | `notifications` | string | `"none"` | `"all"` (success + errors), `"errors"` (errors only), or `"none"` (silent) | 53 49 | `schedule_display` | string | `"description"` | `"description"` (human-readable, e.g. "every 6 hours"), `"cron"` (raw expression, e.g. "0 \* \* \* \*"), or `"none"` | 54 50 | `terminal` | string | `""` | Terminal emulator used in "Shell" and "View Log". If unset, MacOS will use `Terminal.app`. Linux will use default. Not used on Windows. e.g. "Ghostty" |
+1
config.go
··· 193 193 } 194 194 return filepath.Join(configDir(), "restic") 195 195 } 196 + 196 197 func logPath() string { return filepath.Join(configDir(), "restray.log") } 197 198 func lockPath() string { return filepath.Join(configDir(), "restray.lock") } 198 199
-26
exec_other.go
··· 3 3 package main 4 4 5 5 import ( 6 - "io" 7 6 "os" 8 7 "os/exec" 9 - "runtime" 10 8 "syscall" 11 9 ) 12 10 ··· 20 18 func lockFile(f *os.File) error { 21 19 return syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) 22 20 } 23 - 24 - func clearQuarantine(path string) { 25 - if runtime.GOOS != "darwin" { 26 - return 27 - } 28 - tmp := path + ".tmp" 29 - in, err := os.Open(path) 30 - if err != nil { 31 - return 32 - } 33 - out, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY, 0755) 34 - if err != nil { 35 - in.Close() 36 - return 37 - } 38 - _, err = io.Copy(out, in) 39 - in.Close() 40 - out.Close() 41 - if err != nil { 42 - os.Remove(tmp) 43 - return 44 - } 45 - os.Rename(tmp, path) 46 - }
-2
exec_windows.go
··· 22 22 p.Kill() 23 23 } 24 24 25 - func clearQuarantine(_ string) {} 26 - 27 25 func lockFile(f *os.File) error { 28 26 var ol syscall.Overlapped 29 27 r, _, err := procLockFileEx.Call(
+2
flake.nix
··· 21 21 src = ./.; 22 22 vendorHash = "sha256-iYM2yvcTsYqn1pyB7jrqroYUfZM4ysWZnXKiLRVqkT0="; 23 23 24 + ldflags = ["-X" "main.resticBuiltinPath=${pkgs.restic}/bin/restic"]; 25 + 24 26 env = pkgs.lib.optionalAttrs isDarwin { 25 27 CGO_CFLAGS = "-mmacosx-version-min=11.0"; 26 28 CGO_LDFLAGS = "-mmacosx-version-min=11.0";
-7
main.go
··· 3 3 import ( 4 4 "log" 5 5 "os" 6 - "path/filepath" 7 6 8 7 "fyne.io/systray" 9 8 ) ··· 18 17 return 19 18 } 20 19 defer lock.Close() 21 - 22 - if temps, err := filepath.Glob(filepath.Join(configDir(), downloadTmpPattern)); err == nil { 23 - for _, t := range temps { 24 - os.Remove(t) 25 - } 26 - } 27 20 28 21 f, err := os.OpenFile(logPath(), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) 29 22 if err == nil {
+10 -176
restic.go
··· 1 1 package main 2 2 3 3 import ( 4 - "archive/zip" 5 4 "bufio" 6 - "compress/bzip2" 7 5 "context" 8 6 "encoding/json" 9 7 "fmt" 10 8 "io" 11 9 "log" 12 - "net/http" 13 10 "os" 14 11 "os/exec" 15 12 "path/filepath" ··· 21 18 "fyne.io/systray" 22 19 "github.com/gen2brain/beeep" 23 20 ) 21 + 22 + // optional restic path baked at build time for Nix and other pkg managers 23 + var resticBuiltinPath string 24 24 25 25 var ( 26 26 resticOnce sync.Once 27 27 resticPath string 28 28 resticManaged bool 29 - downloading sync.Mutex 30 29 ) 31 30 32 31 func findRestic() (path string, managed bool) { ··· 39 38 resticPath = p 40 39 return 41 40 } 42 - p := localResticPath() 43 - if _, err := os.Stat(p); err == nil { 41 + if resticBuiltinPath != "" { 42 + if _, err := os.Stat(resticBuiltinPath); err == nil { 43 + resticPath = resticBuiltinPath 44 + return 45 + } 46 + } 47 + if p := managedRestic(); p != "" { 44 48 resticPath = p 45 49 resticManaged = true 46 50 } 47 51 }) 48 52 return resticPath, resticManaged 49 - } 50 - 51 - func resetResticPath() { 52 - resticOnce = sync.Once{} 53 - resticPath = "" 54 - resticManaged = false 55 53 } 56 54 57 55 func findResticFromShell() string { ··· 89 87 return "" 90 88 } 91 89 92 - func resticVersion(path string) string { 93 - cmd := exec.Command(path, "version") 94 - hideWindow(cmd) 95 - out, err := cmd.Output() 96 - if err != nil { 97 - return "" 98 - } 99 - parts := strings.Fields(string(out)) 100 - if len(parts) >= 2 { 101 - return parts[1] 102 - } 103 - return "" 104 - } 105 - 106 90 func resticEnv(prof Profile) []string { 107 91 env := os.Environ() 108 92 vars, err := parseEnvFile(prof.EnvFile) ··· 146 130 return repoResult{errMsg: msg, needsInit: code == 10} 147 131 } 148 132 return repoResult{} 149 - } 150 - 151 - func latestResticVersion() (string, error) { 152 - client := &http.Client{ 153 - Timeout: 10 * time.Second, 154 - CheckRedirect: func(req *http.Request, via []*http.Request) error { 155 - return http.ErrUseLastResponse 156 - }, 157 - } 158 - resp, err := client.Get("https://github.com/restic/restic/releases/latest") 159 - if err != nil { 160 - return "", err 161 - } 162 - resp.Body.Close() 163 - loc := resp.Header.Get("Location") 164 - if loc == "" { 165 - return "", fmt.Errorf("no redirect from github") 166 - } 167 - parts := strings.Split(loc, "/") 168 - v := parts[len(parts)-1] 169 - if len(v) > 0 && v[0] == 'v' { 170 - v = v[1:] 171 - } 172 - return v, nil 173 - } 174 - 175 - func downloadRestic(mStatus prefixedMenuItem) error { 176 - if !downloading.TryLock() { 177 - return nil 178 - } 179 - defer downloading.Unlock() 180 - 181 - mStatus.SetTitle("Fetching latest version...") 182 - version, err := latestResticVersion() 183 - if err != nil { 184 - log.Printf("failed to fetch latest version: %v", err) 185 - return err 186 - } 187 - if version == "" { 188 - log.Print("failed to determine latest version") 189 - return fmt.Errorf("failed to determine latest version") 190 - } 191 - 192 - ext := "bz2" 193 - if runtime.GOOS == "windows" { 194 - ext = "zip" 195 - } 196 - url := fmt.Sprintf("https://github.com/restic/restic/releases/download/v%s/restic_%s_%s_%s.%s", 197 - version, version, runtime.GOOS, runtime.GOARCH, ext) 198 - log.Printf("downloading restic %s", version) 199 - mStatus.SetTitle("Downloading restic " + version + "...") 200 - 201 - resp, err := http.Get(url) 202 - if err != nil { 203 - log.Printf("download failed: %v", err) 204 - return err 205 - } 206 - defer resp.Body.Close() 207 - if resp.StatusCode != 200 { 208 - log.Printf("download failed: %s", resp.Status) 209 - return fmt.Errorf("download failed: %s", resp.Status) 210 - } 211 - 212 - if runtime.GOOS == "windows" { 213 - err = extractResticZip(resp.Body) 214 - } else { 215 - err = installBinary(bzip2.NewReader(resp.Body)) 216 - } 217 - if err == nil { 218 - log.Printf("installed restic %s", version) 219 - resetResticPath() 220 - } 221 - return err 222 - } 223 - 224 - const downloadTmpPattern = "restic-download-*" 225 - 226 - func installBinary(r io.Reader) error { 227 - p := localResticPath() 228 - tmp, err := os.CreateTemp(filepath.Dir(p), downloadTmpPattern) 229 - if err != nil { 230 - return err 231 - } 232 - tmpName := tmp.Name() 233 - if _, err := io.Copy(tmp, r); err != nil { 234 - tmp.Close() 235 - os.Remove(tmpName) 236 - return err 237 - } 238 - tmp.Close() 239 - if err := os.Chmod(tmpName, 0755); err != nil { 240 - os.Remove(tmpName) 241 - return err 242 - } 243 - os.Remove(p) 244 - if err := os.Rename(tmpName, p); err != nil { 245 - os.Remove(tmpName) 246 - return err 247 - } 248 - clearQuarantine(p) 249 - return nil 250 - } 251 - 252 - func extractResticZip(r io.Reader) error { 253 - tmp, err := os.CreateTemp("", "restray-download-*.zip") 254 - if err != nil { 255 - return err 256 - } 257 - defer os.Remove(tmp.Name()) 258 - defer tmp.Close() 259 - 260 - if _, err := io.Copy(tmp, r); err != nil { 261 - return err 262 - } 263 - 264 - zr, err := zip.OpenReader(tmp.Name()) 265 - if err != nil { 266 - return err 267 - } 268 - defer zr.Close() 269 - 270 - for _, zf := range zr.File { 271 - if strings.HasSuffix(zf.Name, ".exe") { 272 - rc, err := zf.Open() 273 - if err != nil { 274 - return err 275 - } 276 - defer rc.Close() 277 - return installBinary(rc) 278 - } 279 - } 280 - return fmt.Errorf("restic binary not found in zip") 281 - } 282 - 283 - func checkResticUpdate(resticPath string, autoUpdate bool, mUpdate *systray.MenuItem, mStatus prefixedMenuItem, onUpdated func()) { 284 - current := resticVersion(resticPath) 285 - latest, err := latestResticVersion() 286 - if err != nil || current == "" || latest == "" || current == latest { 287 - return 288 - } 289 - log.Printf("restic update available: %s → %s", current, latest) 290 - if autoUpdate { 291 - err = downloadRestic(mStatus) 292 - if err == nil { 293 - onUpdated() 294 - } 295 - return 296 - } 297 - mUpdate.SetTitle("Update Restic (" + current + " → " + latest + ")") 298 - mUpdate.Show() 299 133 } 300 134 301 135 type appState struct {
+12
restic_manage_other.go
··· 1 + //go:build !windows 2 + 3 + package main 4 + 5 + import "fyne.io/systray" 6 + 7 + // macOS/Linux require system restic, the app never downloads or manages one 8 + const selfManagesRestic = false 9 + 10 + func downloadRestic(prefixedMenuItem) error { return nil } 11 + func checkResticUpdate(string, bool, *systray.MenuItem, prefixedMenuItem, func()) {} 12 + func managedRestic() string { return "" }
+190
restic_manage_windows.go
··· 1 + //go:build windows 2 + 3 + package main 4 + 5 + import ( 6 + "archive/zip" 7 + "fmt" 8 + "io" 9 + "log" 10 + "net/http" 11 + "os" 12 + "os/exec" 13 + "path/filepath" 14 + "runtime" 15 + "strings" 16 + "sync" 17 + "time" 18 + 19 + "fyne.io/systray" 20 + ) 21 + 22 + const selfManagesRestic = true 23 + 24 + var downloading sync.Mutex 25 + 26 + func managedRestic() string { 27 + p := localResticPath() 28 + if _, err := os.Stat(p); err == nil { 29 + return p 30 + } 31 + return "" 32 + } 33 + 34 + func resetResticPath() { 35 + resticOnce = sync.Once{} 36 + resticPath = "" 37 + resticManaged = false 38 + } 39 + 40 + func resticVersion(path string) string { 41 + cmd := exec.Command(path, "version") 42 + hideWindow(cmd) 43 + out, err := cmd.Output() 44 + if err != nil { 45 + return "" 46 + } 47 + parts := strings.Fields(string(out)) 48 + if len(parts) >= 2 { 49 + return parts[1] 50 + } 51 + return "" 52 + } 53 + 54 + func latestResticVersion() (string, error) { 55 + client := &http.Client{ 56 + Timeout: 10 * time.Second, 57 + CheckRedirect: func(req *http.Request, via []*http.Request) error { 58 + return http.ErrUseLastResponse 59 + }, 60 + } 61 + resp, err := client.Get("https://github.com/restic/restic/releases/latest") 62 + if err != nil { 63 + return "", err 64 + } 65 + resp.Body.Close() 66 + loc := resp.Header.Get("Location") 67 + if loc == "" { 68 + return "", fmt.Errorf("no redirect from github") 69 + } 70 + parts := strings.Split(loc, "/") 71 + v := parts[len(parts)-1] 72 + if len(v) > 0 && v[0] == 'v' { 73 + v = v[1:] 74 + } 75 + return v, nil 76 + } 77 + 78 + func downloadRestic(mStatus prefixedMenuItem) error { 79 + if !downloading.TryLock() { 80 + return nil 81 + } 82 + defer downloading.Unlock() 83 + 84 + mStatus.SetTitle("Fetching latest version...") 85 + version, err := latestResticVersion() 86 + if err != nil { 87 + log.Printf("failed to fetch latest version: %v", err) 88 + return err 89 + } 90 + if version == "" { 91 + log.Print("failed to determine latest version") 92 + return fmt.Errorf("failed to determine latest version") 93 + } 94 + 95 + url := fmt.Sprintf("https://github.com/restic/restic/releases/download/v%s/restic_%s_%s_%s.zip", 96 + version, version, runtime.GOOS, runtime.GOARCH) 97 + log.Printf("downloading restic %s", version) 98 + mStatus.SetTitle("Downloading restic " + version + "...") 99 + 100 + resp, err := http.Get(url) 101 + if err != nil { 102 + log.Printf("download failed: %v", err) 103 + return err 104 + } 105 + defer resp.Body.Close() 106 + if resp.StatusCode != 200 { 107 + log.Printf("download failed: %s", resp.Status) 108 + return fmt.Errorf("download failed: %s", resp.Status) 109 + } 110 + 111 + if err = extractResticZip(resp.Body); err == nil { 112 + log.Printf("installed restic %s", version) 113 + resetResticPath() 114 + } 115 + return err 116 + } 117 + 118 + func installBinary(r io.Reader) error { 119 + p := localResticPath() 120 + tmp, err := os.CreateTemp(filepath.Dir(p), "restic-download-*") 121 + if err != nil { 122 + return err 123 + } 124 + tmpName := tmp.Name() 125 + if _, err := io.Copy(tmp, r); err != nil { 126 + tmp.Close() 127 + os.Remove(tmpName) 128 + return err 129 + } 130 + tmp.Close() 131 + if err := os.Chmod(tmpName, 0755); err != nil { 132 + os.Remove(tmpName) 133 + return err 134 + } 135 + os.Remove(p) 136 + if err := os.Rename(tmpName, p); err != nil { 137 + os.Remove(tmpName) 138 + return err 139 + } 140 + return nil 141 + } 142 + 143 + func extractResticZip(r io.Reader) error { 144 + tmp, err := os.CreateTemp("", "restray-download-*.zip") 145 + if err != nil { 146 + return err 147 + } 148 + defer os.Remove(tmp.Name()) 149 + defer tmp.Close() 150 + 151 + if _, err := io.Copy(tmp, r); err != nil { 152 + return err 153 + } 154 + 155 + zr, err := zip.OpenReader(tmp.Name()) 156 + if err != nil { 157 + return err 158 + } 159 + defer zr.Close() 160 + 161 + for _, zf := range zr.File { 162 + if strings.HasSuffix(zf.Name, ".exe") { 163 + rc, err := zf.Open() 164 + if err != nil { 165 + return err 166 + } 167 + defer rc.Close() 168 + return installBinary(rc) 169 + } 170 + } 171 + return fmt.Errorf("restic binary not found in zip") 172 + } 173 + 174 + func checkResticUpdate(resticPath string, autoUpdate bool, mUpdate *systray.MenuItem, mStatus prefixedMenuItem, onUpdated func()) { 175 + current := resticVersion(resticPath) 176 + latest, err := latestResticVersion() 177 + if err != nil || current == "" || latest == "" || current == latest { 178 + return 179 + } 180 + log.Printf("restic update available: %s → %s", current, latest) 181 + if autoUpdate { 182 + err = downloadRestic(mStatus) 183 + if err == nil { 184 + onUpdated() 185 + } 186 + return 187 + } 188 + mUpdate.SetTitle("Update Restic (" + current + " → " + latest + ")") 189 + mUpdate.Show() 190 + }
+6 -4
tray.go
··· 163 163 } 164 164 } 165 165 166 - mDownload := systray.AddMenuItem("Download Restic", "Download restic binary") 166 + mDownload := systray.AddMenuItem("Install Restic", "Install restic") 167 167 mDownload.Hide() 168 168 mUpdate := systray.AddMenuItem("", "Update restic binary") 169 169 mUpdate.Hide() ··· 422 422 423 423 resticPath, managed := findRestic() 424 424 425 - if resticPath == "" && cfg.General.ManageRestic { 425 + if resticPath == "" && selfManagesRestic && cfg.General.ManageRestic { 426 426 mGlobalStatus.SetTitle("Downloading restic...") 427 427 mGlobalStatus.Show() 428 428 go doDownload() ··· 629 629 cfg := loadConfig() 630 630 openConsole(selectedProfile(), len(cfg.Profile), cfg.General.Terminal, "") 631 631 case <-mDownload.ClickedCh: 632 - if !isAnyBusy() { 632 + if !selfManagesRestic { 633 + openFile("https://restic.readthedocs.io/en/stable/020_installation.html") 634 + } else if !isAnyBusy() { 633 635 hideActions() 634 636 go doDownload() 635 637 } 636 638 case <-mUpdate.ClickedCh: 637 - if !isAnyBusy() { 639 + if selfManagesRestic && !isAnyBusy() { 638 640 hideActions() 639 641 go doDownload() 640 642 }