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:
co-authored by
Claude Opus 5.5
parent
d314188247
commit
ead82dc777
+43
-12
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user