Turn the desktop client into a system tray application

Started without a command, keeporg now shows a padlock in the system tray
with a menu like midi-webhook's: a status line, the mirror folder, each
database with its state, Sync Now, browser sign-in and sign-out, the web
interface, the configuration, the log, Start at Login, About and Quit. The
commands are unchanged.

config.json now carries every client setting (device_label,
request_timeout_seconds, ca_file, log_file, notifications), is written in
full on first start and is reloaded by the tray when saved.

A lock in the mirror directory admits one agent at a time, since the tray,
`keeporg run` and a service unit could otherwise race each other.

The tray uses fyne.io/systray: pure Go on Linux, BSD and Windows, cgo on
macOS only. The server keeps no third-party dependencies.

Desktop versions go to 0.5.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Reactorcoremeltdown
2026-11-02 12:30:28 +01:00
co-authored by Claude Opus 5.5
parent d314188247
commit ead82dc777
37 changed files with 2301 additions and 108 deletions
+43 -12
View File
@@ -57,9 +57,17 @@ type Mirror struct {
log *log.Logger
poll time.Duration
// kick asks Run for a reconcile now. It holds at most one request, so
// a burst of them costs one pass.
kick chan struct{}
mu sync.Mutex
st map[string]*fileState
status map[string]*Status
// lastSync and lastErr describe the most recent SyncAll as a whole, which
// a per-database status cannot: an unreachable server has no databases.
lastSync time.Time
lastErr error
}
// NewMirror prepares dir and loads any previous state.
@@ -70,7 +78,7 @@ func NewMirror(api *API, dir string, lg *log.Logger, poll time.Duration) (*Mirro
if poll <= 0 {
poll = 2 * time.Second
}
m := &Mirror{api: api, dir: dir, log: lg, poll: poll,
m := &Mirror{api: api, dir: dir, log: lg, poll: poll, kick: make(chan struct{}, 1),
st: map[string]*fileState{}, status: map[string]*Status{}}
b, err := os.ReadFile(filepath.Join(dir, stateFile))
switch {
@@ -88,18 +96,26 @@ func NewMirror(api *API, dir string, lg *log.Logger, poll time.Duration) (*Mirro
// Dir is the mirror directory.
func (m *Mirror) Dir() string { return m.dir }
// Run reconciles until ctx is done, driven by server push events and by a
// local rescan for saves made by a KeePass application.
func (m *Mirror) Run(ctx context.Context) error {
trigger := make(chan struct{}, 1)
wake := func() {
select {
case trigger <- struct{}{}:
default:
}
// Kick asks a running mirror to reconcile now instead of at the next rescan.
func (m *Mirror) Kick() {
select {
case m.kick <- struct{}{}:
default:
}
}
go m.listen(ctx, wake)
// Run reconciles until ctx is done, driven by server push events and by a
// local rescan for saves made by a KeePass application. It holds the mirror
// directory's lock throughout, so a second agent on the same directory fails
// to start instead of racing this one.
func (m *Mirror) Run(ctx context.Context) error {
unlock, err := LockDir(m.dir)
if err != nil {
return err
}
defer unlock()
go m.listen(ctx, m.Kick)
if err := m.SyncAll(ctx); err != nil {
m.log.Printf("sync: %v", err)
@@ -114,7 +130,7 @@ func (m *Mirror) Run(ctx context.Context) error {
if err := m.SyncAll(ctx); err != nil {
m.log.Printf("sync: %v", err)
}
case <-trigger:
case <-m.kick:
// Let a burst of events settle before reconciling.
time.Sleep(150 * time.Millisecond)
if err := m.SyncAll(ctx); err != nil {
@@ -155,10 +171,14 @@ func (m *Mirror) listen(ctx context.Context, wake func()) {
func (m *Mirror) SyncAll(ctx context.Context) error {
dbs, err := m.api.Databases(ctx)
if err != nil {
m.mu.Lock()
m.lastSync, m.lastErr = time.Now(), err
m.mu.Unlock()
return err
}
m.mu.Lock()
defer m.mu.Unlock()
defer func() { m.lastSync = time.Now() }()
seen := map[string]bool{}
var firstErr error
@@ -184,9 +204,20 @@ func (m *Mirror) SyncAll(ctx context.Context) error {
if err := m.saveState(); err != nil && firstErr == nil {
firstErr = err
}
// A failure of one database is in its own status; lastErr is for what
// stopped the whole pass.
m.lastErr = nil
return firstErr
}
// LastSync reports when the last pass finished, and the error that stopped
// it from reaching the server, if one did.
func (m *Mirror) LastSync() (time.Time, error) {
m.mu.Lock()
defer m.mu.Unlock()
return m.lastSync, m.lastErr
}
// Status reports the current state of each mirrored database.
func (m *Mirror) Status() []Status {
m.mu.Lock()