From bcfa08d67d283e06cbe2ffd69ebfcf861a2f4554 Mon Sep 17 00:00:00 2001 From: intergrav Date: Mon, 29 Jun 2026 11:26:37 -0400 Subject: [PATCH] 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 :) --- README.md | 36 +++++++++---------- cmd/restray/operations.go | 46 +++++++++++++----------- cmd/restray/tray.go | 76 ++++++++++++++++++++++++++++++++------- 3 files changed, 107 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 61ae587..53364f3 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,11 @@ This is new and somewhat work-in-progress/beta software. You might possibly enco ## Features - Initialize a repository if one doesn't already exist -- Run backups on a schedule with cron expressions, or just manually +- Run backups on a schedule with cron expressions, or run the full schedule manually - Can detect battery power too and temporarily disable schedule - Multiple backup profiles with different schedules and different settings - Optional automatic pruning and checks after backups +- Operations menu for running individual operations (backup, prune, check, unlock, mount, shell, hooks) - Optional automatic retry and removal of stale locks - On Windows it can also download and update it's own self-managed restic if not found in PATH - Open a shell with your profile's environment and restic binary loaded, for restoring or other CLI operations without friction @@ -54,23 +55,22 @@ On [Nix(OS)](https://nixos.org)/nix-darwin, run `nix build` (or add this repo to You can create multiple of these sections for separate backup targets. Each profile has its own schedule, repository, environment, etc. -| Key | Type | Default | Description | -| ------------ | ------ | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `name` | string | `"default"` | Name for the profile | -| `env_file` | string | `/.env` | Path to the environment file with `RESTIC_REPOSITORY`, `RESTIC_PASSWORD`, etc. | -| `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 | -| `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 | -| `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 | - -Hooks receive the following environment variables: - -| Variable | Description | -| -------------------- | -------------------------------------------------------------------------------------------- | -| `RESTRAY_PROFILE` | Name of the active profile | -| `RESTRAY_OPERATIONS` | Comma-separated list of operations that ran (e.g. `backup`, `backup,forget,check`, `unlock`) | -| `RESTRAY_SCHEDULED` | `true` if triggered by schedule, `false` if manual | -| `RESTRAY_STATUS` | `success` or `failure` (post-hook only) | -| `RESTRAY_ERROR` | Error message when `RESTRAY_STATUS=failure` (post-hook only) | +| Key | Type | Default | Description | +| ------------ | ------ | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | string | `"default"` | Name for the profile | +| `env_file` | string | `/.env` | Path to the environment file with `RESTIC_REPOSITORY`, `RESTIC_PASSWORD`, etc. | +| `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 | +| `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 | +| `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 | + +Hooks receive the following environment variables when run as part of a schedule: + +| Variable | Description | +| -------------------- | -------------------------------------------------------------------------------------- | +| `RESTRAY_PROFILE` | Name of the active profile (always set, including manual runs) | +| `RESTRAY_OPERATIONS` | Comma-separated list of operations enabled in the schedule (e.g. `backup,prune,check`) | +| `RESTRAY_SCHEDULED` | `true` | +| `RESTRAY_ERROR` | Error message if the schedule failed; unset on success (post-hook only) | #### `[profile.schedule]` diff --git a/cmd/restray/operations.go b/cmd/restray/operations.go index b849dc0..aa180ca 100644 --- a/cmd/restray/operations.go +++ b/cmd/restray/operations.go @@ -203,7 +203,7 @@ func doBackup(idx int, mStatus prefixedMenuItem, prof Profile, scheduled bool) ( } func runWithHooks(idx int, mStatus prefixedMenuItem, prof Profile, onDone func(), - label, opsEnv string, scheduled bool, op func() (bool, string)) { + label, opsEnv string, op func() (bool, string)) { if !acquireProfile(idx) { return } @@ -211,11 +211,7 @@ func runWithHooks(idx int, mStatus prefixedMenuItem, prof Profile, onDone func() defer onDone() defer setProfileBusy(idx, false) - scheduledEnv := "false" - if scheduled { - scheduledEnv = "true" - } - hookEnv := []string{"RESTRAY_OPERATIONS=" + opsEnv, "RESTRAY_SCHEDULED=" + scheduledEnv} + hookEnv := []string{"RESTRAY_OPERATIONS=" + opsEnv, "RESTRAY_SCHEDULED=true"} if prof.PreHook != "" { mStatus.SetTitle("Running pre-hook...") @@ -240,13 +236,11 @@ func runWithHooks(idx int, mStatus prefixedMenuItem, prof Profile, onDone func() if prof.PostHook != "" { mStatus.SetTitle("Running post-hook...") - var hookErr error - if ok { - hookErr = runHook(prof.PostHook, prof, append(hookEnv, "RESTRAY_STATUS=success")...) - } else { - hookErr = runHook(prof.PostHook, prof, append(hookEnv, "RESTRAY_STATUS=failure", "RESTRAY_ERROR="+getProfileFailStatus(idx))...) + postEnv := hookEnv + if !ok { + postEnv = append(postEnv, "RESTRAY_ERROR="+getProfileFailStatus(idx)) } - if hookErr != nil { + if err := runHook(prof.PostHook, prof, postEnv...); err != nil { setProfileFailed(idx, "Post-hook failed") notifyError(label, "Post-hook failed") } @@ -257,14 +251,24 @@ func runBackup(idx int, mStatus prefixedMenuItem, prof Profile, onDone func()) { if p, _ := findRestic(); p == "" { return } - runWithHooks(idx, mStatus, prof, onDone, "Backup", "backup", false, func() (bool, string) { - mStatus.SetTitle("Backing up...") - ok, code := doBackup(idx, mStatus, prof, false) - if ok && code == 3 { - return true, "Completed with warnings: some files could not be read" + if !acquireProfile(idx) { + return + } + setProfileFailed(idx, "") + defer onDone() + defer setProfileBusy(idx, false) + + mStatus.SetTitle("Backing up...") + ok, code := doBackup(idx, mStatus, prof, false) + if ok { + if code == 3 { + notifyError("Backup", "Completed with warnings: some files could not be read") + } else { + notifySuccess("Backup") } - return ok, "" - }) + } else { + notifyError("Backup", getProfileFailStatus(idx)) + } } func runHook(hook string, prof Profile, extraEnv ...string) error { @@ -314,13 +318,13 @@ func runScheduled(idx int, mStatus prefixedMenuItem, prof Profile, onDone func() ops = append(ops, "backup") } if prof.Schedule.Prune && len(prof.Prune.Args) > 0 { - ops = append(ops, "forget") + ops = append(ops, "prune") } if prof.Schedule.Check { ops = append(ops, "check") } - runWithHooks(idx, mStatus, prof, onDone, "Schedule", strings.Join(ops, ","), true, func() (bool, string) { + runWithHooks(idx, mStatus, prof, onDone, "Schedule", strings.Join(ops, ","), func() (bool, string) { if prof.Schedule.BackupEnabled() { mStatus.SetTitle("Backing up...") if ok, _ := doBackup(idx, mStatus, prof, true); !ok { diff --git a/cmd/restray/tray.go b/cmd/restray/tray.go index 944e7f6..4997670 100644 --- a/cmd/restray/tray.go +++ b/cmd/restray/tray.go @@ -167,18 +167,23 @@ func onReady() { mDownload.Hide() mUpdate := systray.AddMenuItem("", "Update restic binary") mUpdate.Hide() - mBackup := systray.AddMenuItem("Run Backup Now", "Run a backup immediately") - mBackup.Hide() + mSchedule := systray.AddMenuItem("Run Schedule Now", "Run the full schedule immediately") + mSchedule.Hide() mCancel := systray.AddMenuItem("Cancel Operation", "Cancel running operation") mCancel.Hide() mInit := systray.AddMenuItem("Initialize Repository", "Initialize a new restic repository") mInit.Hide() - mRepo := systray.AddMenuItem("Repository", "Repository operations") + mRepo := systray.AddMenuItem("Operations", "Repository operations") + mBackup := mRepo.AddSubMenuItem("Backup", "Run a backup") mPrune := mRepo.AddSubMenuItem("Prune", "Remove old snapshots and free space") mCheck := mRepo.AddSubMenuItem("Check", "Verify repository integrity") mUnlock := mRepo.AddSubMenuItem("Unlock", "Remove stale repository locks") mMount := mRepo.AddSubMenuItem("Mount", "Mount repository and browse snapshots") mConsole := mRepo.AddSubMenuItem("Shell", "Open terminal with repository environment") + mPreHook := mRepo.AddSubMenuItem("Pre-Hook", "Run the pre-hook command") + mPreHook.Disable() + mPostHook := mRepo.AddSubMenuItem("Post-Hook", "Run the post-hook command") + mPostHook.Disable() systray.AddSeparator() mConfigure := systray.AddMenuItem("Configure", "Restray settings") mFDA := mConfigure.AddSubMenuItem("Grant Full Disk Access", "Open System Settings to grant Full Disk Access") @@ -200,10 +205,13 @@ func onReady() { state.onBusyChanged = func(busy bool) { if busy { mCancel.Show() - mBackup.Hide() + mSchedule.Hide() + mBackup.Disable() mPrune.Disable() mCheck.Disable() mUnlock.Disable() + mPreHook.Disable() + mPostHook.Disable() } } @@ -216,7 +224,7 @@ func onReady() { } hideActions := func() { - mBackup.Hide() + mSchedule.Hide() mInit.Hide() mDownload.Hide() mUpdate.Hide() @@ -350,9 +358,11 @@ func onReady() { mFixPerms.Hide() } - mBackup.Hide() + mSchedule.Hide() mInit.Hide() mRepo.Disable() + mPreHook.Disable() + mPostHook.Disable() if runtime.GOOS == "windows" { mMount.Hide() @@ -365,6 +375,7 @@ func onReady() { if isProfileBusy(idx) { mCancel.Show() mRepo.Enable() + mBackup.Disable() mPrune.Disable() mCheck.Disable() mUnlock.Disable() @@ -378,11 +389,18 @@ func onReady() { } default: mRepo.Enable() + mBackup.Enable() mPrune.Enable() mCheck.Enable() mUnlock.Enable() - mBackup.Show() - mBackup.Enable() + mSchedule.Show() + mSchedule.Enable() + if prof.PreHook != "" { + mPreHook.Enable() + } + if prof.PostHook != "" { + mPostHook.Enable() + } } } @@ -585,19 +603,31 @@ func onReady() { } prof := selectedProfile() ms := statusItem(idx) - go runWithHooks(idx, ms, prof, applyConfig, args[0], args[0], false, func() (bool, string) { + go func() { + if !acquireProfile(idx) { + return + } + setProfileFailed(idx, "") + defer applyConfig() + defer setProfileBusy(idx, false) ms.SetTitle(status) if msg, err := runRestic(idx, prof, ms, args...); err != nil { setProfileFailed(idx, msg) - return false, "" + notifyError(args[0], msg) + } else { + notifySuccess(args[0]) } - return true, "" - }) + }() } go func() { for { select { + case <-mSchedule.ClickedCh: + idx := getActiveProfile() + if idx < maxProfiles && !isProfileBusy(idx) { + go runScheduled(idx, statusItem(idx), selectedProfile(), applyConfig) + } case <-mBackup.ClickedCh: idx := getActiveProfile() if idx < maxProfiles && !isProfileBusy(idx) { @@ -626,6 +656,28 @@ func onReady() { case <-mConsole.ClickedCh: cfg := loadConfig() openConsole(selectedProfile(), len(cfg.Profile), cfg.General.Terminal, "") + case <-mPreHook.ClickedCh: + prof := selectedProfile() + if prof.PreHook != "" { + go func() { + if err := runHook(prof.PreHook, prof); err != nil { + notifyError("Pre-hook", "Pre-hook failed") + } else { + notifySuccess("Pre-hook") + } + }() + } + case <-mPostHook.ClickedCh: + prof := selectedProfile() + if prof.PostHook != "" { + go func() { + if err := runHook(prof.PostHook, prof); err != nil { + notifyError("Post-hook", "Post-hook failed") + } else { + notifySuccess("Post-hook") + } + }() + } case <-mDownload.ClickedCh: if !selfManagesRestic { openFile("https://restic.readthedocs.io/en/stable/020_installation.html") -- 2.51.2