diff --git a/cmd/backrest/backrest.go b/cmd/backrest/backrest.go index dd78c07f..dd595129 100644 --- a/cmd/backrest/backrest.go +++ b/cmd/backrest/backrest.go @@ -49,6 +49,11 @@ var ( commit = "unknown" ) +// onOpLogReady, when set, is invoked with the OpLog once it is constructed. +// The tray build (-tags tray) uses it to drive a status-reflecting menu bar +// icon. It stays nil in non-tray builds, so this adds no behavior there. +var onOpLogReady func(*oplog.OpLog) + func runApp() { flag.Parse() if *printVersion { @@ -86,6 +91,13 @@ func runApp() { } defer opLogStore.Close() + // onOpLogReady lets the system tray (when built with -tags tray) observe + // operation status in-process to reflect backup state in its icon. It is + // nil in non-tray builds. + if onOpLogReady != nil { + onOpLogReady(opLog) + } + logStore, unsubscribeLogStore, err := newLogStore(opLog) if err != nil { zap.L().Fatal("error creating log store", zap.Error(err)) diff --git a/cmd/backrest/icon_error.ico b/cmd/backrest/icon_error.ico new file mode 100644 index 00000000..4685929d Binary files /dev/null and b/cmd/backrest/icon_error.ico differ diff --git a/cmd/backrest/icon_error.png b/cmd/backrest/icon_error.png new file mode 100644 index 00000000..3bc3f4b1 Binary files /dev/null and b/cmd/backrest/icon_error.png differ diff --git a/cmd/backrest/icon_ok.ico b/cmd/backrest/icon_ok.ico new file mode 100644 index 00000000..c74a7f57 Binary files /dev/null and b/cmd/backrest/icon_ok.ico differ diff --git a/cmd/backrest/icon_ok.png b/cmd/backrest/icon_ok.png new file mode 100644 index 00000000..8c046aaa Binary files /dev/null and b/cmd/backrest/icon_ok.png differ diff --git a/cmd/backrest/icon_syncing.ico b/cmd/backrest/icon_syncing.ico new file mode 100644 index 00000000..558269e8 Binary files /dev/null and b/cmd/backrest/icon_syncing.ico differ diff --git a/cmd/backrest/icon_syncing.png b/cmd/backrest/icon_syncing.png new file mode 100644 index 00000000..e226c3c9 Binary files /dev/null and b/cmd/backrest/icon_syncing.png differ diff --git a/cmd/backrest/icon_unix.go b/cmd/backrest/icon_unix.go index 112fc315..01d77484 100644 --- a/cmd/backrest/icon_unix.go +++ b/cmd/backrest/icon_unix.go @@ -6,3 +6,31 @@ import _ "embed" //go:embed icon.png var icon []byte + +//go:embed icon_ok.png +var iconOK []byte + +//go:embed icon_syncing.png +var iconSyncing []byte + +//go:embed icon_warning.png +var iconWarning []byte + +//go:embed icon_error.png +var iconError []byte + +// statusIcon returns the tray icon for the given backup state. +func statusIcon(s trayState) []byte { + switch s { + case stateRunning: + return iconSyncing + case stateOK: + return iconOK + case stateWarning: + return iconWarning + case stateError: + return iconError + default: + return icon + } +} diff --git a/cmd/backrest/icon_warning.ico b/cmd/backrest/icon_warning.ico new file mode 100644 index 00000000..bca69242 Binary files /dev/null and b/cmd/backrest/icon_warning.ico differ diff --git a/cmd/backrest/icon_warning.png b/cmd/backrest/icon_warning.png new file mode 100644 index 00000000..40ccdcae Binary files /dev/null and b/cmd/backrest/icon_warning.png differ diff --git a/cmd/backrest/icon_windows.go b/cmd/backrest/icon_windows.go index 36022c04..86a97d4e 100644 --- a/cmd/backrest/icon_windows.go +++ b/cmd/backrest/icon_windows.go @@ -6,3 +6,31 @@ import _ "embed" //go:embed icon.ico var icon []byte + +//go:embed icon_ok.ico +var iconOK []byte + +//go:embed icon_syncing.ico +var iconSyncing []byte + +//go:embed icon_warning.ico +var iconWarning []byte + +//go:embed icon_error.ico +var iconError []byte + +// statusIcon returns the tray icon for the given backup state. +func statusIcon(s trayState) []byte { + switch s { + case stateRunning: + return iconSyncing + case stateOK: + return iconOK + case stateWarning: + return iconWarning + case stateError: + return iconError + default: + return icon + } +} diff --git a/cmd/backrest/tray.go b/cmd/backrest/tray.go index bc357007..2b51d806 100644 --- a/cmd/backrest/tray.go +++ b/cmd/backrest/tray.go @@ -13,11 +13,14 @@ import ( ) func startTray() { + status := newTrayStatus() + // Observe oplog status in-process so the icon can reflect backup state. + onOpLogReady = status.attach go runApp() - systray.Run(onReady, func() {}) + systray.Run(func() { onReady(status) }, func() {}) } -func onReady() { +func onReady(status *trayStatus) { systray.SetTooltip("Backrest") systray.SetIcon(icon) @@ -25,6 +28,9 @@ func onReady() { mOpenLog := systray.AddMenuItem("Open Log Dir", "Open the Backrest log directory") mQuit := systray.AddMenuItem("Quit", "Kills the backrest process and exits the tray app") + // Now that the tray is live, reflect the current backup status. + status.markReady() + go func() { for { select { diff --git a/cmd/backrest/tray_status.go b/cmd/backrest/tray_status.go new file mode 100644 index 00000000..f3d49d7e --- /dev/null +++ b/cmd/backrest/tray_status.go @@ -0,0 +1,167 @@ +//go:build tray + +package main + +import ( + "errors" + "fmt" + "sync" + "sync/atomic" + "time" + + "fyne.io/systray" + v1 "github.com/garethgeorge/backrest/gen/go/v1" + "github.com/garethgeorge/backrest/internal/oplog" +) + +// trayState is the overall backup state reflected by the tray icon. +type trayState int + +const ( + stateIdle trayState = iota // no backups recorded yet + stateRunning // a backup is currently in progress + stateOK // most recent backup succeeded + stateWarning // most recent backup completed with warnings + stateError // most recent backup failed +) + +// errStopQuery short-circuits an oplog query once the wanted op is found. +var errStopQuery = errors.New("stop") + +// trayStatus tracks backup state from the oplog and updates the tray icon and +// tooltip to match (addresses #894). Updates are coalesced so a burst of +// progress events triggers at most one recompute per window. +type trayStatus struct { + mu sync.Mutex + log *oplog.OpLog + cur trayState + tooltip string + ready atomic.Bool + pending atomic.Bool +} + +func newTrayStatus() *trayStatus { + return &trayStatus{cur: stateIdle, tooltip: "Backrest"} +} + +// attach is invoked via the onOpLogReady hook once the oplog exists. It seeds +// the initial state and subscribes for subsequent changes. +func (t *trayStatus) attach(log *oplog.OpLog) { + t.mu.Lock() + t.log = log + t.mu.Unlock() + + sub := oplog.Subscription(func(_ []*v1.Operation, _ oplog.OperationEvent) { + t.scheduleRefresh() + }) + log.Subscribe(oplog.Query{}, &sub) + t.doRefresh() +} + +// markReady is called once the systray is live so icon/tooltip writes land. +func (t *trayStatus) markReady() { + t.ready.Store(true) + t.apply() +} + +// scheduleRefresh coalesces a burst of oplog events into one recompute. +func (t *trayStatus) scheduleRefresh() { + if t.pending.Swap(true) { + return + } + go func() { + time.Sleep(750 * time.Millisecond) + t.pending.Store(false) + t.doRefresh() + }() +} + +func (t *trayStatus) doRefresh() { + t.mu.Lock() + log := t.log + t.mu.Unlock() + if log == nil { + return + } + + state := stateIdle + tooltip := "Backrest — no backups yet" + running := false + var last *v1.Operation + + // Reversed = newest first. Skip non-backup ops; a running backup flips the + // state, otherwise the first completed backup is the most recent result. + _ = log.Query(oplog.Query{Reversed: true}, func(op *v1.Operation) error { + // Only backup operations drive the headline status. Match on the oneof + // type rather than GetOperationBackup(), which is nil when the inner + // message is unset on an otherwise-backup op. + if _, ok := op.GetOp().(*v1.Operation_OperationBackup); !ok { + return nil + } + switch op.GetStatus() { + case v1.OperationStatus_STATUS_INPROGRESS, v1.OperationStatus_STATUS_PENDING: + running = true + return nil + default: + last = op + return errStopQuery + } + }) + + switch { + case running: + state = stateRunning + tooltip = "Backrest — backup in progress…" + case last != nil: + when := relativeTime(last.GetUnixTimeEndMs()) + switch last.GetStatus() { + case v1.OperationStatus_STATUS_SUCCESS: + state, tooltip = stateOK, "Backrest — last backup succeeded "+when + case v1.OperationStatus_STATUS_WARNING: + state, tooltip = stateWarning, "Backrest — last backup finished with warnings "+when + case v1.OperationStatus_STATUS_ERROR, v1.OperationStatus_STATUS_SYSTEM_CANCELLED: + state, tooltip = stateError, "Backrest — last backup failed "+when + case v1.OperationStatus_STATUS_USER_CANCELLED: + state, tooltip = stateIdle, "Backrest — last backup was cancelled "+when + } + } + + t.mu.Lock() + changed := state != t.cur || tooltip != t.tooltip + t.cur, t.tooltip = state, tooltip + t.mu.Unlock() + if changed { + t.apply() + } +} + +func (t *trayStatus) apply() { + if !t.ready.Load() { + return + } + t.mu.Lock() + state, tooltip := t.cur, t.tooltip + t.mu.Unlock() + + if ic := statusIcon(state); ic != nil { + systray.SetIcon(ic) + } + systray.SetTooltip(tooltip) +} + +func relativeTime(unixMs int64) string { + if unixMs == 0 { + return "" + } + d := time.Since(time.UnixMilli(unixMs)) + switch { + case d < time.Minute: + return "just now" + case d < time.Hour: + return fmt.Sprintf("%dm ago", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh ago", int(d.Hours())) + default: + return fmt.Sprintf("%dd ago", int(d.Hours()/24)) + } +} diff --git a/cmd/backrest/tray_status_test.go b/cmd/backrest/tray_status_test.go new file mode 100644 index 00000000..29f4aab5 --- /dev/null +++ b/cmd/backrest/tray_status_test.go @@ -0,0 +1,102 @@ +//go:build tray + +package main + +import ( + "testing" + + v1 "github.com/garethgeorge/backrest/gen/go/v1" + "github.com/garethgeorge/backrest/internal/oplog" + "github.com/garethgeorge/backrest/internal/oplog/memstore" +) + +func newTestOpLog(t *testing.T) *oplog.OpLog { + t.Helper() + log, err := oplog.NewOpLog(memstore.NewMemStore()) + if err != nil { + t.Fatalf("NewOpLog: %v", err) + } + return log +} + +var opStartTime int64 = 1_000 + +func backupOp(status v1.OperationStatus) *v1.Operation { + opStartTime += 1_000 + return &v1.Operation{ + InstanceId: "test-instance", + RepoId: "test-repo", + RepoGuid: "test-repo", + PlanId: "test-plan", + UnixTimeStartMs: opStartTime, + UnixTimeEndMs: opStartTime + 500, + Status: status, + Op: &v1.Operation_OperationBackup{OperationBackup: &v1.OperationBackup{}}, + } +} + +func nonBackupOp(status v1.OperationStatus) *v1.Operation { + opStartTime += 1_000 + return &v1.Operation{ + InstanceId: "test-instance", + RepoId: "test-repo", + RepoGuid: "test-repo", + PlanId: "test-plan", + UnixTimeStartMs: opStartTime, + UnixTimeEndMs: opStartTime + 500, + Status: status, + Op: &v1.Operation_OperationForget{OperationForget: &v1.OperationForget{}}, + } +} + +func TestTrayStatus(t *testing.T) { + log := newTestOpLog(t) + ts := newTrayStatus() + ts.attach(log) + + // No operations yet. + ts.doRefresh() + if ts.cur != stateIdle { + t.Fatalf("empty oplog: got %v, want stateIdle", ts.cur) + } + + steps := []struct { + name string + op *v1.Operation + want trayState + }{ + {"successful backup", backupOp(v1.OperationStatus_STATUS_SUCCESS), stateOK}, + {"failed backup is newest", backupOp(v1.OperationStatus_STATUS_ERROR), stateError}, + {"non-backup success is ignored", nonBackupOp(v1.OperationStatus_STATUS_SUCCESS), stateError}, + {"warning backup is newest", backupOp(v1.OperationStatus_STATUS_WARNING), stateWarning}, + {"in-progress backup shows running", backupOp(v1.OperationStatus_STATUS_INPROGRESS), stateRunning}, + {"completed success after run", backupOp(v1.OperationStatus_STATUS_SUCCESS), stateOK}, + } + + for _, s := range steps { + if err := log.Add(s.op); err != nil { + t.Fatalf("%s: Add: %v", s.name, err) + } + ts.doRefresh() + if ts.cur != s.want { + t.Errorf("%s: got state %v, want %v", s.name, ts.cur, s.want) + } + } +} + +func TestTrayStatusInProgressOverridesOlderResult(t *testing.T) { + log := newTestOpLog(t) + ts := newTrayStatus() + ts.attach(log) + + if err := log.Add(backupOp(v1.OperationStatus_STATUS_ERROR)); err != nil { + t.Fatal(err) + } + if err := log.Add(backupOp(v1.OperationStatus_STATUS_INPROGRESS)); err != nil { + t.Fatal(err) + } + ts.doRefresh() + if ts.cur != stateRunning { + t.Errorf("in-progress over older error: got %v, want stateRunning", ts.cur) + } +}