Files
keeporg/cmd/keeporg/tray.go
T
ReactorcoremeltdownandClaude Opus 5.5 ead82dc777 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>
2026-11-02 12:30:28 +01:00

230 lines
5.7 KiB
Go

//go:build !darwin || cgo
package main
import (
"context"
"flag"
"log"
"runtime"
"sync"
"time"
"fyne.io/systray"
"cc.tiredsysadmin.keeporg/internal/icon"
)
// maxDatabaseSlots bounds the Databases submenu. Menu items are created once
// and retitled, shown and hidden as the list changes, which every tray
// implementation handles; adding and removing them does not work everywhere.
const maxDatabaseSlots = 32
func cmdTray(ctx context.Context, args []string) error {
fs := flag.NewFlagSet("tray", flag.ExitOnError)
path := configFlag(fs)
_ = fs.Parse(args)
t := &trayApp{agent: newAgent(*path, notify), path: *path}
// The tray library and anything else that uses the standard logger
// write where the agent does.
log.SetOutput(t.agent.sink)
// SIGTERM, which a session sends at logout, quits as cleanly as the menu.
go func() {
<-ctx.Done()
systray.Quit()
}()
systray.Run(t.onReady, t.onExit)
return nil
}
type trayApp struct {
agent *agent
path string
header, openDir, syncNow, signIn, signOut, web *systray.MenuItem
noDatabases *systray.MenuItem
mu sync.Mutex
slots [maxDatabaseSlots]struct {
item *systray.MenuItem
path string
}
last view
}
func (t *trayApp) onReady() {
if runtime.GOOS == "darwin" {
systray.SetTemplateIcon(icon.PNG(icon.Tray(36, icon.Template)), icon.PNG(icon.Tray(36, icon.Brand)))
} else if runtime.GOOS == "windows" {
systray.SetIcon(icon.ICO(icon.Tray(16, icon.Brand), icon.Tray(32, icon.Brand), icon.Tray(48, icon.Brand)))
} else {
systray.SetIcon(icon.PNG(icon.Tray(64, icon.Brand)))
}
systray.SetTooltip("KeepOrg " + Version)
t.header = systray.AddMenuItem("Starting…", "")
t.header.Disable()
systray.AddSeparator()
t.openDir = systray.AddMenuItem("Open Mirror Folder", "Show the synchronized .kdbx files")
dbs := systray.AddMenuItem("Databases", "Open a database in your KeePass application")
t.noDatabases = dbs.AddSubMenuItem("No databases", "")
t.noDatabases.Disable()
for i := range t.slots {
t.slots[i].item = dbs.AddSubMenuItem("", "")
t.slots[i].item.Hide()
go t.onDatabase(i)
}
t.syncNow = systray.AddMenuItem("Sync Now", "Reconcile every database immediately")
systray.AddSeparator()
t.signIn = systray.AddMenuItem("Sign In…", "Approve this device in a browser")
t.signOut = systray.AddMenuItem("Sign Out", "Revoke this device's session; the files stay")
t.web = systray.AddMenuItem("Open Web Interface", "Create vaults and manage access")
openConfig := systray.AddMenuItem("Open Config", "Edit the settings; changes apply when saved")
openLog := systray.AddMenuItem("Open Log", "")
login := systray.AddMenuItemCheckbox("Start at Login", "Start KeepOrg when you sign in", startAtLoginEnabled())
systray.AddSeparator()
about := systray.AddMenuItem("About", "")
quit := systray.AddMenuItem("Quit", "Stop synchronizing and quit")
t.agent.start()
t.refresh()
go func() {
tick := time.NewTicker(time.Second)
defer tick.Stop()
for range tick.C {
t.agent.tick()
t.refresh()
}
}()
go func() {
for {
select {
case <-t.openDir.ClickedCh:
openPath(t.view().MirrorDir)
case <-t.syncNow.ClickedCh:
t.agent.syncNow()
case <-t.signIn.ClickedCh:
go t.runSignIn()
case <-t.signOut.ClickedCh:
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
t.agent.signOut(ctx)
cancel()
t.refresh()
case <-t.web.ClickedCh:
if u := t.view().WebURL; u != "" {
openPath(u)
}
case <-openConfig.ClickedCh:
openText(t.path)
case <-openLog.ClickedCh:
openText(t.agent.logPath())
case <-login.ClickedCh:
enable := !login.Checked()
if err := setStartAtLogin(enable); err != nil {
t.agent.log.Printf("start at login: %v", err)
notify("KeepOrg", "Could not change Start at Login: "+err.Error())
} else if enable {
login.Check()
} else {
login.Uncheck()
}
case <-about.ClickedCh:
showAbout(t.path)
case <-quit.ClickedCh:
systray.Quit()
return
}
}
}()
}
func (t *trayApp) onExit() { t.agent.stop() }
func (t *trayApp) runSignIn() {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
t.refresh()
err := t.agent.signIn(ctx, openPath)
switch {
case err == errNoServer:
openText(t.path)
notify("KeepOrg", err.Error())
case err != nil:
t.agent.log.Printf("sign in: %v", err)
notify("KeepOrg sign-in failed", err.Error())
}
t.refresh()
}
func (t *trayApp) onDatabase(i int) {
for range t.slots[i].item.ClickedCh {
t.mu.Lock()
path := t.slots[i].path
t.mu.Unlock()
if path != "" {
openPath(path)
}
}
}
func (t *trayApp) view() view {
t.mu.Lock()
defer t.mu.Unlock()
return t.last
}
// refresh brings the menu in line with the agent. Only what changed is sent,
// because on Linux every change is a D-Bus round trip.
func (t *trayApp) refresh() {
v := t.agent.view()
t.mu.Lock()
defer t.mu.Unlock()
old := t.last
t.last = v
if v.Header != old.Header {
t.header.SetTitle(v.Header)
}
if v.Tooltip != old.Tooltip {
systray.SetTooltip(v.Tooltip)
}
showIf(t.signIn, !v.SignedIn && !v.Pending)
showIf(t.signOut, v.SignedIn)
enableIf(t.syncNow, v.SignedIn)
enableIf(t.web, v.WebURL != "")
showIf(t.noDatabases, len(v.Databases) == 0)
for i := range t.slots {
s := &t.slots[i]
if i >= len(v.Databases) {
s.path = ""
s.item.Hide()
continue
}
d := v.Databases[i]
if i >= len(old.Databases) || old.Databases[i] != d {
s.item.SetTitle(d.Label)
}
s.path = d.Path
s.item.Show()
}
}
func showIf(item *systray.MenuItem, show bool) {
if show {
item.Show()
} else {
item.Hide()
}
}
func enableIf(item *systray.MenuItem, enable bool) {
if enable {
item.Enable()
} else {
item.Disable()
}
}