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: redesign logic

hooks now dont automatically run with operations, only schedule. also moved backup to the new operations submenu, and made a separate "run schedule now" outside the operations submenu that also runs the prune/check and hooks if enabled. this was previously "run backup now".

this is to prepare for the cli interface im considering implementing, but i also just think this is far cleaner :)

intergrav (Jun 29, 2026, 11:26 AM EDT) bcfa08d6 8b340dbc

+105 -49
+16 -16
README.md
··· 15 15 ## Features 16 16 17 17 - Initialize a repository if one doesn't already exist 18 - - Run backups on a schedule with cron expressions, or just manually 18 + - Run backups on a schedule with cron expressions, or run the full schedule manually 19 19 - Can detect battery power too and temporarily disable schedule 20 20 - Multiple backup profiles with different schedules and different settings 21 21 - Optional automatic pruning and checks after backups 22 + - Operations menu for running individual operations (backup, prune, check, unlock, mount, shell, hooks) 22 23 - Optional automatic retry and removal of stale locks 23 24 - On Windows it can also download and update it's own self-managed restic if not found in PATH 24 25 - Open a shell with your profile's environment and restic binary loaded, for restoring or other CLI operations without friction ··· 54 55 55 56 You can create multiple of these sections for separate backup targets. Each profile has its own schedule, repository, environment, etc. 56 57 57 - | Key | Type | Default | Description | 58 - | ------------ | ------ | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 59 - | `name` | string | `"default"` | Name for the profile | 60 - | `env_file` | string | `<config_dir>/<name>.env` | Path to the environment file with `RESTIC_REPOSITORY`, `RESTIC_PASSWORD`, etc. | 61 - | `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 | 62 - | `pre_hook` | string | `""` | Shell command to run before any repository operation. Inherits the profile's environment and restic binary. Operation is aborted if the hook exits non-zero. For scheduled runs, runs before the sequence | 63 - | `post_hook` | string | `""` | Shell command to run after any repository operation. Inherits the profile's environment and restic binary. For scheduled runs, runs after the sequence | 58 + | Key | Type | Default | Description | 59 + | ------------ | ------ | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 60 + | `name` | string | `"default"` | Name for the profile | 61 + | `env_file` | string | `<config_dir>/<name>.env` | Path to the environment file with `RESTIC_REPOSITORY`, `RESTIC_PASSWORD`, etc. | 62 + | `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 | 63 + | `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 | 64 + | `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 | 64 65 65 - Hooks receive the following environment variables: 66 + Hooks receive the following environment variables when run as part of a schedule: 66 67 67 - | Variable | Description | 68 - | -------------------- | -------------------------------------------------------------------------------------------- | 69 - | `RESTRAY_PROFILE` | Name of the active profile | 70 - | `RESTRAY_OPERATIONS` | Comma-separated list of operations that ran (e.g. `backup`, `backup,forget,check`, `unlock`) | 71 - | `RESTRAY_SCHEDULED` | `true` if triggered by schedule, `false` if manual | 72 - | `RESTRAY_STATUS` | `success` or `failure` (post-hook only) | 73 - | `RESTRAY_ERROR` | Error message when `RESTRAY_STATUS=failure` (post-hook only) | 68 + | Variable | Description | 69 + | -------------------- | -------------------------------------------------------------------------------------- | 70 + | `RESTRAY_PROFILE` | Name of the active profile (always set, including manual runs) | 71 + | `RESTRAY_OPERATIONS` | Comma-separated list of operations enabled in the schedule (e.g. `backup,prune,check`) | 72 + | `RESTRAY_SCHEDULED` | `true` | 73 + | `RESTRAY_ERROR` | Error message if the schedule failed; unset on success (post-hook only) | 74 74 75 75 #### `[profile.schedule]` 76 76
+25 -21
cmd/restray/operations.go
··· 203 203 } 204 204 205 205 func runWithHooks(idx int, mStatus prefixedMenuItem, prof Profile, onDone func(), 206 - label, opsEnv string, scheduled bool, op func() (bool, string)) { 206 + label, opsEnv string, op func() (bool, string)) { 207 207 if !acquireProfile(idx) { 208 208 return 209 209 } ··· 211 211 defer onDone() 212 212 defer setProfileBusy(idx, false) 213 213 214 - scheduledEnv := "false" 215 - if scheduled { 216 - scheduledEnv = "true" 217 - } 218 - hookEnv := []string{"RESTRAY_OPERATIONS=" + opsEnv, "RESTRAY_SCHEDULED=" + scheduledEnv} 214 + hookEnv := []string{"RESTRAY_OPERATIONS=" + opsEnv, "RESTRAY_SCHEDULED=true"} 219 215 220 216 if prof.PreHook != "" { 221 217 mStatus.SetTitle("Running pre-hook...") ··· 240 236 241 237 if prof.PostHook != "" { 242 238 mStatus.SetTitle("Running post-hook...") 243 - var hookErr error 244 - if ok { 245 - hookErr = runHook(prof.PostHook, prof, append(hookEnv, "RESTRAY_STATUS=success")...) 246 - } else { 247 - hookErr = runHook(prof.PostHook, prof, append(hookEnv, "RESTRAY_STATUS=failure", "RESTRAY_ERROR="+getProfileFailStatus(idx))...) 239 + postEnv := hookEnv 240 + if !ok { 241 + postEnv = append(postEnv, "RESTRAY_ERROR="+getProfileFailStatus(idx)) 248 242 } 249 - if hookErr != nil { 243 + if err := runHook(prof.PostHook, prof, postEnv...); err != nil { 250 244 setProfileFailed(idx, "Post-hook failed") 251 245 notifyError(label, "Post-hook failed") 252 246 } ··· 257 251 if p, _ := findRestic(); p == "" { 258 252 return 259 253 } 260 - runWithHooks(idx, mStatus, prof, onDone, "Backup", "backup", false, func() (bool, string) { 261 - mStatus.SetTitle("Backing up...") 262 - ok, code := doBackup(idx, mStatus, prof, false) 263 - if ok && code == 3 { 264 - return true, "Completed with warnings: some files could not be read" 254 + if !acquireProfile(idx) { 255 + return 256 + } 257 + setProfileFailed(idx, "") 258 + defer onDone() 259 + defer setProfileBusy(idx, false) 260 + 261 + mStatus.SetTitle("Backing up...") 262 + ok, code := doBackup(idx, mStatus, prof, false) 263 + if ok { 264 + if code == 3 { 265 + notifyError("Backup", "Completed with warnings: some files could not be read") 266 + } else { 267 + notifySuccess("Backup") 265 268 } 266 - return ok, "" 267 - }) 269 + } else { 270 + notifyError("Backup", getProfileFailStatus(idx)) 271 + } 268 272 } 269 273 270 274 func runHook(hook string, prof Profile, extraEnv ...string) error { ··· 314 318 ops = append(ops, "backup") 315 319 } 316 320 if prof.Schedule.Prune && len(prof.Prune.Args) > 0 { 317 - ops = append(ops, "forget") 321 + ops = append(ops, "prune") 318 322 } 319 323 if prof.Schedule.Check { 320 324 ops = append(ops, "check") 321 325 } 322 326 323 - runWithHooks(idx, mStatus, prof, onDone, "Schedule", strings.Join(ops, ","), true, func() (bool, string) { 327 + runWithHooks(idx, mStatus, prof, onDone, "Schedule", strings.Join(ops, ","), func() (bool, string) { 324 328 if prof.Schedule.BackupEnabled() { 325 329 mStatus.SetTitle("Backing up...") 326 330 if ok, _ := doBackup(idx, mStatus, prof, true); !ok {
+64 -12
cmd/restray/tray.go
··· 167 167 mDownload.Hide() 168 168 mUpdate := systray.AddMenuItem("", "Update restic binary") 169 169 mUpdate.Hide() 170 - mBackup := systray.AddMenuItem("Run Backup Now", "Run a backup immediately") 171 - mBackup.Hide() 170 + mSchedule := systray.AddMenuItem("Run Schedule Now", "Run the full schedule immediately") 171 + mSchedule.Hide() 172 172 mCancel := systray.AddMenuItem("Cancel Operation", "Cancel running operation") 173 173 mCancel.Hide() 174 174 mInit := systray.AddMenuItem("Initialize Repository", "Initialize a new restic repository") 175 175 mInit.Hide() 176 - mRepo := systray.AddMenuItem("Repository", "Repository operations") 176 + mRepo := systray.AddMenuItem("Operations", "Repository operations") 177 + mBackup := mRepo.AddSubMenuItem("Backup", "Run a backup") 177 178 mPrune := mRepo.AddSubMenuItem("Prune", "Remove old snapshots and free space") 178 179 mCheck := mRepo.AddSubMenuItem("Check", "Verify repository integrity") 179 180 mUnlock := mRepo.AddSubMenuItem("Unlock", "Remove stale repository locks") 180 181 mMount := mRepo.AddSubMenuItem("Mount", "Mount repository and browse snapshots") 181 182 mConsole := mRepo.AddSubMenuItem("Shell", "Open terminal with repository environment") 183 + mPreHook := mRepo.AddSubMenuItem("Pre-Hook", "Run the pre-hook command") 184 + mPreHook.Disable() 185 + mPostHook := mRepo.AddSubMenuItem("Post-Hook", "Run the post-hook command") 186 + mPostHook.Disable() 182 187 systray.AddSeparator() 183 188 mConfigure := systray.AddMenuItem("Configure", "Restray settings") 184 189 mFDA := mConfigure.AddSubMenuItem("Grant Full Disk Access", "Open System Settings to grant Full Disk Access") ··· 200 205 state.onBusyChanged = func(busy bool) { 201 206 if busy { 202 207 mCancel.Show() 203 - mBackup.Hide() 208 + mSchedule.Hide() 209 + mBackup.Disable() 204 210 mPrune.Disable() 205 211 mCheck.Disable() 206 212 mUnlock.Disable() 213 + mPreHook.Disable() 214 + mPostHook.Disable() 207 215 } 208 216 } 209 217 ··· 216 224 } 217 225 218 226 hideActions := func() { 219 - mBackup.Hide() 227 + mSchedule.Hide() 220 228 mInit.Hide() 221 229 mDownload.Hide() 222 230 mUpdate.Hide() ··· 350 358 mFixPerms.Hide() 351 359 } 352 360 353 - mBackup.Hide() 361 + mSchedule.Hide() 354 362 mInit.Hide() 355 363 mRepo.Disable() 364 + mPreHook.Disable() 365 + mPostHook.Disable() 356 366 357 367 if runtime.GOOS == "windows" { 358 368 mMount.Hide() ··· 365 375 if isProfileBusy(idx) { 366 376 mCancel.Show() 367 377 mRepo.Enable() 378 + mBackup.Disable() 368 379 mPrune.Disable() 369 380 mCheck.Disable() 370 381 mUnlock.Disable() ··· 378 389 } 379 390 default: 380 391 mRepo.Enable() 392 + mBackup.Enable() 381 393 mPrune.Enable() 382 394 mCheck.Enable() 383 395 mUnlock.Enable() 384 - mBackup.Show() 385 - mBackup.Enable() 396 + mSchedule.Show() 397 + mSchedule.Enable() 398 + if prof.PreHook != "" { 399 + mPreHook.Enable() 400 + } 401 + if prof.PostHook != "" { 402 + mPostHook.Enable() 403 + } 386 404 } 387 405 } 388 406 ··· 585 603 } 586 604 prof := selectedProfile() 587 605 ms := statusItem(idx) 588 - go runWithHooks(idx, ms, prof, applyConfig, args[0], args[0], false, func() (bool, string) { 606 + go func() { 607 + if !acquireProfile(idx) { 608 + return 609 + } 610 + setProfileFailed(idx, "") 611 + defer applyConfig() 612 + defer setProfileBusy(idx, false) 589 613 ms.SetTitle(status) 590 614 if msg, err := runRestic(idx, prof, ms, args...); err != nil { 591 615 setProfileFailed(idx, msg) 592 - return false, "" 616 + notifyError(args[0], msg) 617 + } else { 618 + notifySuccess(args[0]) 593 619 } 594 - return true, "" 595 - }) 620 + }() 596 621 } 597 622 598 623 go func() { 599 624 for { 600 625 select { 626 + case <-mSchedule.ClickedCh: 627 + idx := getActiveProfile() 628 + if idx < maxProfiles && !isProfileBusy(idx) { 629 + go runScheduled(idx, statusItem(idx), selectedProfile(), applyConfig) 630 + } 601 631 case <-mBackup.ClickedCh: 602 632 idx := getActiveProfile() 603 633 if idx < maxProfiles && !isProfileBusy(idx) { ··· 626 656 case <-mConsole.ClickedCh: 627 657 cfg := loadConfig() 628 658 openConsole(selectedProfile(), len(cfg.Profile), cfg.General.Terminal, "") 659 + case <-mPreHook.ClickedCh: 660 + prof := selectedProfile() 661 + if prof.PreHook != "" { 662 + go func() { 663 + if err := runHook(prof.PreHook, prof); err != nil { 664 + notifyError("Pre-hook", "Pre-hook failed") 665 + } else { 666 + notifySuccess("Pre-hook") 667 + } 668 + }() 669 + } 670 + case <-mPostHook.ClickedCh: 671 + prof := selectedProfile() 672 + if prof.PostHook != "" { 673 + go func() { 674 + if err := runHook(prof.PostHook, prof); err != nil { 675 + notifyError("Post-hook", "Post-hook failed") 676 + } else { 677 + notifySuccess("Post-hook") 678 + } 679 + }() 680 + } 629 681 case <-mDownload.ClickedCh: 630 682 if !selfManagesRestic { 631 683 openFile("https://restic.readthedocs.io/en/stable/020_installation.html")