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: hooks

intergrav (Jun 28, 2026, 8:44 AM EDT) 92ba5c03 7cc5230a

+138 -10
+17 -5
README.md
··· 57 57 58 58 You can create multiple of these sections for separate backup targets. Each profile has its own schedule, repository, environment, etc. 59 59 60 - | Key | Type | Default | Description | 61 - | ------------ | ------ | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | 62 - | `name` | string | `"default"` | Name for the profile | 63 - | `env_file` | string | `<config_dir>/<name>.env` | Path to the environment file with `RESTIC_REPOSITORY`, `RESTIC_PASSWORD`, etc. | 64 - | `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 | 60 + | Key | Type | Default | Description | 61 + | ------------ | ------ | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 62 + | `name` | string | `"default"` | Name for the profile | 63 + | `env_file` | string | `<config_dir>/<name>.env` | Path to the environment file with `RESTIC_REPOSITORY`, `RESTIC_PASSWORD`, etc. | 64 + | `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 | 65 + | `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 | 66 + | `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 | 67 + 68 + Hooks receive the following environment variables: 69 + 70 + | Variable | Description | 71 + | -------------------- | -------------------------------------------------------------------------------------------- | 72 + | `RESTRAY_PROFILE` | Name of the active profile | 73 + | `RESTRAY_OPERATIONS` | Comma-separated list of operations that ran (e.g. `backup`, `backup,forget,check`, `unlock`) | 74 + | `RESTRAY_SCHEDULED` | `true` if triggered by schedule, `false` if manual | 75 + | `RESTRAY_STATUS` | `success` or `failure` (post-hook only) | 76 + | `RESTRAY_ERROR` | Error message when `RESTRAY_STATUS=failure` (post-hook only) | 65 77 66 78 #### `[profile.schedule]` 67 79
+2
config.go
··· 56 56 Name string `toml:"name"` 57 57 EnvFile string `toml:"env_file"` 58 58 RetryLock string `toml:"retry_lock"` 59 + PreHook string `toml:"pre_hook"` 60 + PostHook string `toml:"post_hook"` 59 61 Schedule Schedule `toml:"schedule"` 60 62 Backup Backup `toml:"backup"` 61 63 Prune Prune `toml:"prune"`
+94 -2
restic.go
··· 574 574 } 575 575 576 576 setProfileFailed(idx, "") 577 - mStatus.SetTitle("Backing up...") 578 577 setProfileBusy(idx, true) 579 578 defer onDone() 580 579 defer setProfileBusy(idx, false) 581 580 582 - doBackup(idx, mStatus, prof, false) 581 + hookEnv := []string{"RESTRAY_OPERATIONS=backup", "RESTRAY_SCHEDULED=false"} 582 + 583 + if prof.PreHook != "" { 584 + mStatus.SetTitle("Running pre-hook...") 585 + if err := runHook(prof.PreHook, prof, hookEnv...); err != nil { 586 + setProfileFailed(idx, "Pre-hook failed") 587 + notifyError("Backup", "Pre-hook failed") 588 + return 589 + } 590 + } 591 + 592 + mStatus.SetTitle("Backing up...") 593 + ok := doBackup(idx, mStatus, prof, false) 594 + 595 + if prof.PostHook != "" { 596 + mStatus.SetTitle("Running post-hook...") 597 + if ok { 598 + runHook(prof.PostHook, prof, append(hookEnv, "RESTRAY_STATUS=success")...) 599 + } else { 600 + runHook(prof.PostHook, prof, append(hookEnv, "RESTRAY_STATUS=failure", "RESTRAY_ERROR="+getProfileFailStatus(idx))...) 601 + } 602 + } 603 + } 604 + 605 + func runHook(hook string, prof Profile, extraEnv ...string) error { 606 + if hook == "" { 607 + return nil 608 + } 609 + var cmd *exec.Cmd 610 + if runtime.GOOS == "windows" { 611 + cmd = exec.Command("cmd", "/c", hook) 612 + } else { 613 + cmd = exec.Command("sh", "-c", hook) 614 + } 615 + vars, err := parseEnvFile(prof.EnvFile) 616 + if err != nil { 617 + vars = map[string]string{} 618 + } 619 + cmd.Env = os.Environ() 620 + for k, v := range vars { 621 + cmd.Env = append(cmd.Env, k+"="+v) 622 + } 623 + if p := localResticPath(); fileExists(p) { 624 + dir := filepath.Dir(p) 625 + for i, e := range cmd.Env { 626 + if strings.HasPrefix(e, "PATH=") { 627 + cmd.Env[i] = "PATH=" + dir + string(os.PathListSeparator) + e[5:] 628 + break 629 + } 630 + } 631 + } 632 + cmd.Env = append(cmd.Env, extraEnv...) 633 + cmd.Env = append(cmd.Env, "RESTRAY_PROFILE="+prof.displayName()) 634 + log.Printf("[%s] running hook: %s", prof.displayName(), hook) 635 + out, err := cmd.CombinedOutput() 636 + if err != nil { 637 + log.Printf("[%s] hook %q failed: %v: %s", prof.displayName(), hook, err, out) 638 + } 639 + return err 583 640 } 584 641 585 642 func runScheduled(idx int, mStatus prefixedMenuItem, prof Profile, onDone func()) { ··· 595 652 defer onDone() 596 653 defer setProfileBusy(idx, false) 597 654 655 + var ops []string 656 + if prof.Schedule.BackupEnabled() { 657 + ops = append(ops, "backup") 658 + } 659 + if prof.Schedule.Prune && len(prof.Prune.Args) > 0 { 660 + ops = append(ops, "forget") 661 + } 662 + if prof.Schedule.Check { 663 + ops = append(ops, "check") 664 + } 665 + 666 + hookEnv := []string{"RESTRAY_SCHEDULED=true", "RESTRAY_OPERATIONS=" + strings.Join(ops, ",")} 667 + 668 + if prof.PreHook != "" { 669 + mStatus.SetTitle("Running pre-hook...") 670 + if err := runHook(prof.PreHook, prof, hookEnv...); err != nil { 671 + setProfileFailed(idx, "Pre-hook failed") 672 + notifyError("Schedule", "Pre-hook failed") 673 + return 674 + } 675 + } 676 + 598 677 failed := false 599 678 600 679 if prof.Schedule.BackupEnabled() { 601 680 mStatus.SetTitle("Backing up...") 602 681 if !doBackup(idx, mStatus, prof, true) { 603 682 notifyError("Schedule", getProfileFailStatus(idx)) 683 + if prof.PostHook != "" { 684 + mStatus.SetTitle("Running post-hook...") 685 + runHook(prof.PostHook, prof, append(hookEnv, "RESTRAY_STATUS=failure", "RESTRAY_ERROR="+getProfileFailStatus(idx))...) 686 + } 604 687 return 605 688 } 606 689 } ··· 624 707 notifyError("Schedule", getProfileFailStatus(idx)) 625 708 } else { 626 709 notifySuccess("Schedule") 710 + } 711 + 712 + if prof.PostHook != "" { 713 + mStatus.SetTitle("Running post-hook...") 714 + if failed { 715 + runHook(prof.PostHook, prof, append(hookEnv, "RESTRAY_STATUS=failure", "RESTRAY_ERROR="+getProfileFailStatus(idx))...) 716 + } else { 717 + runHook(prof.PostHook, prof, append(hookEnv, "RESTRAY_STATUS=success")...) 718 + } 627 719 } 628 720 } 629 721
+2
templates/config.toml
··· 8 8 [[profile]] 9 9 name = "default" 10 10 # env_file = "{{ENV_FILE}}" 11 + # pre_hook = "" 12 + # post_hook = "" 11 13 12 14 [profile.schedule] 13 15 # cron = ""
+23 -3
tray.go
··· 546 546 prof := selectedProfile() 547 547 ms := statusItem(idx) 548 548 go func() { 549 - ms.SetTitle(status) 550 549 setProfileBusy(idx, true) 550 + defer setProfileBusy(idx, false) 551 + defer applyConfig() 552 + 553 + hookEnv := []string{"RESTRAY_OPERATIONS=" + args[0], "RESTRAY_SCHEDULED=false"} 554 + 555 + if prof.PreHook != "" { 556 + ms.SetTitle("Running pre-hook...") 557 + if err := runHook(prof.PreHook, prof, hookEnv...); err != nil { 558 + setProfileFailed(idx, "Pre-hook failed") 559 + notifyError(args[0], "Pre-hook failed") 560 + return 561 + } 562 + } 563 + 564 + ms.SetTitle(status) 551 565 msg, err := runRestic(idx, prof, ms, args...) 552 - setProfileBusy(idx, false) 553 566 if err != nil { 554 567 setProfileFailed(idx, msg) 555 568 notifyError(args[0], msg) ··· 557 570 setProfileFailed(idx, "") 558 571 notifySuccess(args[0]) 559 572 } 560 - applyConfig() 573 + if prof.PostHook != "" { 574 + ms.SetTitle("Running post-hook...") 575 + if err != nil { 576 + runHook(prof.PostHook, prof, append(hookEnv, "RESTRAY_STATUS=failure", "RESTRAY_ERROR="+msg)...) 577 + } else { 578 + runHook(prof.PostHook, prof, append(hookEnv, "RESTRAY_STATUS=success")...) 579 + } 580 + } 561 581 }() 562 582 } 563 583