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: experimental rustic support

intergrav (Jul 10, 2026, 11:07 AM EDT) c8f89416 31757a94

+977 -549
+33 -25
README.md
··· 1 1 # Restray 2 2 3 - Restray is a simple, handy GUI/CLI/daemon to manage and schedule restic backups with ease. Supports multiple profiles and quite a bit of configuration, along with other features (see feature list below). Runs on MacOS, Windows, and Linux. 3 + Restray is a powerful but friendly backup program, powered by [restic](https://restic.net), that runs in your tray. Supports multiple profiles and quite a bit of configuration, along with other features (see feature list below). Runs on MacOS, Windows, and Linux. 4 + 5 + You can also configure Restray to use [rustic](https://rustic.cli.rs) instead, but please keep in mind that rustic support is currently experimental. 4 6 5 7 I needed a replacement for Time Machine, since it's painfully slow, only exclusive to Macs, and only supports SMB now. I quite like it's simplicity and ease-of-use though. Looking for other solutions, restic appeared to be a great fit, and I already use it for my server. However, I didn't find any option for scheduling restic backups that I actually liked. I did see [resticprofile](https://github.com/creativeprojects/resticprofile) which looks cool, and may be a better solution for some, please do check it out! But I'd still prefer a simple GUI personally. So... here's Restray which should hopefully solve that :) 6 8 ··· 34 36 35 37 To install, grab a build artifact for your OS and architecture from the [repository's tags](https://tangled.org/devins.page/restray/tags). On MacOS you'll need to [remove quarantine](https://disable-gatekeeper.github.io#disabling-gatekeeper-for-one-application-only) from the `.app`, since I can't pay Apple $100/year to sign it. Sorry. 36 38 37 - On MacOS, a restic binary is downloaded and bundled to the `.app` at build time, though I'd recommend installing restic to your PATH so you have the latest version. On Windows and Linux, Restray can download and update it's own restic binary itself if it isn't already in PATH. 39 + On macOS, latest restic/rustic backends are bundled into the `.app` at build time. On Windows and Linux, Restray can download and update the backend. If you install the backend to your path, e.g. with your package manager, it is preferred over the self-managed binary. 38 40 39 41 _If you're a Nix/NixOS/Nix-darwin user, I also provide package/module in the project's [flake](https://tangled.org/devins.page/restray) for Linux and MacOS._ 40 42 ··· 61 63 | Key | Type | Default | Description | 62 64 | ------------------ | ------ | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 63 65 | `check_updates` | bool | `true` | **MacOS/Windows only.** Check for new Restray versions on startup and periodically. When an update is found, the version item in the menu changes to an update button | 64 - | `manage_restic` | bool | `false` | **Windows/Linux only.** If restic is not found in PATH, automatically downloads and updates a self-managed restic binary daily from GitHub. | 66 + | `manage_backend` | bool | `false` | **Windows/Linux only.** If the selected backend is not found in PATH, automatically self-manages it's binary from GitHub. | 65 67 | `notifications` | string | `"none"` | `"all"` (success + errors), `"errors"` (errors only), or `"none"` (silent) | 66 68 | `icon` | string | platform | `"color"`, `"mono"`, `"white"`, or `"black"`. Default is `"mono"` on MacOS/Windows and `"color"` elsewhere. `"mono"` follows OS light/dark mode. | 67 69 | `schedule_display` | string | `"description"` | `"description"` (human-readable, e.g. "every 6 hours"), `"cron"` (raw expression, e.g. "0 \* \* \* \*"), `"last"` (time since last backup, e.g. "2 hours ago"), or `"none"` | ··· 71 73 72 74 You can create multiple of these `[[profiles]]` sections for separate backup targets. Each profile has its own schedule, repository, environment, etc. 73 75 74 - | Key | Type | Default | Description | 75 - | -------------------- | ------ | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 76 - | `name` | string | `"default"` | Name for the profile | 77 - | `env_file` | string | `<config_dir>/<name>.env` | Path to the environment file with `RESTIC_REPOSITORY`, `RESTIC_PASSWORD`, etc. | 78 - | `rclone_config_file` | string | `""` | Path to an rclone config file, set as `RCLONE_CONFIG` for this profile. When using an [`rclone:`](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#other-services-via-rclone) repository | 79 - | `retry_lock` | string | `"2m"` | How long to retry acquiring a repository lock (e.g. `"5m"`, `"30s"`). Will automatically attempt to clear stale locks after this timeout. Set to `"0"` to disable | 80 - | `pre_hook` | string | `""` | Shell command to run before a scheduled run. Inherits the profile's environment and restic binary. The schedule is aborted if the hook exits non-zero. Can also be run manually from the Operations menu | 81 - | `post_hook` | string | `""` | Shell command to run after a scheduled run. Inherits the profile's environment and restic binary. Can also be run manually from the Operations menu | 76 + | Key | Type | Default | Description | 77 + | -------------------- | ------ | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 78 + | `name` | string | `"default"` | Name for the profile | 79 + | `backend` | string | `"restic"` | `"restic"` or `"rustic"`. Do not change unless you know what this does. | 80 + | `env_file` | string | `<config_dir>/<name>.env` | Path to the environment file with RESTIC__, RUSTIC__, etc | 81 + | `rclone_config_file` | string | `""` | Path to an rclone config file, set as `RCLONE_CONFIG`. When using an [`rclone:`](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#other-services-via-rclone) repository | 82 + | `retry_lock` | string | `"2m"` | How long to retry acquiring a repository lock (e.g. `"5m"`, `"30s"`). Will automatically attempt to clear stale locks after this timeout. Set to `"0"` to disable | 83 + | `pre_hook` | string | `""` | Shell command to run before a scheduled run. Inherits the profile's environment and backend binary. | 84 + | `post_hook` | string | `""` | Shell command to run after a scheduled run. Inherits the profile's environment and backend binary. | 82 85 83 86 Hooks receive the following environment variables when run as part of a schedule: 84 87 ··· 101 104 102 105 #### `[profiles.backup]` 103 106 104 - | Key | Type | Default | Description | 105 - | ---------------- | -------- | -------------------------------------------- | ------------------------------------------------------------------------- | 106 - | `paths` | string[] | `[]` | Paths to back up, e.g. `["/Users/user/Documents", "/home/user/Projects"]` | 107 - | `args` | string[] | `["--exclude-caches", "--exclude", "*.tmp"]` | Extra arguments passed to `restic backup` | 108 - | `args_scheduled` | string[] | `["--tag", "scheduled"]` | Extra arguments appended (not replaced) when running from schedule | 107 + | Key | Type | Default | Description | 108 + | ---------------- | -------- | -------------------------------------------- | ------------------------------------------------------------------------------ | 109 + | `paths` | string[] | `[]` | Paths to back up, e.g. `["/Users/user/Documents", "/home/user/Projects"]` | 110 + | `args` | string[] | `["--exclude-caches", "--exclude", "*.tmp"]` | Extra arguments passed to `backup` | 111 + | `args_scheduled` | string[] | `["--tag", "scheduled"]` | Extra arguments appended (not replaced) to `backup` when running from schedule | 109 112 110 113 #### `[profiles.prune]` 111 114 112 - | Key | Type | Default | Description | 113 - | ------ | -------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | 114 - | `args` | string[] | `["--keep-last", "4", "--keep-hourly", "24", "--keep-daily", "7", "--keep-weekly", "4", "--keep-monthly", "12"]` | Extra arguments passed to `restic forget --prune` | 115 + | Key | Type | Default | Description | 116 + | ------ | -------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | 117 + | `args` | string[] | `["--keep-last", "4", "--keep-hourly", "24", "--keep-daily", "7", "--keep-weekly", "4", "--keep-monthly", "12"]` | Extra arguments passed to `forget --prune` | 115 118 116 119 #### `[profiles.check]` 117 120 118 - | Key | Type | Default | Description | 119 - | ------ | -------- | ------- | ----------------------------------------------------------------- | 120 - | `args` | string[] | `[]` | Extra arguments passed to `restic check` (e.g. `["--read-data"]`) | 121 + | Key | Type | Default | Description | 122 + | ------ | -------- | ------- | --------------------------------- | 123 + | `args` | string[] | `[]` | Extra arguments passed to `check` | 121 124 122 125 #### `[profiles.mount]` 123 126 124 - | Key | Type | Default | Description | 125 - | ------ | -------- | ------- | ------------------------------------------------------------------- | 126 - | `args` | string[] | `[]` | Extra arguments passed to `restic mount` (e.g. `["--allow-other"]`) | 127 + | Key | Type | Default | Description | 128 + | ------ | -------- | ------- | --------------------------------- | 129 + | `args` | string[] | `[]` | Extra arguments passed to `mount` | 127 130 128 131 ## Credits 132 + 133 + Wouldn't have been possible without: 134 + 135 + - [github.com/restic/restic](https://github.com/restic/restic) 136 + - [github.com/rustic-rs/rustic](https://github.com/rustic-rs/rustic) 129 137 130 138 Inspirations and cool things: 131 139
+219
cmd/restray/backend.go
··· 1 + package main 2 + 3 + import ( 4 + "log" 5 + "os" 6 + "os/exec" 7 + "path/filepath" 8 + "runtime" 9 + "strings" 10 + "sync" 11 + ) 12 + 13 + type Backend string 14 + 15 + const ( 16 + BackendRestic Backend = "restic" 17 + BackendRustic Backend = "rustic" 18 + ) 19 + 20 + func (p Profile) backend() Backend { 21 + switch Backend(strings.ToLower(strings.TrimSpace(p.Backend))) { 22 + case "", BackendRestic: 23 + return BackendRestic 24 + case BackendRustic: 25 + return BackendRustic 26 + default: 27 + return Backend("") 28 + } 29 + } 30 + 31 + func (p Profile) backendName() string { 32 + if b := p.backend(); b != "" { 33 + return string(b) 34 + } 35 + return p.Backend 36 + } 37 + 38 + func (p Profile) backendDisplayName() string { 39 + switch p.backend() { 40 + case BackendRestic: 41 + return "Restic" 42 + case BackendRustic: 43 + return "Rustic" 44 + default: 45 + return p.backendName() 46 + } 47 + } 48 + 49 + func (b Backend) supportsUnlock() bool { return b == BackendRestic } 50 + 51 + func mountSupported(prof Profile) bool { 52 + if prof.backend() == BackendRestic { 53 + return runtime.GOOS != "windows" 54 + } 55 + if prof.backend() != BackendRustic || runtime.GOOS != "linux" { 56 + return false 57 + } 58 + path, _ := findBackend(prof) 59 + if path == "" { 60 + return false 61 + } 62 + cmd := exec.Command(path, "mount", "--help") 63 + hideWindow(cmd) 64 + return cmd.Run() == nil 65 + } 66 + 67 + func backendEnvError(b Backend, vars map[string]string) string { 68 + prefix := strings.ToUpper(string(b)) 69 + if vars[prefix+"_REPOSITORY"] == "" { 70 + return prefix + "_REPOSITORY not set in env file" 71 + } 72 + if b == BackendRustic { 73 + return "" 74 + } 75 + if vars["RESTIC_PASSWORD"] == "" && vars["RESTIC_PASSWORD_FILE"] == "" && vars["RESTIC_PASSWORD_COMMAND"] == "" { 76 + return "No password set in env file" 77 + } 78 + return "" 79 + } 80 + 81 + var rusticBuiltinPath string 82 + var resticBuiltinPath string 83 + 84 + var backendPaths = map[Backend]*backendPathState{ 85 + BackendRestic: {}, 86 + BackendRustic: {}, 87 + } 88 + 89 + type backendPathState struct { 90 + sync.Mutex 91 + path string 92 + managed bool 93 + } 94 + 95 + func findBackend(prof Profile) (string, bool) { 96 + state := backendPaths[prof.backend()] 97 + if state == nil { 98 + return "", false 99 + } 100 + state.Lock() 101 + defer state.Unlock() 102 + if state.path != "" { 103 + return state.path, state.managed 104 + } 105 + state.path, state.managed = searchBackend(prof.backend()) 106 + return state.path, state.managed 107 + } 108 + 109 + func searchBackend(b Backend) (string, bool) { 110 + name := string(b) 111 + if p, err := exec.LookPath(name); err == nil { 112 + return p, false 113 + } 114 + if p := findBackendFromShell(name); p != "" { 115 + return p, false 116 + } 117 + if p := builtinBackend(b); p != "" { 118 + if _, err := os.Stat(p); err == nil { 119 + return p, false 120 + } 121 + } 122 + if p := managedBackend(b); p != "" { 123 + return p, true 124 + } 125 + if p := bundledBackend(name); p != "" { 126 + return p, false 127 + } 128 + return "", false 129 + } 130 + 131 + func builtinBackend(b Backend) string { 132 + switch b { 133 + case BackendRestic: 134 + return resticBuiltinPath 135 + case BackendRustic: 136 + return rusticBuiltinPath 137 + default: 138 + return "" 139 + } 140 + } 141 + 142 + func managedBackend(b Backend) string { 143 + switch b { 144 + case BackendRestic: 145 + return managedRestic() 146 + case BackendRustic: 147 + return managedRustic() 148 + default: 149 + return "" 150 + } 151 + } 152 + 153 + func bundledBackend(name string) string { 154 + if runtime.GOOS != "darwin" { 155 + return "" 156 + } 157 + exe, err := os.Executable() 158 + if err != nil { 159 + return "" 160 + } 161 + p := filepath.Join(filepath.Dir(exe), "..", "Resources", name) 162 + if _, err := os.Stat(p); err == nil { 163 + return p 164 + } 165 + return "" 166 + } 167 + 168 + func findBackendFromShell(name string) string { 169 + if runtime.GOOS == "windows" { 170 + return "" 171 + } 172 + shell := os.Getenv("SHELL") 173 + if shell == "" { 174 + if runtime.GOOS == "darwin" { 175 + shell = "/bin/zsh" 176 + } else { 177 + shell = "/bin/sh" 178 + } 179 + } 180 + out, err := exec.Command(shell, "-l", "-c", "command -v "+name).Output() 181 + if err == nil { 182 + if p := strings.TrimSpace(string(out)); p != "" { 183 + if _, err := os.Stat(p); err == nil { 184 + return p 185 + } 186 + } 187 + } 188 + for _, dir := range []string{"/opt/homebrew/bin", "/usr/local/bin", filepath.Join(os.Getenv("HOME"), ".nix-profile/bin"), "/run/current-system/sw/bin", filepath.Join(os.Getenv("HOME"), ".local/bin")} { 189 + p := filepath.Join(dir, name) 190 + if _, err := os.Stat(p); err == nil { 191 + return p 192 + } 193 + } 194 + return "" 195 + } 196 + 197 + func backendCmd(prof Profile, args ...string) *exec.Cmd { 198 + path, _ := findBackend(prof) 199 + args = backendCommandArgs(prof, args...) 200 + log.Printf("[%s] run (%s): %s %s", prof.displayName(), prof.backendName(), prof.backendName(), strings.Join(args, " ")) 201 + cmd := exec.Command(path, args...) 202 + cmd.Env = profileEnv(prof) 203 + hideWindow(cmd) 204 + return cmd 205 + } 206 + 207 + func backendCommandArgs(prof Profile, args ...string) []string { 208 + if prof.backend() == BackendRustic { 209 + return append([]string{"--log-level", "warn"}, args...) 210 + } 211 + return args 212 + } 213 + 214 + func backendUnavailable(prof Profile, operation string) string { 215 + if operation == "mount" && prof.backend() == BackendRustic && runtime.GOOS == "linux" { 216 + return "mount is unavailable because this rustic build lacks FUSE mount support" 217 + } 218 + return operation + " is not supported by " + prof.backendName() + " on " + runtime.GOOS 219 + }
+66 -57
cmd/restray/cli.go
··· 98 98 return nil 99 99 } 100 100 101 - func cliRunRestic(prof Profile, args ...string) error { 102 - p, _ := findRestic() 101 + func cliRunBackend(prof Profile, args ...string) error { 102 + return cliCommandError(runCLIBackend(prof, args...)) 103 + } 104 + 105 + func cliBackendCmd(prof Profile, args ...string) (*exec.Cmd, error) { 106 + p, _ := findBackend(prof) 103 107 if p == "" { 104 - return cli.Exit("error: restic not found", 1) 108 + return nil, fmt.Errorf("%s not found", prof.backendName()) 109 + } 110 + return backendCmd(prof, args...), nil 111 + } 112 + 113 + func runCLIBackend(prof Profile, args ...string) error { 114 + cmd, err := cliBackendCmd(prof, args...) 115 + if err != nil { 116 + return err 105 117 } 106 - cmd := resticCmd(prof, args...) 107 118 cmd.Stdout = os.Stdout 108 119 cmd.Stderr = os.Stderr 109 - if err := cmd.Run(); err != nil { 110 - return cli.Exit("", exitCode(err)) 111 - } 112 - return nil 120 + return cmd.Run() 113 121 } 114 122 115 - func cliRunResticRetryLock(prof Profile, args []string) error { 116 - err := cliRunRestic(prof, args...) 117 - if err != nil && exitCode(err) == 11 && retryLockEnabled(prof) { 118 - log.Printf("[%s] locked, running unlock and retrying", prof.displayName()) 119 - _ = cliRunRestic(prof, "unlock") 120 - err = cliRunRestic(prof, args...) 121 - } 122 - return err 123 + func cliRunBackendWithRetry(prof Profile, args []string) error { 124 + return cliCommandError(runWithRetryLock(prof, args, runCLIBackend)) 123 125 } 124 126 125 127 func cliRunCmdLogged(prof Profile, cmd *exec.Cmd) error { 128 + return cliCommandError(runLoggedCommand(prof, cmd)) 129 + } 130 + 131 + func runLoggedCommand(prof Profile, cmd *exec.Cmd) error { 126 132 stdoutPipe, _ := cmd.StdoutPipe() 127 133 stderrPipe, _ := cmd.StderrPipe() 128 134 if err := cmd.Start(); err != nil { 129 - return cli.Exit("", exitCode(err)) 135 + return err 130 136 } 131 137 var wg sync.WaitGroup 132 138 for _, pipe := range []io.Reader{stdoutPipe, stderrPipe} { ··· 141 147 } 142 148 err := cmd.Wait() 143 149 wg.Wait() 150 + return err 151 + } 152 + 153 + func runCLIBackendLogged(prof Profile, args ...string) error { 154 + cmd, err := cliBackendCmd(prof, args...) 144 155 if err != nil { 145 - return cli.Exit("", exitCode(err)) 156 + return err 146 157 } 147 - return nil 158 + return runLoggedCommand(prof, cmd) 148 159 } 149 160 150 - func cliRunResticLogged(prof Profile, args ...string) error { 151 - p, _ := findRestic() 152 - if p == "" { 153 - return cli.Exit("error: restic not found", 1) 154 - } 155 - return cliRunCmdLogged(prof, resticCmd(prof, args...)) 161 + func cliRunScheduledBackend(prof Profile, args []string) error { 162 + return cliCommandError(runWithRetryLock(prof, args, runCLIBackendLogged)) 156 163 } 157 164 158 - func cliRunResticRetryLockLogged(prof Profile, args []string) error { 159 - err := cliRunResticLogged(prof, args...) 165 + func runWithRetryLock(prof Profile, args []string, run func(Profile, ...string) error) error { 166 + err := run(prof, args...) 160 167 if err != nil && exitCode(err) == 11 && retryLockEnabled(prof) { 161 168 log.Printf("[%s] locked, running unlock and retrying", prof.displayName()) 162 - _ = cliRunResticLogged(prof, "unlock") 163 - err = cliRunResticLogged(prof, args...) 169 + _ = run(prof, "unlock") 170 + err = run(prof, args...) 164 171 } 165 172 return err 173 + } 174 + 175 + func cliCommandError(err error) error { 176 + if err == nil { 177 + return nil 178 + } 179 + if code := exitCode(err); code > 0 { 180 + return cli.Exit("", code) 181 + } 182 + return cli.Exit("error: "+err.Error(), 1) 166 183 } 167 184 168 185 func cliRunHook(hook string, prof Profile, extraEnv ...string) error { ··· 186 203 } 187 204 188 205 func cliBackup(prof Profile, scheduled bool) error { 189 - return cliRunResticRetryLock(prof, backupArgs(prof, scheduled)) 206 + return cliRunBackendWithRetry(prof, backupArgs(prof, scheduled)) 190 207 } 191 208 192 209 func cliPrune(prof Profile) error { 193 - return cliRunResticRetryLock(prof, addRetryLockArgs(prof, forgetArgs(prof))) 210 + return cliRunBackendWithRetry(prof, addRetryLockArgs(prof, forgetArgs(prof))) 194 211 } 195 212 196 213 func cliCheck(prof Profile) error { 197 - return cliRunResticRetryLock(prof, addRetryLockArgs(prof, checkArgs(prof))) 214 + return cliRunBackendWithRetry(prof, addRetryLockArgs(prof, checkArgs(prof))) 198 215 } 199 216 200 217 func cliUnlock(prof Profile) error { 201 - return cliRunRestic(prof, "unlock") 218 + if !prof.backend().supportsUnlock() { 219 + return cli.Exit("error: "+backendUnavailable(prof, "unlock"), 1) 220 + } 221 + return cliRunBackend(prof, "unlock") 202 222 } 203 223 204 224 func cliPreHook(prof Profile) error { ··· 218 238 shell = "/bin/sh" 219 239 } 220 240 } 221 - env := envWithResticPath(resticEnv(prof)) 222 - fmt.Fprintf(os.Stderr, "Restray: environment for profile %q loaded. Run \"restic\" for commands. Exit to return.\n", prof.displayName()) 223 - args := []string{"-l"} 224 - if runtime.GOOS == "windows" { 225 - args = nil 226 - } 227 - cmd := exec.Command(shell, args...) 241 + env := envWithBackendPath(profileEnv(prof), prof) 242 + fmt.Fprintf(os.Stderr, "Restray: environment for profile %q loaded. Run \"%s\" for commands. Exit to return.\n", prof.displayName(), prof.backendName()) 243 + cmd := exec.Command(shell) 228 244 cmd.Env = env 229 245 cmd.Stdin = os.Stdin 230 246 cmd.Stdout = os.Stdout ··· 236 252 } 237 253 238 254 func cliMount(prof Profile) error { 239 - if runtime.GOOS == "windows" { 240 - return cli.Exit("error: mount is not supported on Windows", 1) 255 + if !mountSupported(prof) { 256 + return cli.Exit("error: "+backendUnavailable(prof, "mount"), 1) 241 257 } 242 258 dir, err := os.MkdirTemp("", "restray-mount-") 243 259 if err != nil { ··· 245 261 } 246 262 defer os.Remove(dir) 247 263 248 - args := append([]string{"mount", "--no-lock"}, prof.Mount.Args...) 249 - args = append(args, dir) 250 264 fmt.Fprintf(os.Stderr, "Mounting at %s (press Ctrl+C to unmount)\n", dir) 251 - return cliRunRestic(prof, args...) 265 + return cliRunBackend(prof, mountArgs(prof, dir)...) 252 266 } 253 267 254 268 func cliSchedule(prof Profile) error { 255 - p, _ := findRestic() 269 + p, _ := findBackend(prof) 256 270 if p == "" { 257 - return cli.Exit("error: restic not found", 1) 271 + return cli.Exit("error: "+prof.backendName()+" not found", 1) 258 272 } 259 273 260 274 var interrupted atomic.Bool ··· 283 297 interrupted.Load, 284 298 func() (bool, string) { 285 299 log.Printf("[%s] running backup", prof.displayName()) 286 - if err := cliRunResticRetryLockLogged(prof, backupArgs(prof, true)); err != nil { 300 + if err := cliRunScheduledBackend(prof, backupArgs(prof, true)); err != nil { 287 301 return false, "backup failed" 288 302 } 289 303 return true, "" 290 304 }, 291 305 func() (bool, string) { 292 306 log.Printf("[%s] running prune", prof.displayName()) 293 - if err := cliRunResticRetryLockLogged(prof, addRetryLockArgs(prof, forgetArgs(prof))); err != nil { 307 + if err := cliRunScheduledBackend(prof, addRetryLockArgs(prof, forgetArgs(prof))); err != nil { 294 308 return false, "prune failed" 295 309 } 296 310 return true, "" 297 311 }, 298 312 func() (bool, string) { 299 313 log.Printf("[%s] running check", prof.displayName()) 300 - if err := cliRunResticRetryLockLogged(prof, addRetryLockArgs(prof, checkArgs(prof))); err != nil { 314 + if err := cliRunScheduledBackend(prof, addRetryLockArgs(prof, checkArgs(prof))); err != nil { 301 315 return false, "check failed" 302 316 } 303 317 return true, "" ··· 378 392 } 379 393 380 394 func cliDaemon(context.Context, *cli.Command) error { 381 - p, _ := findRestic() 382 - if p == "" { 383 - return cli.Exit("error: restic not found", 1) 384 - } 385 - 386 395 sched, err := buildDaemonSchedule() 387 396 if err != nil { 388 397 return cli.Exit(fmt.Sprintf("error: %v", err), 1) ··· 436 445 } 437 446 return &cli.Command{ 438 447 Name: "restray", 439 - Usage: "Restic backup scheduler and tray app", 448 + Usage: "Restic and Rustic backup scheduler and tray app", 440 449 Version: version, 441 450 EnableShellCompletion: true, 442 451 Flags: []cli.Flag{profileFlag()}, ··· 460 469 {Name: "prune", Usage: "Prune old snapshots", Action: profileAction(cliPrune)}, 461 470 {Name: "check", Usage: "Verify repository integrity", Action: profileAction(cliCheck)}, 462 471 {Name: "unlock", Usage: "Remove stale locks", Action: profileAction(cliUnlock)}, 463 - {Name: "shell", Usage: "Open shell with restic environment", Action: profileAction(cliShell)}, 472 + {Name: "shell", Usage: "Open shell with selected backend environment", Action: profileAction(cliShell)}, 464 473 {Name: "mount", Usage: "Mount repository (Ctrl+C to unmount)", Action: profileAction(cliMount)}, 465 474 {Name: "pre-hook", Usage: "Run pre-hook command", Action: profileAction(cliPreHook)}, 466 475 {Name: "post-hook", Usage: "Run post-hook command", Action: profileAction(cliPostHook)},
+18 -5
cmd/restray/config.go
··· 53 53 54 54 type Profile struct { 55 55 Name string `toml:"name"` 56 + Backend string `toml:"backend"` 56 57 EnvFile string `toml:"env_file"` 57 58 RcloneConfigFile string `toml:"rclone_config_file"` 58 59 RetryLock string `toml:"retry_lock"` ··· 67 68 68 69 type GUI struct { 69 70 CheckUpdates *bool `toml:"check_updates"` 71 + ManageBackend *bool `toml:"manage_backend"` 70 72 ManageRestic bool `toml:"manage_restic"` 71 73 Notifications string `toml:"notifications"` 72 74 Icon string `toml:"icon"` ··· 76 78 77 79 func (g GUI) UpdatesEnabled() bool { 78 80 return g.CheckUpdates == nil || *g.CheckUpdates 81 + } 82 + 83 + func (g GUI) BackendManagementEnabled() bool { 84 + return g.ManageBackend != nil && *g.ManageBackend || g.ManageBackend == nil && g.ManageRestic 79 85 } 80 86 81 87 type Config struct { ··· 122 128 if err != nil { 123 129 return "Env file not found" 124 130 } 125 - if vars["RESTIC_REPOSITORY"] == "" { 126 - return "RESTIC_REPOSITORY not set in env file" 131 + if prof.backend() == "" { 132 + return "Unsupported backend: " + prof.Backend 127 133 } 128 - if vars["RESTIC_PASSWORD"] == "" && vars["RESTIC_PASSWORD_FILE"] == "" && vars["RESTIC_PASSWORD_COMMAND"] == "" { 129 - return "No password set in env file" 134 + if err := backendEnvError(prof.backend(), vars); err != "" { 135 + return err 130 136 } 131 137 if prof.Schedule.BackupEnabled() && len(prof.Backup.Paths) == 0 { 132 138 return "No paths configured" ··· 250 256 } 251 257 for i := range cfg.Profiles { 252 258 prof := &cfg.Profiles[i] 259 + if prof.Backend == "" { 260 + prof.Backend = string(BackendRestic) 261 + } 253 262 if prof.EnvFile == "" { 254 263 prof.EnvFile = defaultEnvPath(prof.Name) 255 264 } ··· 257 266 prof.RetryLock = "2m" 258 267 } 259 268 if prof.Backup.Args == nil { 260 - prof.Backup.Args = []string{"--exclude-caches", "--exclude", "*.tmp"} 269 + if prof.backend() == BackendRustic { 270 + prof.Backup.Args = []string{} 271 + } else { 272 + prof.Backup.Args = []string{"--exclude-caches", "--exclude", "*.tmp"} 273 + } 261 274 } 262 275 if prof.Backup.ArgsScheduled == nil { 263 276 prof.Backup.ArgsScheduled = []string{"--tag", "scheduled"}
+2 -2
cmd/restray/console_darwin.go
··· 10 10 "strings" 11 11 ) 12 12 13 - func consoleCmd(vars map[string]string, consoleMsg, tailMsg, tailFile, terminal string) *exec.Cmd { 13 + func consoleCmd(vars map[string]string, prof Profile, consoleMsg, tailMsg, tailFile, terminal string) *exec.Cmd { 14 14 envFile, err := os.CreateTemp("", "restray-env-*") 15 15 if err != nil { 16 16 return nil ··· 32 32 fmt.Fprintf(script, "#!/bin/sh\n") 33 33 fmt.Fprintf(script, "while IFS='=' read -r -d '' k v; do export \"$k=$v\"; done < %q\n", envFile.Name()) 34 34 fmt.Fprintf(script, "rm -f %q\n", envFile.Name()) 35 - if p, _ := findRestic(); p != "" { 35 + if p, _ := findBackend(prof); p != "" { 36 36 fmt.Fprintf(script, "export PATH=%q:\"$PATH\"\n", filepath.Dir(p)) 37 37 } 38 38 fmt.Fprintf(script, "clear\n")
+2 -2
cmd/restray/console_other.go
··· 21 21 return "" 22 22 } 23 23 24 - func consoleCmd(vars map[string]string, consoleMsg, tailMsg, tailFile, terminal string) *exec.Cmd { 24 + func consoleCmd(vars map[string]string, prof Profile, consoleMsg, tailMsg, tailFile, terminal string) *exec.Cmd { 25 25 shell := os.Getenv("SHELL") 26 26 if shell == "" { 27 27 shell = "/bin/sh" ··· 45 45 for k, v := range vars { 46 46 env = append(env, k+"="+v) 47 47 } 48 - cmd.Env = envWithResticPath(env) 48 + cmd.Env = envWithBackendPath(env, prof) 49 49 return cmd 50 50 }
+2 -2
cmd/restray/console_windows.go
··· 8 8 "os/exec" 9 9 ) 10 10 11 - func consoleCmd(vars map[string]string, consoleMsg, tailMsg, tailFile, _ string) *exec.Cmd { 11 + func consoleCmd(vars map[string]string, prof Profile, consoleMsg, tailMsg, tailFile, _ string) *exec.Cmd { 12 12 script, err := os.CreateTemp("", "restray-shell-*.bat") 13 13 if err != nil { 14 14 return nil ··· 28 28 for k, v := range vars { 29 29 env = append(env, k+"="+v) 30 30 } 31 - cmd.Env = envWithResticPath(env) 31 + cmd.Env = envWithBackendPath(env, prof) 32 32 showConsole(cmd) 33 33 return cmd 34 34 }
+1 -1
cmd/restray/main.go
··· 11 11 "github.com/gen2brain/beeep" 12 12 ) 13 13 14 - const version = "0.20.6" 14 + const version = "0.21.0" 15 15 16 16 const ( 17 17 systemConfigDir = "/etc/restray"
+148 -34
cmd/restray/manage.go
··· 3 3 package main 4 4 5 5 import ( 6 + "archive/tar" 6 7 "archive/zip" 7 8 "compress/bzip2" 9 + "compress/gzip" 8 10 "fmt" 9 11 "io" 10 12 "log" ··· 20 22 "fyne.io/systray" 21 23 ) 22 24 23 - const selfManagesRestic = true 25 + const selfManagesBackend = true 24 26 25 27 var downloading sync.Mutex 26 28 ··· 39 41 return "" 40 42 } 41 43 42 - func resetResticPath() { 43 - resticMu.Lock() 44 - defer resticMu.Unlock() 45 - resticPath = "" 46 - resticManaged = false 44 + func rusticBinaryName() string { 45 + if runtime.GOOS == "windows" { 46 + return "rustic.exe" 47 + } 48 + return "rustic" 47 49 } 48 50 49 - func resticVersion(path string) string { 50 - cmd := exec.Command(path, "version") 51 + func managedRustic() string { 52 + p := filepath.Join(dataDir(), rusticBinaryName()) 53 + if _, err := os.Stat(p); err == nil { 54 + return p 55 + } 56 + return "" 57 + } 58 + 59 + func resetBackendPath(b Backend) { 60 + if state := backendPaths[b]; state != nil { 61 + state.Lock() 62 + defer state.Unlock() 63 + state.path = "" 64 + state.managed = false 65 + } 66 + } 67 + 68 + func binaryVersion(path string, args ...string) string { 69 + cmd := exec.Command(path, args...) 51 70 hideWindow(cmd) 52 71 out, err := cmd.Output() 53 72 if err != nil { ··· 60 79 return "" 61 80 } 62 81 63 - func latestResticVersion() (string, error) { 82 + func latestGitHubRelease(repo string) (string, error) { 64 83 client := &http.Client{ 65 84 Timeout: 10 * time.Second, 66 85 CheckRedirect: func(req *http.Request, via []*http.Request) error { 67 86 return http.ErrUseLastResponse 68 87 }, 69 88 } 70 - resp, err := client.Get("https://github.com/restic/restic/releases/latest") 89 + resp, err := client.Get("https://github.com/" + repo + "/releases/latest") 71 90 if err != nil { 72 91 return "", err 73 92 } ··· 76 95 if loc == "" { 77 96 return "", fmt.Errorf("no redirect from github") 78 97 } 79 - parts := strings.Split(loc, "/") 80 - v := parts[len(parts)-1] 81 - if len(v) > 0 && v[0] == 'v' { 82 - v = v[1:] 98 + v := strings.TrimPrefix(filepath.Base(loc), "v") 99 + if v == "" || v == "." { 100 + return "", fmt.Errorf("invalid release redirect from github") 83 101 } 84 102 return v, nil 85 103 } ··· 91 109 defer downloading.Unlock() 92 110 93 111 mStatus.SetTitle("Fetching latest version...") 94 - version, err := latestResticVersion() 112 + version, err := latestGitHubRelease("restic/restic") 95 113 if err != nil { 96 114 log.Printf("failed to fetch latest version: %v", err) 97 115 return err ··· 123 141 124 142 if err = extractRestic(resp.Body); err == nil { 125 143 log.Printf("installed restic %s", version) 126 - resetResticPath() 144 + resetBackendPath(BackendRestic) 127 145 } 128 146 return err 129 147 } 130 148 131 149 func extractRestic(r io.Reader) error { 132 150 if runtime.GOOS != "windows" { 133 - return installBinary(bzip2.NewReader(r)) 151 + return installNamedBinary(bzip2.NewReader(r), resticBinaryName()) 134 152 } 135 153 136 154 tmp, err := os.CreateTemp("", "restray-download-*.zip") ··· 157 175 return err 158 176 } 159 177 defer rc.Close() 160 - return installBinary(rc) 178 + return installNamedBinary(rc, resticBinaryName()) 161 179 } 162 180 } 163 181 return fmt.Errorf("restic binary not found in zip") 164 182 } 165 183 166 - func installBinary(r io.Reader) error { 167 - p := filepath.Join(dataDir(), resticBinaryName()) 168 - tmp, err := os.CreateTemp(filepath.Dir(p), "restic-download-*") 184 + func rusticTarget() (string, error) { 185 + if runtime.GOOS == "windows" && runtime.GOARCH == "amd64" { 186 + return "x86_64-pc-windows-msvc", nil 187 + } 188 + if runtime.GOOS == "linux" && runtime.GOARCH == "amd64" { 189 + return "x86_64-unknown-linux-musl", nil 190 + } 191 + if runtime.GOOS == "linux" && runtime.GOARCH == "arm64" { 192 + return "aarch64-unknown-linux-musl", nil 193 + } 194 + return "", fmt.Errorf("rustic managed downloads are unsupported on %s/%s", runtime.GOOS, runtime.GOARCH) 195 + } 196 + 197 + func downloadRustic(mStatus prefixedMenuItem) error { 198 + if !downloading.TryLock() { 199 + return nil 200 + } 201 + defer downloading.Unlock() 202 + mStatus.SetTitle("Fetching latest Rustic version...") 203 + version, err := latestGitHubRelease("rustic-rs/rustic") 204 + if err != nil { 205 + return err 206 + } 207 + target, err := rusticTarget() 208 + if err != nil { 209 + return err 210 + } 211 + ext := "tar.gz" 212 + url := fmt.Sprintf("https://github.com/rustic-rs/rustic/releases/download/v%s/rustic-v%s-%s.%s", version, version, target, ext) 213 + mStatus.SetTitle("Downloading Rustic " + version + "...") 214 + resp, err := http.Get(url) 215 + if err != nil { 216 + return err 217 + } 218 + defer resp.Body.Close() 219 + if resp.StatusCode != http.StatusOK { 220 + return fmt.Errorf("download failed: %s", resp.Status) 221 + } 222 + err = extractRusticTar(resp.Body) 223 + if err == nil { 224 + resetBackendPath(BackendRustic) 225 + log.Printf("installed rustic %s", version) 226 + } 227 + return err 228 + } 229 + 230 + func extractRusticTar(r io.Reader) error { 231 + gz, err := gzip.NewReader(r) 232 + if err != nil { 233 + return err 234 + } 235 + defer gz.Close() 236 + tr := tar.NewReader(gz) 237 + for { 238 + h, err := tr.Next() 239 + if err == io.EOF { 240 + break 241 + } 242 + if err != nil { 243 + return err 244 + } 245 + if filepath.Base(h.Name) == rusticBinaryName() { 246 + return installNamedBinary(tr, rusticBinaryName()) 247 + } 248 + } 249 + return fmt.Errorf("rustic binary not found in archive") 250 + } 251 + 252 + func installNamedBinary(r io.Reader, name string) error { 253 + p := filepath.Join(dataDir(), name) 254 + if err := os.MkdirAll(filepath.Dir(p), 0700); err != nil { 255 + return err 256 + } 257 + tmp, err := os.CreateTemp(filepath.Dir(p), name+"-download-*") 169 258 if err != nil { 170 259 return err 171 260 } ··· 175 264 os.Remove(tmpName) 176 265 return err 177 266 } 178 - tmp.Close() 267 + if err := tmp.Close(); err != nil { 268 + os.Remove(tmpName) 269 + return err 270 + } 179 271 if err := os.Chmod(tmpName, 0755); err != nil { 180 272 os.Remove(tmpName) 181 273 return err 182 274 } 183 - os.Remove(p) 275 + if runtime.GOOS == "windows" { 276 + if err := os.Remove(p); err != nil && !os.IsNotExist(err) { 277 + os.Remove(tmpName) 278 + return err 279 + } 280 + } 184 281 if err := os.Rename(tmpName, p); err != nil { 185 282 os.Remove(tmpName) 186 283 return err ··· 188 285 return nil 189 286 } 190 287 191 - func checkResticUpdate(resticPath string, autoUpdate bool, mUpdate *systray.MenuItem, mStatus prefixedMenuItem, onUpdated func()) { 192 - current := resticVersion(resticPath) 193 - latest, err := latestResticVersion() 194 - if err != nil || current == "" || latest == "" || current == latest { 288 + func downloadBackend(prof Profile, status prefixedMenuItem) error { 289 + if prof.backend() == BackendRustic { 290 + return downloadRustic(status) 291 + } 292 + return downloadRestic(status) 293 + } 294 + func checkBackendUpdate(prof Profile, path string, auto bool, item *systray.MenuItem, status prefixedMenuItem, done func()) { 295 + backend := prof.backend() 296 + versionArgs := []string{"version"} 297 + repo := "restic/restic" 298 + label := "Restic" 299 + download := downloadRestic 300 + if backend == BackendRustic { 301 + versionArgs = []string{"--version"} 302 + repo = "rustic-rs/rustic" 303 + label = "Rustic" 304 + download = downloadRustic 305 + } 306 + current := strings.TrimPrefix(binaryVersion(path, versionArgs...), "v") 307 + latest, err := latestGitHubRelease(repo) 308 + latest = strings.TrimPrefix(latest, "v") 309 + if err != nil || current == "" || latest == current { 195 310 return 196 311 } 197 - log.Printf("restic update available: %s → %s", current, latest) 198 - if autoUpdate { 199 - err = downloadRestic(mStatus) 200 - if err == nil { 201 - onUpdated() 312 + log.Printf("%s update available: %s → %s", strings.ToLower(label), current, latest) 313 + if auto { 314 + if download(status) == nil { 315 + done() 202 316 } 203 317 return 204 318 } 205 - mUpdate.SetTitle("Update Restic (" + current + " → " + latest + ")") 206 - mUpdate.Show() 319 + item.SetTitle("Update " + label + " (" + current + " → " + latest + ")") 320 + item.Show() 207 321 }
+5 -4
cmd/restray/manage_other.go
··· 4 4 5 5 import "fyne.io/systray" 6 6 7 - const selfManagesRestic = false 7 + const selfManagesBackend = false 8 8 9 - func downloadRestic(prefixedMenuItem) error { return nil } 10 - func checkResticUpdate(string, bool, *systray.MenuItem, prefixedMenuItem, func()) {} 11 - func managedRestic() string { return "" } 9 + func managedRestic() string { return "" } 10 + func managedRustic() string { return "" } 11 + func downloadBackend(Profile, prefixedMenuItem) error { return nil } 12 + func checkBackendUpdate(Profile, string, bool, *systray.MenuItem, prefixedMenuItem, func()) {}
+42 -26
cmd/restray/operations.go
··· 68 68 }) 69 69 } 70 70 71 - func runResticOnce(idx profileIndex, prof Profile, mStatus prefixedMenuItem, args ...string) (string, error) { 72 - cmd := resticCmd(prof, args...) 71 + func runBackendOnce(idx profileIndex, prof Profile, mStatus prefixedMenuItem, args ...string) (string, error) { 72 + cmd := backendCmd(prof, args...) 73 73 var stdout strings.Builder 74 74 cmd.Stdout = &stdout 75 75 stderrPipe, _ := cmd.StderrPipe() ··· 86 86 logPrefixedOutput(prof.displayName(), out) 87 87 } 88 88 if err == nil { 89 - log.Printf("[%s] done: restic %s", prof.displayName(), args[0]) 89 + log.Printf("[%s] done (%s): %s", prof.displayName(), prof.backendName(), args[0]) 90 90 return "", nil 91 91 } 92 - msg, code := classifyError(err, sl.lastLine()) 92 + msg, code := classifyBackendError(prof, err, sl.lastLine()) 93 93 if msg != "" { 94 94 mStatus.SetTitle(msg) 95 95 } ··· 98 98 } 99 99 100 100 func retryLockEnabled(prof Profile) bool { 101 - return prof.RetryLock != "" && prof.RetryLock != "0" 101 + return prof.backend() == BackendRestic && prof.RetryLock != "" && prof.RetryLock != "0" 102 102 } 103 103 104 104 func retryUnlock(idx profileIndex, prof Profile, mStatus prefixedMenuItem) { 105 105 log.Printf("[%s] locked, running unlock and retrying", prof.displayName()) 106 106 mStatus.SetTitle("Unlocking repository...") 107 - runResticOnce(idx, prof, mStatus, "unlock") 107 + runBackendOnce(idx, prof, mStatus, "unlock") 108 108 } 109 109 110 - func runRestic(idx profileIndex, prof Profile, mStatus prefixedMenuItem, args ...string) (string, error) { 110 + func runBackend(idx profileIndex, prof Profile, mStatus prefixedMenuItem, args ...string) (string, error) { 111 111 if retryLockEnabled(prof) { 112 112 args = append(args, "--retry-lock", prof.RetryLock) 113 113 } 114 - msg, err := runResticOnce(idx, prof, mStatus, args...) 114 + msg, err := runBackendOnce(idx, prof, mStatus, args...) 115 115 if err != nil && exitCode(err) == 11 && retryLockEnabled(prof) { 116 116 retryUnlock(idx, prof, mStatus) 117 - msg, err = runResticOnce(idx, prof, mStatus, args...) 117 + msg, err = runBackendOnce(idx, prof, mStatus, args...) 118 118 } 119 119 return msg, err 120 120 } 121 121 122 122 func doBackupOnce(idx profileIndex, mStatus prefixedMenuItem, prof Profile, args []string) (bool, int) { 123 - cmd := resticCmd(prof, args...) 123 + cmd := backendCmd(prof, args...) 124 124 stdoutPipe, _ := cmd.StdoutPipe() 125 125 stderrPipe, _ := cmd.StderrPipe() 126 126 setProfileBusyCmd(idx, cmd) ··· 166 166 } 167 167 168 168 if err := cmd.Wait(); err != nil { 169 - msg, code := classifyError(err, sl.lastLine()) 169 + msg, code := classifyBackendError(prof, err, sl.lastLine()) 170 170 log.Printf("[%s] exit: %v (code %d)", prof.displayName(), err, code) 171 - if code == 3 { 171 + if prof.backend() == BackendRestic && code == 3 { 172 172 log.Printf("[%s] backup completed with warnings (some files could not be read)", prof.displayName()) 173 173 return true, 3 174 174 } ··· 180 180 } 181 181 182 182 func doBackup(idx profileIndex, mStatus prefixedMenuItem, prof Profile, scheduled bool) (bool, int) { 183 - args := backupArgs(prof, scheduled, "--json") 183 + jsonFlag := "--json" 184 + if prof.backend() == BackendRustic { 185 + jsonFlag = "--json-progress" 186 + } 187 + args := backupArgs(prof, scheduled, jsonFlag) 184 188 185 189 ok, code := doBackupOnce(idx, mStatus, prof, args) 186 190 if !ok && code == 11 && retryLockEnabled(prof) { ··· 196 200 } 197 201 198 202 func runBackup(idx profileIndex, mStatus prefixedMenuItem, prof Profile, onDone func()) { 199 - if p, _ := findRestic(); p == "" { 203 + if p, _ := findBackend(prof); p == "" { 200 204 return 201 205 } 202 206 if !acquireProfile(idx) { ··· 219 223 } 220 224 } 221 225 222 - func envWithResticPath(env []string) []string { 223 - p, _ := findRestic() 226 + func envWithBackendPath(env []string, prof Profile) []string { 227 + p, _ := findBackend(prof) 224 228 if p == "" { 225 229 return env 226 230 } ··· 241 245 } else { 242 246 cmd = exec.Command("sh", "-c", hook) 243 247 } 244 - cmd.Env = envWithResticPath(resticEnv(prof)) 248 + cmd.Env = envWithBackendPath(profileEnv(prof), prof) 245 249 cmd.Env = append(cmd.Env, extraEnv...) 246 250 cmd.Env = append(cmd.Env, "RESTRAY_PROFILE="+prof.displayName()) 247 251 return cmd ··· 320 324 } 321 325 322 326 func runScheduled(idx profileIndex, mStatus prefixedMenuItem, prof Profile, onDone func()) { 323 - if p, _ := findRestic(); p == "" { 327 + if p, _ := findBackend(prof); p == "" { 324 328 return 325 329 } 326 330 if !acquireProfile(idx) { ··· 356 360 }, 357 361 func() (bool, string) { 358 362 mStatus.SetTitle("Pruning repository...") 359 - if msg, err := runRestic(idx, prof, mStatus, forgetArgs(prof)...); err != nil { 363 + if msg, err := runBackend(idx, prof, mStatus, forgetArgs(prof)...); err != nil { 360 364 setProfileFailed(idx, msg) 361 365 return false, msg 362 366 } ··· 364 368 }, 365 369 func() (bool, string) { 366 370 mStatus.SetTitle("Checking repository...") 367 - if msg, err := runRestic(idx, prof, mStatus, checkArgs(prof)...); err != nil { 371 + if msg, err := runBackend(idx, prof, mStatus, checkArgs(prof)...); err != nil { 368 372 setProfileFailed(idx, msg) 369 373 return false, msg 370 374 } ··· 416 420 return append([]string{"check"}, prof.Check.Args...) 417 421 } 418 422 423 + func mountArgs(prof Profile, dir string) []string { 424 + args := []string{"mount"} 425 + if prof.backend() == BackendRestic { 426 + args = append(args, "--no-lock") 427 + } 428 + args = append(args, prof.Mount.Args...) 429 + return append(args, dir) 430 + } 431 + 419 432 func isProfileMounted(idx profileIndex) bool { 420 433 state.mu.Lock() 421 434 defer state.mu.Unlock() ··· 423 436 } 424 437 425 438 func startMount(idx profileIndex, prof Profile, mMount *systray.MenuItem, onDone func()) { 439 + if !mountSupported(prof) { 440 + notifyError("Mount", backendUnavailable(prof, "mount")) 441 + onDone() 442 + return 443 + } 426 444 dir, err := os.MkdirTemp("", "restray-mount-") 427 445 if err != nil { 428 446 return 429 447 } 430 448 431 - mountArgs := append([]string{"mount", "--no-lock"}, prof.Mount.Args...) 432 - mountArgs = append(mountArgs, dir) 433 - cmd := resticCmd(prof, mountArgs...) 449 + cmd := backendCmd(prof, mountArgs(prof, dir)...) 434 450 var stderr strings.Builder 435 451 cmd.Stderr = &stderr 436 452 ··· 539 555 return 540 556 } 541 557 542 - consoleMsg := "Restray: environment loaded in shell. Run \"restic\" for commands." 558 + consoleMsg := fmt.Sprintf("Restray: environment loaded in shell. Run \"%s\" for commands.", prof.backendName()) 543 559 if profileCount > 1 { 544 - consoleMsg = fmt.Sprintf("Restray: environment for profile \"%s\" loaded in shell. Run \"restic\" for commands.", prof.displayName()) 560 + consoleMsg = fmt.Sprintf("Restray: environment for profile \"%s\" loaded in shell. Run \"%s\" for commands.", prof.displayName(), prof.backendName()) 545 561 } 546 562 547 - cmd := consoleCmd(vars, consoleMsg, "Restray: tailing log.", tailFile, terminal) 563 + cmd := consoleCmd(vars, prof, consoleMsg, "Restray: tailing log.", tailFile, terminal) 548 564 if cmd != nil { 549 565 cmd.Start() 550 566 }
+186
cmd/restray/repository.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "log" 7 + "os" 8 + "os/exec" 9 + "strings" 10 + "time" 11 + ) 12 + 13 + func profileEnv(prof Profile) []string { 14 + env := os.Environ() 15 + if vars, err := parseEnvFile(prof.EnvFile); err == nil { 16 + for k, v := range vars { 17 + env = setEnv(env, k, v) 18 + } 19 + } 20 + if prof.RcloneConfigFile != "" { 21 + env = setEnv(env, "RCLONE_CONFIG", prof.RcloneConfigFile) 22 + } 23 + return env 24 + } 25 + 26 + func setEnv(env []string, key, value string) []string { 27 + for i, entry := range env { 28 + if k, _, ok := strings.Cut(entry, "="); ok && k == key { 29 + env[i] = key + "=" + value 30 + return env 31 + } 32 + } 33 + return append(env, key+"="+value) 34 + } 35 + 36 + type repoResult struct { 37 + errMsg string 38 + needsInit bool 39 + } 40 + 41 + func probeRepo(prof Profile, args []string) ([]byte, repoResult) { 42 + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 43 + defer cancel() 44 + args = backendCommandArgs(prof, args...) 45 + path, _ := findBackend(prof) 46 + log.Printf("[%s] probe (%s): %s %s", prof.displayName(), prof.backendName(), prof.backendName(), strings.Join(args, " ")) 47 + start := time.Now() 48 + cmd := exec.CommandContext(ctx, path, args...) 49 + cmd.Env = profileEnv(prof) 50 + hideWindow(cmd) 51 + var stderr strings.Builder 52 + cmd.Stderr = &stderr 53 + out, err := cmd.Output() 54 + if err != nil { 55 + trimmed := strings.TrimSpace(stderr.String()) 56 + if ctx.Err() != nil { 57 + log.Printf("[%s] repo status timed out after 30s; last output: %s", prof.displayName(), trimmed) 58 + return nil, repoResult{errMsg: "Repository unreachable"} 59 + } 60 + log.Printf("[%s] repo status error: %s", prof.displayName(), trimmed) 61 + lines := strings.Split(trimmed, "\n") 62 + msg, code := classifyBackendError(prof, err, lines[len(lines)-1]) 63 + needsInit := repositoryNeedsInit(prof, code, trimmed) 64 + if needsInit { 65 + msg = "Repository not initialized" 66 + } 67 + return nil, repoResult{errMsg: msg, needsInit: needsInit} 68 + } 69 + log.Printf("[%s] repo status ok (%s)", prof.displayName(), time.Since(start).Round(time.Millisecond)) 70 + return out, repoResult{} 71 + } 72 + 73 + func repoStatus(prof Profile) repoResult { 74 + args := []string{"cat", "config"} 75 + if prof.backend() == BackendRestic { 76 + args = append(args, "--no-lock", "-q") 77 + } 78 + _, rr := probeRepo(prof, args) 79 + return rr 80 + } 81 + 82 + func repoStatusAndLastBackup(prof Profile) (repoResult, time.Time) { 83 + host, err := os.Hostname() 84 + if err != nil { 85 + return repoStatus(prof), time.Time{} 86 + } 87 + args := []string{"snapshots", "--json"} 88 + if prof.backend() == BackendRestic { 89 + args = append(args, "--latest", "1", "--host", host, "--no-lock") 90 + } else { 91 + args = append(args, "--filter-host", host, "--filter-last", "1", "--group-by", "host") 92 + } 93 + out, rr := probeRepo(prof, args) 94 + if rr.errMsg != "" { 95 + return rr, time.Time{} 96 + } 97 + if prof.backend() == BackendRustic { 98 + var groups []struct { 99 + Snapshots []struct { 100 + Time time.Time `json:"time"` 101 + } `json:"snapshots"` 102 + } 103 + if json.Unmarshal(out, &groups) != nil || len(groups) == 0 || len(groups[0].Snapshots) == 0 { 104 + return rr, time.Time{} 105 + } 106 + return rr, groups[0].Snapshots[0].Time 107 + } 108 + var snaps []struct { 109 + Time time.Time `json:"time"` 110 + } 111 + if json.Unmarshal(out, &snaps) != nil || len(snaps) == 0 { 112 + return rr, time.Time{} 113 + } 114 + return rr, snaps[0].Time 115 + } 116 + 117 + func exitCode(err error) int { 118 + if exitErr, ok := err.(*exec.ExitError); ok { 119 + return exitErr.ExitCode() 120 + } 121 + return -1 122 + } 123 + 124 + func killedBySystem(err error) bool { 125 + return err != nil && exitCode(err) == -1 && strings.Contains(err.Error(), "killed") 126 + } 127 + 128 + func classifyResticError(err error, lastStderr string) (string, int) { 129 + code := exitCode(err) 130 + if killedBySystem(err) { 131 + return "Process terminated by system", code 132 + } 133 + if code == 12 { 134 + return "Wrong password", code 135 + } 136 + if lastStderr != "" { 137 + var msg struct { 138 + Message string `json:"message"` 139 + } 140 + if json.Unmarshal([]byte(lastStderr), &msg) == nil && msg.Message != "" { 141 + return msg.Message, code 142 + } 143 + return lastStderr, code 144 + } 145 + if err != nil { 146 + return err.Error(), code 147 + } 148 + return "", code 149 + } 150 + 151 + func rusticNeedsInit(output string) bool { 152 + s := strings.ToLower(output) 153 + return strings.Contains(s, "repository does not exist") || strings.Contains(s, "repository is not initialized") || strings.Contains(s, "no repository config file found") || strings.Contains(s, "no such file or directory") && strings.Contains(s, "config") 154 + } 155 + 156 + func repositoryNeedsInit(prof Profile, code int, output string) bool { 157 + switch prof.backend() { 158 + case BackendRestic: 159 + return code == 10 160 + case BackendRustic: 161 + return rusticNeedsInit(output) 162 + default: 163 + return false 164 + } 165 + } 166 + 167 + func classifyBackendError(prof Profile, err error, lastStderr string) (string, int) { 168 + if prof.backend() == BackendRestic { 169 + return classifyResticError(err, lastStderr) 170 + } 171 + if killedBySystem(err) { 172 + return "Process terminated by system", exitCode(err) 173 + } 174 + msg := strings.TrimSpace(lastStderr) 175 + lower := strings.ToLower(msg) 176 + if strings.Contains(lower, "wrong password") || strings.Contains(lower, "password") && strings.Contains(lower, "failed") { 177 + return "Wrong password or key", exitCode(err) 178 + } 179 + if msg != "" { 180 + return msg, exitCode(err) 181 + } 182 + if err != nil { 183 + return err.Error(), exitCode(err) 184 + } 185 + return "", exitCode(err) 186 + }
-309
cmd/restray/restic.go
··· 1 - package main 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - "log" 7 - "os" 8 - "os/exec" 9 - "path/filepath" 10 - "runtime" 11 - "strings" 12 - "sync" 13 - "time" 14 - ) 15 - 16 - // optional restic path baked at build time for Nix and other pkg managers 17 - var resticBuiltinPath string 18 - 19 - var ( 20 - resticMu sync.Mutex 21 - resticPath string 22 - resticManaged bool 23 - ) 24 - 25 - func findRestic() (path string, managed bool) { 26 - resticMu.Lock() 27 - defer resticMu.Unlock() 28 - if resticPath != "" { 29 - return resticPath, resticManaged 30 - } 31 - resticPath, resticManaged = searchRestic() 32 - return resticPath, resticManaged 33 - } 34 - 35 - func searchRestic() (string, bool) { 36 - if p, err := exec.LookPath("restic"); err == nil { 37 - return p, false 38 - } 39 - if p := findResticFromShell(); p != "" { 40 - return p, false 41 - } 42 - if resticBuiltinPath != "" { 43 - if _, err := os.Stat(resticBuiltinPath); err == nil { 44 - return resticBuiltinPath, false 45 - } 46 - } 47 - if p := managedRestic(); p != "" { 48 - return p, true 49 - } 50 - if p := bundledRestic(); p != "" { 51 - return p, false 52 - } 53 - return "", false 54 - } 55 - 56 - func bundledRestic() string { 57 - if runtime.GOOS != "darwin" { 58 - return "" 59 - } 60 - exe, err := os.Executable() 61 - if err != nil { 62 - return "" 63 - } 64 - p := filepath.Join(filepath.Dir(exe), "..", "Resources", "restic") 65 - if _, err := os.Stat(p); err == nil { 66 - return p 67 - } 68 - return "" 69 - } 70 - 71 - func findResticFromShell() string { 72 - if runtime.GOOS == "windows" { 73 - return "" 74 - } 75 - shell := os.Getenv("SHELL") 76 - if shell == "" { 77 - if runtime.GOOS == "darwin" { 78 - shell = "/bin/zsh" 79 - } else { 80 - shell = "/bin/sh" 81 - } 82 - } 83 - out, err := exec.Command(shell, "-l", "-c", "which restic").Output() 84 - if err == nil { 85 - if p := strings.TrimSpace(string(out)); p != "" { 86 - if _, err := os.Stat(p); err == nil { 87 - return p 88 - } 89 - } 90 - } 91 - for _, dir := range []string{ 92 - "/opt/homebrew/bin", 93 - "/usr/local/bin", 94 - filepath.Join(os.Getenv("HOME"), ".nix-profile/bin"), 95 - "/run/current-system/sw/bin", 96 - filepath.Join(os.Getenv("HOME"), ".local/bin"), 97 - } { 98 - p := filepath.Join(dir, "restic") 99 - if _, err := os.Stat(p); err == nil { 100 - return p 101 - } 102 - } 103 - return "" 104 - } 105 - 106 - func resticEnv(prof Profile) []string { 107 - env := os.Environ() 108 - if vars, err := parseEnvFile(prof.EnvFile); err == nil { 109 - for k, v := range vars { 110 - env = append(env, k+"="+v) 111 - } 112 - } 113 - if prof.RcloneConfigFile != "" { 114 - env = append(env, "RCLONE_CONFIG="+prof.RcloneConfigFile) 115 - } 116 - return env 117 - } 118 - 119 - func resticCmd(prof Profile, args ...string) *exec.Cmd { 120 - path, _ := findRestic() 121 - log.Printf("[%s] run: restic %s", prof.displayName(), strings.Join(args, " ")) 122 - cmd := exec.Command(path, args...) 123 - cmd.Env = resticEnv(prof) 124 - hideWindow(cmd) 125 - return cmd 126 - } 127 - 128 - type repoResult struct { 129 - errMsg string 130 - needsInit bool 131 - } 132 - 133 - func probeRepo(prof Profile, args []string) ([]byte, repoResult) { 134 - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 135 - defer cancel() 136 - path, _ := findRestic() 137 - log.Printf("[%s] run: restic %s", prof.displayName(), strings.Join(args, " ")) 138 - start := time.Now() 139 - cmd := exec.CommandContext(ctx, path, args...) 140 - cmd.Env = resticEnv(prof) 141 - hideWindow(cmd) 142 - var stderr strings.Builder 143 - cmd.Stderr = &stderr 144 - out, err := cmd.Output() 145 - if err != nil { 146 - trimmed := strings.TrimSpace(stderr.String()) 147 - if ctx.Err() != nil { 148 - log.Printf("[%s] repo status timed out after 30s; last output: %s", prof.displayName(), trimmed) 149 - return nil, repoResult{errMsg: "Repository unreachable"} 150 - } 151 - log.Printf("[%s] repo status error: %s", prof.displayName(), trimmed) 152 - lines := strings.Split(trimmed, "\n") 153 - msg, code := classifyError(err, lines[len(lines)-1]) 154 - return nil, repoResult{errMsg: msg, needsInit: code == 10} 155 - } 156 - log.Printf("[%s] repo status ok (%s)", prof.displayName(), time.Since(start).Round(time.Millisecond)) 157 - return out, repoResult{} 158 - } 159 - 160 - func repoStatus(prof Profile) repoResult { 161 - _, rr := probeRepo(prof, []string{"cat", "config", "--no-lock", "-q"}) 162 - return rr 163 - } 164 - 165 - func repoStatusAndLastBackup(prof Profile) (repoResult, time.Time) { 166 - host, err := os.Hostname() 167 - if err != nil { 168 - return repoStatus(prof), time.Time{} 169 - } 170 - out, rr := probeRepo(prof, []string{"snapshots", "--no-lock", "--json", "--latest", "1", "--host", host}) 171 - if rr.errMsg != "" { 172 - return rr, time.Time{} 173 - } 174 - var snaps []struct { 175 - Time time.Time `json:"time"` 176 - } 177 - if json.Unmarshal(out, &snaps) != nil || len(snaps) == 0 { 178 - return rr, time.Time{} 179 - } 180 - return rr, snaps[0].Time 181 - } 182 - 183 - type profileIndex = int 184 - 185 - type appState struct { 186 - mu sync.Mutex 187 - busyProfiles map[profileIndex]*exec.Cmd 188 - onBusyChanged func(bool) 189 - mountCmds map[profileIndex]*exec.Cmd 190 - mountStopping map[profileIndex]bool 191 - notifications string 192 - failStatus map[profileIndex]string 193 - lastBackup map[profileIndex]time.Time 194 - } 195 - 196 - var state = appState{ 197 - busyProfiles: make(map[profileIndex]*exec.Cmd), 198 - mountCmds: make(map[profileIndex]*exec.Cmd), 199 - mountStopping: make(map[profileIndex]bool), 200 - failStatus: make(map[profileIndex]string), 201 - lastBackup: make(map[profileIndex]time.Time), 202 - } 203 - 204 - func setLastBackup(idx profileIndex, t time.Time) { 205 - state.mu.Lock() 206 - state.lastBackup[idx] = t 207 - state.mu.Unlock() 208 - } 209 - 210 - func getLastBackup(idx profileIndex) time.Time { 211 - state.mu.Lock() 212 - defer state.mu.Unlock() 213 - return state.lastBackup[idx] 214 - } 215 - 216 - func isProfileBusy(idx profileIndex) bool { 217 - state.mu.Lock() 218 - defer state.mu.Unlock() 219 - _, ok := state.busyProfiles[idx] 220 - return ok 221 - } 222 - 223 - func isAnyBusy() bool { 224 - state.mu.Lock() 225 - defer state.mu.Unlock() 226 - return len(state.busyProfiles) > 0 227 - } 228 - 229 - func releaseProfile(idx profileIndex) { 230 - state.mu.Lock() 231 - wasBusy := len(state.busyProfiles) > 0 232 - delete(state.busyProfiles, idx) 233 - nowBusy := len(state.busyProfiles) > 0 234 - cb := state.onBusyChanged 235 - state.mu.Unlock() 236 - finishBusyChange(wasBusy, nowBusy, cb) 237 - } 238 - 239 - func acquireProfile(idx profileIndex) bool { 240 - state.mu.Lock() 241 - if _, busy := state.busyProfiles[idx]; busy { 242 - state.mu.Unlock() 243 - return false 244 - } 245 - wasBusy := len(state.busyProfiles) > 0 246 - state.busyProfiles[idx] = nil 247 - nowBusy := len(state.busyProfiles) > 0 248 - cb := state.onBusyChanged 249 - state.mu.Unlock() 250 - finishBusyChange(wasBusy, nowBusy, cb) 251 - return true 252 - } 253 - 254 - func finishBusyChange(wasBusy, nowBusy bool, cb func(bool)) { 255 - if nowBusy && !wasBusy { 256 - setIconAnimated("busy") 257 - } 258 - if cb != nil { 259 - cb(nowBusy) 260 - } 261 - } 262 - 263 - func setProfileBusyCmd(idx profileIndex, cmd *exec.Cmd) { 264 - state.mu.Lock() 265 - defer state.mu.Unlock() 266 - state.busyProfiles[idx] = cmd 267 - } 268 - 269 - func cancelProfile(idx profileIndex) { 270 - state.mu.Lock() 271 - defer state.mu.Unlock() 272 - if cmd := state.busyProfiles[idx]; cmd != nil && cmd.Process != nil { 273 - interruptProcess(cmd.Process) 274 - } 275 - } 276 - 277 - func exitCode(err error) int { 278 - if exitErr, ok := err.(*exec.ExitError); ok { 279 - return exitErr.ExitCode() 280 - } 281 - return -1 282 - } 283 - 284 - func killedBySystem(err error) bool { 285 - return err != nil && exitCode(err) == -1 && strings.Contains(err.Error(), "killed") 286 - } 287 - 288 - func classifyError(err error, lastStderr string) (string, int) { 289 - code := exitCode(err) 290 - if killedBySystem(err) { 291 - return "Process terminated by system", code 292 - } 293 - if code == 12 { 294 - return "Wrong password", code 295 - } 296 - if lastStderr != "" { 297 - var msg struct { 298 - Message string `json:"message"` 299 - } 300 - if json.Unmarshal([]byte(lastStderr), &msg) == nil && msg.Message != "" { 301 - return msg.Message, code 302 - } 303 - return lastStderr, code 304 - } 305 - if err != nil { 306 - return err.Error(), code 307 - } 308 - return "", code 309 - }
+101
cmd/restray/state.go
··· 1 + package main 2 + 3 + import ( 4 + "os/exec" 5 + "sync" 6 + "time" 7 + ) 8 + 9 + type profileIndex = int 10 + 11 + type appState struct { 12 + mu sync.Mutex 13 + busyProfiles map[profileIndex]*exec.Cmd 14 + onBusyChanged func(bool) 15 + mountCmds map[profileIndex]*exec.Cmd 16 + mountStopping map[profileIndex]bool 17 + notifications string 18 + failStatus map[profileIndex]string 19 + lastBackup map[profileIndex]time.Time 20 + } 21 + 22 + var state = appState{ 23 + busyProfiles: make(map[profileIndex]*exec.Cmd), 24 + mountCmds: make(map[profileIndex]*exec.Cmd), 25 + mountStopping: make(map[profileIndex]bool), 26 + failStatus: make(map[profileIndex]string), 27 + lastBackup: make(map[profileIndex]time.Time), 28 + } 29 + 30 + func setLastBackup(idx profileIndex, t time.Time) { 31 + state.mu.Lock() 32 + state.lastBackup[idx] = t 33 + state.mu.Unlock() 34 + } 35 + 36 + func getLastBackup(idx profileIndex) time.Time { 37 + state.mu.Lock() 38 + defer state.mu.Unlock() 39 + return state.lastBackup[idx] 40 + } 41 + 42 + func isProfileBusy(idx profileIndex) bool { 43 + state.mu.Lock() 44 + defer state.mu.Unlock() 45 + _, ok := state.busyProfiles[idx] 46 + return ok 47 + } 48 + 49 + func isAnyBusy() bool { 50 + state.mu.Lock() 51 + defer state.mu.Unlock() 52 + return len(state.busyProfiles) > 0 53 + } 54 + 55 + func releaseProfile(idx profileIndex) { 56 + state.mu.Lock() 57 + wasBusy := len(state.busyProfiles) > 0 58 + delete(state.busyProfiles, idx) 59 + nowBusy := len(state.busyProfiles) > 0 60 + cb := state.onBusyChanged 61 + state.mu.Unlock() 62 + finishBusyChange(wasBusy, nowBusy, cb) 63 + } 64 + 65 + func acquireProfile(idx profileIndex) bool { 66 + state.mu.Lock() 67 + if _, busy := state.busyProfiles[idx]; busy { 68 + state.mu.Unlock() 69 + return false 70 + } 71 + wasBusy := len(state.busyProfiles) > 0 72 + state.busyProfiles[idx] = nil 73 + nowBusy := len(state.busyProfiles) > 0 74 + cb := state.onBusyChanged 75 + state.mu.Unlock() 76 + finishBusyChange(wasBusy, nowBusy, cb) 77 + return true 78 + } 79 + 80 + func finishBusyChange(wasBusy, nowBusy bool, cb func(bool)) { 81 + if nowBusy && !wasBusy { 82 + setIconAnimated("busy") 83 + } 84 + if cb != nil { 85 + cb(nowBusy) 86 + } 87 + } 88 + 89 + func setProfileBusyCmd(idx profileIndex, cmd *exec.Cmd) { 90 + state.mu.Lock() 91 + defer state.mu.Unlock() 92 + state.busyProfiles[idx] = cmd 93 + } 94 + 95 + func cancelProfile(idx profileIndex) { 96 + state.mu.Lock() 97 + defer state.mu.Unlock() 98 + if cmd := state.busyProfiles[idx]; cmd != nil && cmd.Process != nil { 99 + interruptProcess(cmd.Process) 100 + } 101 + }
+2 -1
cmd/restray/templates/config.toml
··· 3 3 4 4 [gui] 5 5 # check_updates = true 6 - # manage_restic = false 6 + # manage_backend = false 7 7 # notifications = "none" 8 8 9 9 [[profiles]] 10 10 name = "default" 11 + # backend = "restic" 11 12 # env_file = "{{ENV_FILE}}" 12 13 # pre_hook = "" 13 14 # post_hook = ""
+2 -1
cmd/restray/templates/default.env
··· 1 1 # Restray environment 2 - # Restic scripting documentation: https://restic.readthedocs.io/en/stable/075_scripting.html 2 + # Restic variables: https://restic.readthedocs.io/en/stable/075_scripting.html 3 + # Rustic variables: https://rustic.cli.rs/docs/commands/backup/environment_variables.html 3 4 4 5 RESTIC_REPOSITORY= 5 6 RESTIC_PASSWORD=
+35 -19
cmd/restray/templates/webeditor.html
··· 98 98 <label 99 99 ><input 100 100 type="checkbox" 101 - name="manage_restic" 101 + name="manage_backend" 102 102 {{if 103 - .GUI.ManageRestic}}checked{{end}} 103 + .GUI.BackendManagementEnabled}}checked{{end}} 104 104 /> 105 - Manage Restic</label 105 + Manage Backend</label 106 106 > 107 107 <small 108 - >Windows/Linux only. If restic is not found in PATH, 109 - automatically downloads and updates a self-managed restic 110 - binary daily from GitHub.</small 108 + >Windows/Linux only. If the selected backend is not found in 109 + PATH, automatically self-manages it's binary from 110 + GitHub.</small 111 111 > 112 112 </p> 113 113 ··· 195 195 value="{{$p.Name}}" 196 196 /> 197 197 </p> 198 + <p> 199 + <label for="backend-{{$i}}">Backend</label> 200 + <small 201 + >Backup program used by this profile. Do not change unless you 202 + know what this does.</small 203 + > 204 + <select name="backend" id="backend-{{$i}}"> 205 + {{if eq $p.Backend "restic"}} 206 + <option value="restic" selected>restic</option> 207 + {{else}} 208 + <option value="restic">restic</option> 209 + {{end}} {{if eq $p.Backend "rustic"}} 210 + <option value="rustic" selected>rustic (experimental)</option> 211 + {{else}} 212 + <option value="rustic">rustic (experimental)</option> 213 + {{end}} 214 + </select> 215 + </p> 198 216 199 217 <p> 200 218 <label for="env_file-{{$i}}">Env File</label> 201 219 <small 202 - >Path to the environment file with RESTIC_REPOSITORY, 203 - RESTIC_PASSWORD, etc.</small 220 + >Path to the environment file with RESTIC_*, RUSTIC_*, 221 + etc.</small 204 222 > 205 223 <input 206 224 type="text" ··· 215 233 >Rclone Config File</label 216 234 > 217 235 <small 218 - >Path to an rclone config file, set as RCLONE_CONFIG for this 219 - profile. Edit its contents with your regular editor/rclone; 220 - this only stores the path.</small 236 + >Path to an rclone config file, set as RCLONE_CONFIG. Edit its 237 + contents with your regular editor/rclone; this only stores the 238 + path.</small 221 239 > 222 240 <input 223 241 type="text" ··· 328 346 </p> 329 347 <p> 330 348 <label for="backup_args-{{$i}}">Args</label> 331 - <small>Extra arguments passed to restic backup.</small> 349 + <small>Extra arguments passed to `backup`.</small> 332 350 <textarea name="backup_args" id="backup_args-{{$i}}"> 333 351 {{joinLines $p.Backup.Args}}</textarea> 334 352 </p> ··· 337 355 >Scheduled Args</label 338 356 > 339 357 <small 340 - >Extra arguments appended (not replaced) when running from 341 - schedule.</small 358 + >Extra arguments appended (not replaced) to `backup` when 359 + running from schedule.</small 342 360 > 343 361 <textarea 344 362 name="backup_args_scheduled" ··· 352 370 <legend>Prune</legend> 353 371 <p> 354 372 <label for="prune_args-{{$i}}">Args</label> 355 - <small 356 - >Extra arguments passed to restic forget --prune.</small 357 - > 373 + <small>Extra arguments passed to `forget --prune`.</small> 358 374 <textarea name="prune_args" id="prune_args-{{$i}}"> 359 375 {{joinLines $p.Prune.Args}}</textarea> 360 376 </p> ··· 364 380 <legend>Check</legend> 365 381 <p> 366 382 <label for="check_args-{{$i}}">Args</label> 367 - <small>Extra arguments passed to restic check.</small> 383 + <small>Extra arguments passed to `check`.</small> 368 384 <textarea name="check_args" id="check_args-{{$i}}"> 369 385 {{joinLines $p.Check.Args}}</textarea> 370 386 </p> ··· 374 390 <legend>Mount</legend> 375 391 <p> 376 392 <label for="mount_args-{{$i}}">Args</label> 377 - <small>Extra arguments passed to restic mount.</small> 393 + <small>Extra arguments passed to `mount`.</small> 378 394 <textarea name="mount_args" id="mount_args-{{$i}}"> 379 395 {{joinLines $p.Mount.Args}}</textarea> 380 396 </p>
+53 -32
cmd/restray/tray.go
··· 172 172 } 173 173 } 174 174 175 - mDownload := systray.AddMenuItem("Install Restic", "Install restic") 175 + mDownload := systray.AddMenuItem("Install Backend", "Install selected backup backend") 176 176 mDownload.Hide() 177 - mUpdate := systray.AddMenuItem("", "Update restic binary") 177 + mUpdate := systray.AddMenuItem("", "Update backend binary") 178 178 mUpdate.Hide() 179 179 mSchedule := systray.AddMenuItem("Run Schedule Now", "Run the full schedule immediately") 180 180 mSchedule.Hide() 181 181 mCancel := systray.AddMenuItem("Cancel Operation", "Cancel running operation") 182 182 mCancel.Hide() 183 - mInit := systray.AddMenuItem("Initialize Repository", "Initialize a new restic repository") 183 + mInit := systray.AddMenuItem("Initialize Repository", "Initialize a new backup repository") 184 184 mInit.Hide() 185 185 mRepo := systray.AddMenuItem("Operations", "Repository operations") 186 186 mRepo.Disable() ··· 249 249 mRepo.Disable() 250 250 } 251 251 252 - doDownload := func() { 253 - setIconAnimated("download") 254 - err := downloadRestic(mGlobalStatus) 255 - if err != nil { 256 - setIconAnimated("fail") 257 - mGlobalStatus.SetTitle("Download failed: " + err.Error()) 258 - mDownload.Show() 259 - return 260 - } 261 - applyConfig() 262 - } 263 - 264 252 selectedProfile := func() Profile { 265 253 cfg := loadConfig() 266 254 idx := getActiveProfile() ··· 273 261 return Profile{} 274 262 } 275 263 264 + doDownload := func() { 265 + setIconAnimated("download") 266 + if err := downloadBackend(selectedProfile(), mGlobalStatus); err != nil { 267 + setIconAnimated("fail") 268 + mGlobalStatus.SetTitle("Download failed: " + err.Error()) 269 + mDownload.Show() 270 + return 271 + } 272 + applyConfig() 273 + } 274 + 276 275 statusItem := func(idx int) prefixedMenuItem { 277 276 cfg := loadConfig() 278 277 prefix := "" ··· 381 380 mPreHook.Disable() 382 381 mPostHook.Disable() 383 382 384 - if runtime.GOOS == "windows" { 385 - mMount.Hide() 386 - } else if isProfileMounted(idx) { 383 + mMount.Show() 384 + if isProfileMounted(idx) { 387 385 mMount.SetTitle("Unmount") 386 + mMount.Enable() 387 + } else if mountSupported(prof) { 388 + mMount.SetTitle("Mount") 389 + mMount.Enable() 388 390 } else { 389 391 mMount.SetTitle("Mount") 392 + mMount.Disable() 390 393 } 391 394 392 395 if isProfileBusy(idx) { ··· 409 412 mBackup.Enable() 410 413 mPrune.Enable() 411 414 mCheck.Enable() 412 - mUnlock.Enable() 415 + if prof.backend().supportsUnlock() { 416 + mUnlock.Enable() 417 + } else { 418 + mUnlock.Disable() 419 + } 413 420 mSchedule.Show() 414 421 mSchedule.Enable() 415 422 if prof.PreHook != "" { ··· 424 431 425 432 probeProfileState := func(cfg Config, i int, prof Profile) profileState { 426 433 var ps profileState 434 + if path, _ := findBackend(prof); path == "" { 435 + ps.errMsg = prof.backendDisplayName() + " not found" 436 + return ps 437 + } 427 438 ps.errMsg = prof.configError() 428 439 if ps.errMsg == "" { 429 440 wantsLast := cfg.GUI.ScheduleDisplay == "last" && getLastBackup(i).IsZero() ··· 524 535 mDownload.Hide() 525 536 mUpdate.Hide() 526 537 527 - resticPath, managed := findRestic() 538 + prof := selectedProfile() 539 + backendPath, managed := findBackend(prof) 540 + backendName := prof.backendDisplayName() 528 541 529 - if resticPath == "" && selfManagesRestic && cfg.GUI.ManageRestic { 530 - mGlobalStatus.SetTitle("Downloading restic...") 542 + if backendPath == "" && selfManagesBackend && cfg.GUI.BackendManagementEnabled() { 543 + mGlobalStatus.SetTitle("Downloading " + prof.backendName() + "...") 531 544 mGlobalStatus.Show() 532 545 go doDownload() 533 546 return 534 547 } 535 - if resticPath == "" { 548 + if backendPath == "" { 536 549 setIconAnimated("fail") 537 - mGlobalStatus.SetTitle("Restic not found") 550 + mGlobalStatus.SetTitle(backendName + " not found") 538 551 mGlobalStatus.Show() 539 552 hideActions() 553 + mDownload.SetTitle("Install " + backendName) 540 554 mDownload.Show() 541 555 mProfile.Disable() 542 556 return ··· 645 659 applyProfileUI(cfg) 646 660 647 661 if managed { 648 - go checkResticUpdate(resticPath, cfg.GUI.ManageRestic, mUpdate, mGlobalStatus, applyConfig) 662 + go checkBackendUpdate(prof, backendPath, cfg.GUI.BackendManagementEnabled(), mUpdate, mGlobalStatus, applyConfig) 649 663 } 650 664 } 651 665 ··· 662 676 } 663 677 }() 664 678 for range time.NewTicker(24 * time.Hour).C { 665 - resticPath, managed := findRestic() 679 + prof := selectedProfile() 680 + backendPath, managed := findBackend(prof) 666 681 if managed && !isAnyBusy() { 667 682 cfg := loadConfig() 668 - checkResticUpdate(resticPath, cfg.GUI.ManageRestic, mUpdate, mGlobalStatus, applyConfig) 683 + checkBackendUpdate(prof, backendPath, cfg.GUI.BackendManagementEnabled(), mUpdate, mGlobalStatus, applyConfig) 669 684 } 670 685 } 671 686 }() ··· 724 739 defer finishProfile(idx) 725 740 defer releaseProfile(idx) 726 741 ms.SetTitle(status) 727 - if msg, err := runRestic(idx, prof, ms, args...); err != nil { 742 + if msg, err := runBackend(idx, prof, ms, args...); err != nil { 728 743 setProfileFailed(idx, msg) 729 744 notifyError(args[0], msg) 730 745 } else { ··· 759 774 case <-mCheck.ClickedCh: 760 775 repoOp("Checking repository...", checkArgs(selectedProfile())...) 761 776 case <-mUnlock.ClickedCh: 762 - repoOp("Unlocking repository...", "unlock") 777 + if selectedProfile().backend().supportsUnlock() { 778 + repoOp("Unlocking repository...", "unlock") 779 + } 763 780 case <-mInit.ClickedCh: 764 781 repoOp("Initializing repository...", "init") 765 782 case <-mMount.ClickedCh: ··· 796 813 }() 797 814 } 798 815 case <-mDownload.ClickedCh: 799 - if !selfManagesRestic { 800 - openFile("https://restic.readthedocs.io/en/stable/020_installation.html") 816 + if !selfManagesBackend { 817 + if selectedProfile().backend() == BackendRustic { 818 + openFile("https://rustic.cli.rs/docs/installation.html") 819 + } else { 820 + openFile("https://restic.readthedocs.io/en/stable/020_installation.html") 821 + } 801 822 } else if !isAnyBusy() { 802 823 hideActions() 803 824 go doDownload() 804 825 } 805 826 case <-mUpdate.ClickedCh: 806 - if selfManagesRestic && !isAnyBusy() { 827 + if selfManagesBackend && !isAnyBusy() { 807 828 hideActions() 808 829 go doDownload() 809 830 }
+3 -1
cmd/restray/webeditor.go
··· 190 190 cfg := loadConfig() 191 191 checkUpdates := r.FormValue("check_updates") == "on" 192 192 cfg.GUI.CheckUpdates = &checkUpdates 193 - cfg.GUI.ManageRestic = r.FormValue("manage_restic") == "on" 193 + manageBackend := r.FormValue("manage_backend") == "on" 194 + cfg.GUI.ManageBackend = &manageBackend 194 195 cfg.GUI.Notifications = r.FormValue("notifications") 195 196 cfg.GUI.Icon = r.FormValue("icon") 196 197 cfg.GUI.ScheduleDisplay = r.FormValue("schedule_display") ··· 210 211 } 211 212 p := &cfg.Profiles[idx] 212 213 p.Name = r.FormValue("name") 214 + p.Backend = r.FormValue("backend") 213 215 p.EnvFile = r.FormValue("env_file") 214 216 p.RcloneConfigFile = r.FormValue("rclone_config_file") 215 217 p.RetryLock = r.FormValue("retry_lock")
+6 -6
flake.nix
··· 1 1 { 2 - description = "A simple, handy GUI/CLI/daemon to manage and schedule restic backups with ease"; 2 + description = "Powerful but friendly backup program, powered by restic, that runs in your tray"; 3 3 4 4 inputs = { 5 5 nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; ··· 23 23 in { 24 24 packages.default = pkgs.buildGoModule { 25 25 pname = "restray"; 26 - version = "0.20.6"; 26 + version = "0.21.0"; 27 27 src = ./.; 28 - vendorHash = "sha256-JdR/v3Ho0rd5uNZVCEo1wQ/OzXz1kFhp1DXG+84Vgw0="; 28 + vendorHash = "sha256-SeXluece3RKPrmbv90E3H4Trj6loS3Y3IYLJexBeFBU="; 29 29 subPackages = ["cmd/restray"]; 30 30 31 - ldflags = ["-s" "-w" "-X" "main.resticBuiltinPath=${pkgs.restic}/bin/restic"]; 31 + ldflags = ["-s" "-w" "-X" "main.resticBuiltinPath=${pkgs.restic}/bin/restic" "-X" "main.rusticBuiltinPath=${pkgs.rustic}/bin/rustic"]; 32 32 33 33 env = pkgs.lib.optionalAttrs isDarwin { 34 34 CGO_CFLAGS = "-mmacosx-version-min=11.0"; ··· 58 58 --replace-fail "ExecStart=restray daemon" "ExecStart=$out/bin/restray daemon" 59 59 install -Dm644 packaging/linux/restray.service $out/lib/systemd/system/restray.service 60 60 substituteInPlace $out/lib/systemd/system/restray.service \ 61 - --replace-fail "ExecStart=restray daemon" "ExecStart=$out/bin/restray daemon" 61 + --replace-fail "ExecStart=restray --config /etc/restray --state /var/lib/restray daemon" "ExecStart=$out/bin/restray --config /etc/restray --state /var/lib/restray daemon" 62 62 '' 63 63 } 64 64 installShellCompletion --cmd restray \ ··· 68 68 ''; 69 69 70 70 meta = { 71 - description = "Restic backup scheduler system tray app"; 71 + description = "Restic and Rustic backup scheduler system tray app"; 72 72 license = pkgs.lib.licenses.gpl3Only; 73 73 platforms = pkgs.lib.platforms.linux ++ pkgs.lib.platforms.darwin; 74 74 mainProgram = "restray";
+1 -1
go.mod
··· 26 26 github.com/sergeymakinen/go-bmp v1.0.0 // indirect 27 27 github.com/sergeymakinen/go-ico v1.0.0 // indirect 28 28 github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect 29 - golang.org/x/sys v0.46.0 // indirect 29 + golang.org/x/sys v0.47.0 // indirect 30 30 howett.net/plist v1.0.1 // indirect 31 31 )
+2 -2
go.sum
··· 54 54 github.com/urfave/cli/v3 v3.10.1 h1:7Kx9H50hrHbRbyxgO1KP6/BcbiGRz0uYh5YyQ30JEEY= 55 55 github.com/urfave/cli/v3 v3.10.1/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= 56 56 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 57 - golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= 58 - golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 57 + golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= 58 + golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 59 59 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 60 60 gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= 61 61 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+24
justfile
··· 130 130 just _fetch-restic-darwin {{arch}} 131 131 cp {{bin}}/restic-darwin-{{arch}} {{dist}}/Restray.app/Contents/Resources/restic 132 132 chmod +x {{dist}}/Restray.app/Contents/Resources/restic 133 + just _fetch-rustic-darwin {{arch}} 134 + cp {{bin}}/rustic-darwin-{{arch}} {{dist}}/Restray.app/Contents/Resources/rustic 135 + chmod +x {{dist}}/Restray.app/Contents/Resources/rustic 133 136 (cd {{dist}} && zip -r {{app}}-{{version}}-darwin-{{arch}}-app.zip Restray.app) 134 137 rm -rf {{dist}}/Restray.app 135 138 ··· 144 147 version=$(curl -fsSL https://api.github.com/repos/restic/restic/releases/latest | grep '"tag_name"' | head -1 | cut -d'"' -f4 | sed 's/^v//') 145 148 curl -fsSL -o "$tmp/restic.bz2" "https://github.com/restic/restic/releases/download/v${version}/restic_${version}_darwin_{{arch}}.bz2" 146 149 bunzip2 -c "$tmp/restic.bz2" > "$out" 150 + chmod +x "$out" 151 + 152 + _fetch-rustic-darwin arch: 153 + #!/usr/bin/env bash 154 + set -e 155 + out="{{bin}}/rustic-darwin-{{arch}}" 156 + [ -f "$out" ] && exit 0 157 + mkdir -p {{bin}} 158 + tmp=$(mktemp -d) 159 + trap 'rm -rf "$tmp"' EXIT 160 + version=$(curl -fsSL https://api.github.com/repos/rustic-rs/rustic/releases/latest | grep '"tag_name"' | head -1 | cut -d'"' -f4 | sed 's/^v//') 161 + case "{{arch}}" in 162 + amd64) target=x86_64-apple-darwin ;; 163 + arm64) target=aarch64-apple-darwin ;; 164 + *) echo "unsupported macOS architecture: {{arch}}" >&2; exit 1 ;; 165 + esac 166 + curl -fsSL -o "$tmp/rustic.tar.gz" "https://github.com/rustic-rs/rustic/releases/download/v${version}/rustic-v${version}-${target}.tar.gz" 167 + tar -xzf "$tmp/rustic.tar.gz" -C "$tmp" 168 + binary=$(find "$tmp" -type f -name rustic -perm -111 | head -1) 169 + [ -n "$binary" ] 170 + cp "$binary" "$out" 147 171 chmod +x "$out" 148 172 149 173 _package-windows arch:
+21 -16
nix/shared.nix
··· 9 9 default = true; 10 10 description = "MacOS/Windows only. Check for new Restray versions on startup and periodically. When an update is found, the version item in the menu changes to an update button"; 11 11 }; 12 - manageRestic = lib.mkOption { 12 + manageBackend = lib.mkOption { 13 13 type = lib.types.bool; 14 14 default = false; 15 - description = "Windows/Linux only. If restic is not found in PATH, automatically downloads and updates a self-managed restic binary daily from GitHub."; 15 + description = "Windows/Linux only. If the selected backend is not found in PATH, automatically self-manages it's binary from GitHub."; 16 16 }; 17 17 notifications = lib.mkOption { 18 18 type = lib.types.str; ··· 44 44 default = name; 45 45 description = "Name for the profile."; 46 46 }; 47 + backend = lib.mkOption { 48 + type = lib.types.enum ["restic" "rustic"]; 49 + default = "restic"; 50 + description = ''"restic" or "rustic". Do not change unless you know what this does.''; 51 + }; 47 52 envFile = lib.mkOption { 48 53 type = lib.types.nullOr lib.types.str; 49 54 default = null; 50 - description = "Path to the environment file with RESTIC_REPOSITORY, RESTIC_PASSWORD, etc."; 55 + description = "Path to the environment file with RESTIC_*, RUSTIC_*, etc."; 51 56 example = "/run/secrets/restray-home-env"; 52 57 }; 53 58 rcloneConfigFile = lib.mkOption { 54 59 type = lib.types.nullOr lib.types.str; 55 60 default = null; 56 - description = "Path to an rclone config file, set as RCLONE_CONFIG for this profile. When using an rclone: repository."; 61 + description = "Path to an rclone config file, set as RCLONE_CONFIG. When using an rclone: repository."; 57 62 example = "/run/agenix/rclone-onedrive"; 58 63 }; 59 64 retryLock = lib.mkOption { 60 65 type = lib.types.str; 61 66 default = "2m"; 62 - description = "How long to retry acquiring a repository lock (e.g. \"5m\", \"30s\"). Will automatically attempt to clear stale locks after this timeout. Set to \"0\" to disable."; 67 + description = "How long to retry acquiring a repository lock (e.g. \"5m\", \"30s\"). Will automatically attempt to clear stale locks after this timeout. Set to \"0\" to disable"; 63 68 }; 64 69 preHook = lib.mkOption { 65 70 type = lib.types.str; 66 71 default = ""; 67 - description = "Shell command to run before a scheduled run. Inherits the profile's environment and restic binary. The schedule is aborted if the hook exits non-zero. Can also be run manually from the Operations menu."; 72 + description = "Shell command to run before a scheduled run. Inherits the profile's environment and backend binary."; 68 73 }; 69 74 postHook = lib.mkOption { 70 75 type = lib.types.str; 71 76 default = ""; 72 - description = "Shell command to run after a scheduled run. Inherits the profile's environment and restic binary. Can also be run manually from the Operations menu."; 77 + description = "Shell command to run after a scheduled run. Inherits the profile's environment and backend binary."; 73 78 }; 74 79 schedule = { 75 80 cron = lib.mkOption { ··· 105 110 description = "Paths to back up, e.g. [\"/Users/user/Documents\" \"/home/user/Projects\"]."; 106 111 }; 107 112 args = lib.mkOption { 108 - type = lib.types.listOf lib.types.str; 109 - default = ["--exclude-caches" "--exclude" "*.tmp"]; 110 - description = "Extra arguments passed to restic backup."; 113 + type = lib.types.nullOr (lib.types.listOf lib.types.str); 114 + default = null; 115 + description = "Extra arguments passed to `backup`."; 111 116 }; 112 117 argsScheduled = lib.mkOption { 113 118 type = lib.types.listOf lib.types.str; 114 119 default = ["--tag" "scheduled"]; 115 - description = "Extra arguments appended (not replaced) when running from schedule."; 120 + description = "Extra arguments appended (not replaced) to `backup` when running from schedule."; 116 121 }; 117 122 }; 118 123 prune = { ··· 130 135 "--keep-monthly" 131 136 "12" 132 137 ]; 133 - description = "Extra arguments passed to restic forget --prune."; 138 + description = "Extra arguments passed to `forget --prune`."; 134 139 }; 135 140 }; 136 141 check = { 137 142 args = lib.mkOption { 138 143 type = lib.types.listOf lib.types.str; 139 144 default = []; 140 - description = "Extra arguments passed to restic check (e.g. [\"--read-data\"])."; 145 + description = "Extra arguments passed to `check`."; 141 146 }; 142 147 }; 143 148 mount = { 144 149 args = lib.mkOption { 145 150 type = lib.types.listOf lib.types.str; 146 151 default = []; 147 - description = "Extra arguments passed to restic mount (e.g. [\"--allow-other\"])."; 152 + description = "Extra arguments passed to `mount`."; 148 153 }; 149 154 }; 150 155 }; ··· 152 157 153 158 profileToToml = name: prof: 154 159 lib.filterAttrsRecursive (_: v: v != null && v != [] && v != "") { 155 - inherit (prof) name; 160 + inherit (prof) name backend; 156 161 env_file = prof.envFile; 157 162 rclone_config_file = prof.rcloneConfigFile; 158 163 retry_lock = prof.retryLock; ··· 178 183 guiToToml = gui: 179 184 lib.filterAttrsRecursive (_: v: v != null && v != [] && v != "") { 180 185 check_updates = gui.checkUpdates; 181 - manage_restic = gui.manageRestic; 186 + manage_backend = gui.manageBackend; 182 187 notifications = gui.notifications; 183 188 icon = gui.icon; 184 189 schedule_display = gui.scheduleDisplay;
+2 -2
packaging/darwin/Info.plist
··· 9 9 <key>CFBundleIdentifier</key> 10 10 <string>page.devins.restray</string> 11 11 <key>CFBundleVersion</key> 12 - <string>0.20.6</string> 12 + <string>0.21.0</string> 13 13 <key>CFBundleShortVersionString</key> 14 - <string>0.20.6</string> 14 + <string>0.21.0</string> 15 15 <key>CFBundlePackageType</key> 16 16 <string>APPL</string> 17 17 <key>CFBundleInfoDictionaryVersion</key>
+1 -1
packaging/windows/installer.nsi
··· 1 1 !include "MUI2.nsh" 2 2 3 - !define VERSION "0.20.6" 3 + !define VERSION "0.21.0" 4 4 5 5 Name "Restray" 6 6 OutFile "..\..\dist\restray-${VERSION}-windows-${ARCH}-setup.exe"