feat(tray): reflect backup status in the tray icon (#1255)

This commit is contained in:
Jean Galea
2026-07-08 08:57:57 +02:00
committed by GitHub
parent ccddf37437
commit 63ab46858f
14 changed files with 345 additions and 2 deletions
+12
View File
@@ -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))
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

+28
View File
@@ -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
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+28
View File
@@ -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
}
}
+8 -2
View File
@@ -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 {
+167
View File
@@ -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))
}
}
+102
View File
@@ -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)
}
}