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>
230 lines
5.7 KiB
Go
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()
|
|
}
|
|
}
|