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
+36
View File
@@ -0,0 +1,36 @@
//go:build !windows
package main
import (
"fmt"
"os/exec"
"runtime"
"strings"
)
// showAbout shows a native About dialog: AppleScript on macOS, zenity or
// kdialog elsewhere, and the homepage when neither is installed.
func showAbout(configPath string) {
text := aboutText(configPath)
if runtime.GOOS == "darwin" {
go func() {
out, _ := exec.Command("osascript", "-e", fmt.Sprintf(
`button returned of (display dialog %q with title "About KeepOrg" buttons {"Homepage", "OK"} default button "OK" with icon note)`,
text)).Output()
if strings.TrimSpace(string(out)) == "Homepage" {
openPath(homepage)
}
}()
return
}
if p, err := exec.LookPath("zenity"); err == nil {
go exec.Command(p, "--info", "--title=About KeepOrg", "--no-wrap", "--text="+text).Run()
return
}
if p, err := exec.LookPath("kdialog"); err == nil {
go exec.Command(p, "--title", "About KeepOrg", "--msgbox", text).Run()
return
}
openPath(homepage)
}
+13
View File
@@ -0,0 +1,13 @@
//go:build windows
package main
import "golang.org/x/sys/windows"
// showAbout shows a message box. It runs on its own goroutine because the
// box is modal and the tray's event loop must keep running.
func showAbout(configPath string) {
text, _ := windows.UTF16PtrFromString(aboutText(configPath))
title, _ := windows.UTF16PtrFromString("About KeepOrg")
go windows.MessageBox(0, text, title, windows.MB_OK|windows.MB_ICONINFORMATION)
}
+489
View File
@@ -0,0 +1,489 @@
package main
import (
"context"
"errors"
"fmt"
"io"
"log"
"os"
"path/filepath"
"sync"
"time"
"cc.tiredsysadmin.keeporg/internal/client"
)
// agent is the tray application without the tray: the configuration, the
// mirror's lifecycle and sign-in. The menu only reads view() and calls the
// methods below, so everything that decides anything is testable without a
// desktop session.
type agent struct {
path string
log *log.Logger
sink *logSink
notify func(title, message string)
mu sync.Mutex
cfg *client.Config
cfgMod time.Time
cfgErr error
mir *client.Mirror
cancel context.CancelFunc
done chan struct{}
// run is why the current mirror stopped, or could not start. It has its
// own lock because the mirror's goroutine sets it, and that goroutine
// must never wait on mu: stopping the mirror holds mu while it waits for
// the goroutine to finish.
run *runErr
pending string // the user code of a browser sign-in in progress
expired bool // the server rejected the stored token
statuses map[string]client.Status
}
func newAgent(path string, notify func(title, message string)) *agent {
sink := &logSink{}
return &agent{path: path, sink: sink, log: log.New(sink, "", log.LstdFlags),
notify: notify, run: &runErr{}, statuses: map[string]client.Status{}}
}
type runErr struct {
mu sync.Mutex
err error
}
func (r *runErr) set(err error) {
r.mu.Lock()
r.err = err
r.mu.Unlock()
}
func (r *runErr) get() error {
r.mu.Lock()
defer r.mu.Unlock()
return r.err
}
// start loads the configuration, writing a complete default one on first
// run, and starts synchronizing if this device is signed in.
func (a *agent) start() {
cfg, err := client.EnsureConfig(a.path)
a.mu.Lock()
defer a.mu.Unlock()
a.cfgMod = modTime(a.path)
if cfg == nil {
cfg = client.DefaultConfig()
}
a.cfg, a.cfgErr = cfg, err
a.openLogLocked()
if err != nil {
a.log.Printf("config: %v", err)
return
}
a.startMirrorLocked()
}
// stop ends synchronization, for quitting.
func (a *agent) stop() {
a.mu.Lock()
defer a.mu.Unlock()
a.stopMirrorLocked()
a.sink.set(nil)
}
func (a *agent) startMirrorLocked() {
if a.cancel != nil || a.cfg.Token == "" || a.cfg.ServerURL == "" {
return
}
a.run = &runErr{}
api, err := a.cfg.API()
if err != nil {
a.run.set(err)
return
}
mir, err := client.NewMirror(api, a.cfg.MirrorDir, a.log, a.cfg.Poll())
if err != nil {
a.run.set(err)
return
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
run, notify := a.run, a.cfg.Notifications
a.mir, a.cancel, a.done, a.expired = mir, cancel, done, false
a.log.Printf("mirroring %s into %s", a.cfg.ServerURL, a.cfg.MirrorDir)
go func() {
defer close(done)
err := mir.Run(ctx)
if errors.Is(err, context.Canceled) {
return
}
a.log.Printf("agent stopped: %v", err)
run.set(err)
if notify && errors.Is(err, client.ErrMirrorBusy) {
a.notify("KeepOrg is already running", err.Error())
}
}()
}
func (a *agent) stopMirrorLocked() {
if a.cancel == nil {
return
}
a.cancel()
<-a.done
a.mir, a.cancel, a.done = nil, nil, nil
a.statuses = map[string]client.Status{}
}
// reload rereads the configuration when the file changed, and restarts the
// mirror if a setting it depends on did. A file that no longer parses is
// reported and the running configuration kept.
func (a *agent) reload() {
mod := modTime(a.path)
a.mu.Lock()
defer a.mu.Unlock()
if mod.Equal(a.cfgMod) {
return
}
a.cfgMod = mod
cfg, err := client.LoadConfig(a.path)
if err != nil {
if a.cfgErr == nil || a.cfgErr.Error() != err.Error() {
a.log.Printf("config: %v", err)
a.sayLocked("KeepOrg configuration error", err.Error())
}
a.cfgErr = err
return
}
old := a.cfg
a.cfg, a.cfgErr = cfg, nil
if old.LogFile != cfg.LogFile {
a.openLogLocked()
}
if mirrorSettingsChanged(old, cfg) || a.cancel == nil {
a.log.Printf("config: reloaded")
a.stopMirrorLocked()
a.startMirrorLocked()
}
}
// mirrorSettingsChanged reports whether the running mirror was built from
// settings that differ in b.
func mirrorSettingsChanged(a, b *client.Config) bool {
return a.ServerURL != b.ServerURL || a.Token != b.Token || a.MirrorDir != b.MirrorDir ||
a.PollSeconds != b.PollSeconds || a.RequestTimeoutSeconds != b.RequestTimeoutSeconds ||
a.CAFile != b.CAFile
}
// tick runs once a second: it notices configuration edits, a revoked
// session, and status changes worth a notification.
func (a *agent) tick() {
a.reload()
a.mu.Lock()
defer a.mu.Unlock()
if a.mir == nil {
return
}
if _, err := a.mir.LastSync(); client.Code(err) == "unauthorized" {
a.log.Printf("the server no longer accepts this device's session")
a.stopMirrorLocked()
a.cfg.Token, a.expired = "", true
a.saveLocked()
a.sayLocked("KeepOrg signed out", "The session expired or was revoked. Sign in again from the menu.")
return
}
next := map[string]client.Status{}
for _, s := range a.mir.Status() {
next[s.DatabaseID] = s
if title, msg, ok := statusNotice(a.statuses[s.DatabaseID], s); ok {
a.sayLocked(title, msg)
}
}
a.statuses = next
}
// statusNotice decides whether a database's change of state is worth
// interrupting someone for. Routine pulls and pushes are not.
func statusNotice(prev, cur client.Status) (title, msg string, ok bool) {
if prev.State == cur.State && prev.Message == cur.Message {
return "", "", false
}
switch cur.State {
case "conflict":
return cur.Name + ": conflict", cur.Message, true
case "conflict-resolved":
return cur.Name + ": your copy was kept aside", cur.Message, true
case "merged":
return cur.Name + ": merged", "Your save was merged with a colleague's edit.", true
case "error":
return cur.Name + ": synchronization failed", cur.Message, true
}
return "", "", false
}
// syncNow asks the mirror for an immediate pass.
func (a *agent) syncNow() {
a.mu.Lock()
defer a.mu.Unlock()
if a.mir != nil {
a.mir.Kick()
}
}
// signIn runs the browser-approved device flow, which serves passwords,
// second factors and single sign-on alike, so the tray needs no dialogs.
func (a *agent) signIn(ctx context.Context, open func(string)) error {
a.mu.Lock()
if a.pending != "" {
a.mu.Unlock()
return errors.New("a sign-in is already waiting for approval")
}
if a.cfgErr != nil {
a.mu.Unlock()
return a.cfgErr
}
if a.cfg.ServerURL == "" {
a.mu.Unlock()
return errNoServer
}
cfg := *a.cfg
a.mu.Unlock()
cfg.Token = ""
api, err := cfg.API()
if err != nil {
return err
}
start, err := api.DeviceStart(ctx, cfg.DeviceLabel)
if err != nil {
return err
}
a.setPending(start.UserCode)
defer a.setPending("")
open(start.VerificationURL)
interval := time.Duration(max(start.Interval, 1)) * time.Second
deadline := time.Now().Add(time.Duration(max(start.ExpiresIn, 60)) * time.Second)
for time.Now().Before(deadline) {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(interval):
}
sess, err := api.DevicePoll(ctx, start.DeviceCode)
if err != nil {
return err
}
if sess == nil {
continue
}
a.mu.Lock()
a.cfg.Token, a.cfg.Username = sess.Token, sess.User.Username
a.saveLocked()
a.stopMirrorLocked()
a.startMirrorLocked()
a.mu.Unlock()
a.log.Printf("signed in as %s", sess.User.Username)
return nil
}
return errors.New("the approval window expired")
}
var errNoServer = errors.New("no server is configured: set server_url in the configuration file, save it, and sign in again")
func (a *agent) setPending(code string) {
a.mu.Lock()
a.pending = code
a.mu.Unlock()
}
// signOut revokes the session on the server and forgets it here. The files
// stay where they are.
func (a *agent) signOut(ctx context.Context) {
a.mu.Lock()
defer a.mu.Unlock()
a.stopMirrorLocked()
if api, err := a.cfg.API(); err == nil && a.cfg.Token != "" {
if err := api.Logout(ctx); err != nil {
a.log.Printf("revoking the session on the server failed: %v", err)
}
}
a.cfg.Token = ""
a.saveLocked()
a.log.Printf("signed out")
}
// saveLocked writes the configuration and records the write, so the next
// reload does not mistake it for an edit.
func (a *agent) saveLocked() {
if err := a.cfg.Save(a.path); err != nil {
a.log.Printf("config: %v", err)
}
a.cfgMod = modTime(a.path)
}
func (a *agent) sayLocked(title, msg string) {
if a.cfg == nil || a.cfg.Notifications {
a.notify(title, msg)
}
}
// logPath is where the tray logs: the configured file, or keeporg.log beside
// the configuration, since a tray application has no terminal.
func (a *agent) logPath() string {
a.mu.Lock()
defer a.mu.Unlock()
return a.logPathLocked()
}
func (a *agent) logPathLocked() string {
if a.cfg != nil && a.cfg.LogFile != "" {
return a.cfg.LogFile
}
return filepath.Join(filepath.Dir(a.path), "keeporg.log")
}
func (a *agent) openLogLocked() {
f, err := openLog(a.logPathLocked())
if err != nil {
a.sink.set(nil)
fmt.Fprintln(os.Stderr, "keeporg: log:", err)
return
}
a.sink.set(f)
}
func modTime(path string) time.Time {
fi, err := os.Stat(path)
if err != nil {
return time.Time{}
}
return fi.ModTime()
}
// logSink is a log destination that can be swapped while loggers hold it.
type logSink struct {
mu sync.Mutex
w io.WriteCloser
}
func (s *logSink) set(w io.WriteCloser) {
s.mu.Lock()
defer s.mu.Unlock()
if s.w != nil {
s.w.Close()
}
s.w = w
}
func (s *logSink) Write(p []byte) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.w == nil {
return os.Stderr.Write(p)
}
return s.w.Write(p)
}
// view is what the menu shows, decided in one place.
type view struct {
Header string
Tooltip string
SignedIn bool
Pending bool
MirrorDir string
WebURL string
Databases []dbItem
}
type dbItem struct {
Label string
Path string
}
// snapshot is the agent's state at one moment, for buildView.
type snapshot struct {
cfg client.Config
cfgErr error
pending string
expired bool
running bool
runErr error
lastSync time.Time
lastErr error
statuses []client.Status
}
func (a *agent) view() view {
a.mu.Lock()
s := snapshot{cfgErr: a.cfgErr, pending: a.pending, expired: a.expired,
running: a.mir != nil, runErr: a.run.get()}
if a.cfg != nil {
s.cfg = *a.cfg
}
if a.mir != nil {
s.lastSync, s.lastErr = a.mir.LastSync()
s.statuses = a.mir.Status()
}
a.mu.Unlock()
return buildView(s)
}
func buildView(s snapshot) view {
v := view{SignedIn: s.cfg.Token != "", Pending: s.pending != "", MirrorDir: s.cfg.MirrorDir}
if s.cfg.ServerURL != "" {
v.WebURL = s.cfg.ServerURL + "/web"
}
attention := 0
for _, st := range s.statuses {
label := st.Name
switch st.State {
case "ok":
label = "✓ " + label
case "merged":
label = "✓ " + label + " (merged)"
case "conflict":
label, attention = "⚠ "+label+": conflict", attention+1
case "conflict-resolved":
label, attention = "⚠ "+label+": your copy kept aside", attention+1
case "error":
label, attention = "✗ "+label+": not synchronized", attention+1
}
if st.Perm == "read" {
label += " (read-only)"
}
item := dbItem{Label: label}
if st.File != "" {
item.Path = filepath.Join(s.cfg.MirrorDir, st.File)
}
v.Databases = append(v.Databases, item)
}
switch {
case s.cfgErr != nil:
v.Header = "Configuration error, see Open Config"
case s.pending != "":
v.Header = "Approve code " + s.pending + " in the browser"
case s.cfg.ServerURL == "":
v.Header = "No server configured"
case s.expired:
v.Header = "Session expired, sign in again"
case !v.SignedIn:
v.Header = "Signed out"
case errors.Is(s.runErr, client.ErrMirrorBusy):
v.Header = "Another KeepOrg agent is syncing"
case s.runErr != nil:
v.Header = "Stopped: " + s.runErr.Error()
case !s.running || s.lastSync.IsZero():
v.Header = "Connecting…"
case s.lastErr != nil:
v.Header = "Offline, retrying"
case attention > 0:
v.Header = fmt.Sprintf("%s · %d need attention", s.cfg.Username, attention)
default:
v.Header = s.cfg.Username + " · up to date"
}
v.Tooltip = "KeepOrg " + Version + "\n" + v.Header
return v
}
+97
View File
@@ -0,0 +1,97 @@
package main
import (
"context"
"io"
"log"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"cc.tiredsysadmin.keeporg/internal/client"
"cc.tiredsysadmin.keeporg/internal/kdbx"
"cc.tiredsysadmin.keeporg/internal/secret"
"cc.tiredsysadmin.keeporg/internal/server"
"cc.tiredsysadmin.keeporg/internal/store"
)
// TestAgentAgainstAServer drives the tray's agent against a real server:
// it syncs a database into the mirror, and signs itself out when the server
// revokes its session.
func TestAgentAgainstAServer(t *testing.T) {
srv, err := server.New(server.Config{
DataDir: t.TempDir(),
MasterKeyFile: filepath.Join(t.TempDir(), "master.key"),
VaultKDF: kdbx.KDFParams{MemoryKiB: 64, Iterations: 1, Lanes: 1},
}, log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
hash, err := secret.Hash("admin-password-1")
if err != nil {
t.Fatal(err)
}
if err := srv.Store().Update(func(st *store.State) error {
st.Users["admin"] = &store.User{ID: "admin", Username: "admin", PassHash: hash, ServerAdmin: true}
st.Groups["everyone"] = &store.Group{ID: "everyone", Name: "everyone"}
st.Memberships = append(st.Memberships,
store.Membership{UserID: "admin", GroupID: "everyone", Role: store.RoleAdmin})
return nil
}); err != nil {
t.Fatal(err)
}
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
ctx := context.Background()
admin := client.NewAPI(ts.URL, "")
sess, err := admin.Login(ctx, "admin", "admin-password-1", "", "admin")
if err != nil {
t.Fatal(err)
}
admin.Token = sess.Token
if _, err := admin.CreateVault(ctx, "Team Vault", "everyone", "merge", "vault-password-1"); err != nil {
t.Fatal(err)
}
device, err := client.NewAPI(ts.URL, "").Login(ctx, "admin", "admin-password-1", "", "tray")
if err != nil {
t.Fatal(err)
}
cfg := client.DefaultConfig()
cfg.ServerURL, cfg.Token, cfg.Username = ts.URL, device.Token, "admin"
cfg.MirrorDir = t.TempDir()
a, said := newTestAgent(t, cfg)
waitFor(t, func() bool { a.tick(); return a.view().Header == "admin · up to date" })
v := a.view()
if len(v.Databases) != 1 || v.Databases[0].Path != filepath.Join(cfg.MirrorDir, "Team Vault.kdbx") {
t.Fatalf("databases %+v", v.Databases)
}
// Revoke the device's session from the server side.
if err := client.NewAPI(ts.URL, device.Token).Logout(ctx); err != nil {
t.Fatal(err)
}
a.syncNow()
waitFor(t, func() bool { a.tick(); return strings.HasPrefix(a.view().Header, "Session expired") })
if got, _ := client.LoadConfig(a.path); got.Token != "" {
t.Error("the revoked token was kept in the config")
}
if len(*said) == 0 || !strings.Contains((*said)[len(*said)-1], "signed out") {
t.Errorf("no sign-out notification: %v", *said)
}
}
func waitFor(t *testing.T, cond func() bool) {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for !cond() {
if time.Now().After(deadline) {
t.Fatal("timed out")
}
time.Sleep(50 * time.Millisecond)
}
}
+161
View File
@@ -0,0 +1,161 @@
package main
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"cc.tiredsysadmin.keeporg/internal/client"
)
func TestBuildViewHeader(t *testing.T) {
signed := client.Config{ServerURL: "https://k.example.com", Token: "t", Username: "alice", MirrorDir: "/m"}
now := time.Now()
for _, c := range []struct {
name string
s snapshot
want string
}{
{"config error", snapshot{cfg: signed, cfgErr: errors.New("x")}, "Configuration error"},
{"no server", snapshot{}, "No server configured"},
{"pending", snapshot{cfg: client.Config{ServerURL: "https://k"}, pending: "ABCD"}, "Approve code ABCD"},
{"signed out", snapshot{cfg: client.Config{ServerURL: "https://k"}}, "Signed out"},
{"expired", snapshot{cfg: client.Config{ServerURL: "https://k"}, expired: true}, "Session expired"},
{"busy", snapshot{cfg: signed, runErr: client.ErrMirrorBusy}, "Another KeepOrg agent"},
{"connecting", snapshot{cfg: signed, running: true}, "Connecting"},
{"offline", snapshot{cfg: signed, running: true, lastSync: now, lastErr: errors.New("dial")}, "Offline"},
{"attention", snapshot{cfg: signed, running: true, lastSync: now,
statuses: []client.Status{{Name: "A", State: "conflict"}, {Name: "B", State: "ok"}}}, "alice · 1 need attention"},
{"fine", snapshot{cfg: signed, running: true, lastSync: now,
statuses: []client.Status{{Name: "A", State: "ok"}}}, "alice · up to date"},
} {
if got := buildView(c.s).Header; !strings.HasPrefix(got, c.want) {
t.Errorf("%s: header %q, want prefix %q", c.name, got, c.want)
}
}
}
func TestBuildViewDatabases(t *testing.T) {
v := buildView(snapshot{cfg: client.Config{ServerURL: "https://k.example.com", MirrorDir: "/m"},
statuses: []client.Status{
{Name: "Team", File: "Team.kdbx", State: "ok", Perm: "write"},
{Name: "Ops", File: "Ops.kdbx", State: "conflict-resolved", Perm: "read"},
{Name: "New", State: "ok"},
}})
if v.WebURL != "https://k.example.com/web" {
t.Errorf("web url %q", v.WebURL)
}
want := []dbItem{
{"✓ Team", filepath.Join("/m", "Team.kdbx")},
{"⚠ Ops: your copy kept aside (read-only)", filepath.Join("/m", "Ops.kdbx")},
{"✓ New", ""},
}
for i, d := range want {
if v.Databases[i] != d {
t.Errorf("database %d = %+v, want %+v", i, v.Databases[i], d)
}
}
}
func TestStatusNoticeOnlyForNews(t *testing.T) {
ok := client.Status{Name: "A", State: "ok"}
conflict := client.Status{Name: "A", State: "conflict", Message: "server copy written beside it"}
if _, _, n := statusNotice(client.Status{}, ok); n {
t.Error("a routine sync notified")
}
if _, _, n := statusNotice(ok, conflict); !n {
t.Error("a new conflict did not notify")
}
if _, _, n := statusNotice(conflict, conflict); n {
t.Error("the same conflict notified twice")
}
}
// newTestAgent writes a configuration and starts an agent on it without a
// tray, recording notifications.
func newTestAgent(t *testing.T, cfg *client.Config) (*agent, *[]string) {
t.Helper()
path := filepath.Join(t.TempDir(), "config.json")
if cfg != nil {
if err := cfg.Save(path); err != nil {
t.Fatal(err)
}
}
var said []string
a := newAgent(path, func(title, msg string) { said = append(said, title) })
a.start()
t.Cleanup(a.stop)
return a, &said
}
func TestAgentWritesAFullConfigOnFirstStart(t *testing.T) {
a, _ := newTestAgent(t, nil)
if _, err := client.LoadConfig(a.path); err != nil {
t.Fatalf("first start left no usable config: %v", err)
}
if _, err := os.Stat(a.logPath()); err != nil {
t.Errorf("no log file beside the config: %v", err)
}
if got := a.view().Header; got != "No server configured" {
t.Errorf("header %q", got)
}
if err := a.signIn(context.Background(), func(string) {}); !errors.Is(err, errNoServer) {
t.Errorf("sign in without a server: %v", err)
}
}
func TestAgentReloadsAndReportsABrokenEdit(t *testing.T) {
a, said := newTestAgent(t, nil)
broken := []byte(`{"server_url": "not a url"}`)
if err := os.WriteFile(a.path, broken, 0o600); err != nil {
t.Fatal(err)
}
future := time.Now().Add(time.Second)
_ = os.Chtimes(a.path, future, future)
a.tick()
if !strings.HasPrefix(a.view().Header, "Configuration error") || len(*said) != 1 {
t.Fatalf("broken edit: header %q, notifications %v", a.view().Header, *said)
}
fixed := client.DefaultConfig()
fixed.ServerURL = "https://k.example.com"
if err := fixed.Save(a.path); err != nil {
t.Fatal(err)
}
future = future.Add(time.Second)
_ = os.Chtimes(a.path, future, future)
a.tick()
if got := a.view().Header; got != "Signed out" {
t.Fatalf("after fixing the file, header %q", got)
}
}
func TestAgentHonoursNotificationsSetting(t *testing.T) {
cfg := client.DefaultConfig()
cfg.Notifications = false
a, said := newTestAgent(t, cfg)
a.mu.Lock()
a.sayLocked("title", "message")
a.mu.Unlock()
if len(*said) != 0 {
t.Fatalf("notified with notifications off: %v", *said)
}
}
func TestMirrorSettingsChanged(t *testing.T) {
a := client.DefaultConfig()
b := *a
b.Notifications, b.LogFile = !a.Notifications, "/tmp/x.log"
if mirrorSettingsChanged(a, &b) {
t.Error("cosmetic settings restarted the mirror")
}
b.MirrorDir = "/elsewhere"
if !mirrorSettingsChanged(a, &b) {
t.Error("a new mirror directory did not restart the mirror")
}
}
+58
View File
@@ -0,0 +1,58 @@
//go:build darwin
package main
import (
"fmt"
"os"
"path/filepath"
)
// launchAgentPath is the per-user LaunchAgent that starts the tray at login.
func launchAgentPath() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, "Library", "LaunchAgents", AppID+".plist")
}
func startAtLoginEnabled() bool {
_, err := os.Stat(launchAgentPath())
return err == nil
}
func setStartAtLogin(enable bool) error {
path := launchAgentPath()
if !enable {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
exe, err := executable()
if err != nil {
return err
}
plist := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>%s</string>
<key>ProgramArguments</key>
<array>
<string>%s</string>
<string>tray</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>LimitLoadToSessionType</key>
<string>Aqua</string>
<key>ProcessType</key>
<string>Interactive</string>
</dict>
</plist>
`, AppID, exe)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(path, []byte(plist), 0o644)
}
+11
View File
@@ -0,0 +1,11 @@
//go:build !(darwin || windows || linux || freebsd || openbsd || netbsd || dragonfly)
package main
import "errors"
func startAtLoginEnabled() bool { return false }
func setStartAtLogin(bool) error {
return errors.New("starting at login is not supported on this platform")
}
+41
View File
@@ -0,0 +1,41 @@
//go:build windows
package main
import (
"errors"
"golang.org/x/sys/windows/registry"
)
// runKey is the per-user list of programs Windows starts at sign-in.
const runKey = `Software\Microsoft\Windows\CurrentVersion\Run`
func startAtLoginEnabled() bool {
k, err := registry.OpenKey(registry.CURRENT_USER, runKey, registry.QUERY_VALUE)
if err != nil {
return false
}
defer k.Close()
_, _, err = k.GetStringValue("KeepOrg")
return err == nil
}
func setStartAtLogin(enable bool) error {
k, _, err := registry.CreateKey(registry.CURRENT_USER, runKey, registry.SET_VALUE)
if err != nil {
return err
}
defer k.Close()
if !enable {
if err := k.DeleteValue("KeepOrg"); err != nil && !errors.Is(err, registry.ErrNotExist) {
return err
}
return nil
}
exe, err := executable()
if err != nil {
return err
}
return k.SetStringValue("KeepOrg", `"`+exe+`" tray`)
}
+51
View File
@@ -0,0 +1,51 @@
//go:build linux || freebsd || openbsd || netbsd || dragonfly
package main
import (
"fmt"
"os"
"path/filepath"
)
// autostartPath is the XDG autostart entry every freedesktop session reads.
func autostartPath() string {
dir := os.Getenv("XDG_CONFIG_HOME")
if dir == "" {
home, _ := os.UserHomeDir()
dir = filepath.Join(home, ".config")
}
return filepath.Join(dir, "autostart", "keeporg.desktop")
}
func startAtLoginEnabled() bool {
_, err := os.Stat(autostartPath())
return err == nil
}
func setStartAtLogin(enable bool) error {
path := autostartPath()
if !enable {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
exe, err := executable()
if err != nil {
return err
}
entry := fmt.Sprintf(`[Desktop Entry]
Type=Application
Name=KeepOrg
Comment=KeePass database synchronization
Exec="%s" tray
Icon=keeporg
Terminal=false
X-GNOME-Autostart-enabled=true
`, exe)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(path, []byte(entry), 0o644)
}
+42 -23
View File
@@ -10,6 +10,7 @@ import (
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"text/tabwriter"
"time"
@@ -31,7 +32,8 @@ func session(path string) (*client.Config, *client.API, error) {
if cfg.Token == "" {
return nil, nil, client.ErrNoConfig
}
return cfg, client.NewAPI(cfg.ServerURL, cfg.Token), nil
api, err := cfg.API()
return cfg, api, err
}
func cmdLogin(ctx context.Context, args []string) error {
@@ -41,15 +43,14 @@ func cmdLogin(ctx context.Context, args []string) error {
user := fs.String("user", "", "username (omit with -browser)")
dir := fs.String("dir", "", "mirror directory (default "+client.DefaultMirrorDir()+")")
browser := fs.String("browser", "", "approve in a browser instead: use -browser=sso for OIDC or -browser=yes")
label := fs.String("label", hostLabel(), "label shown in the server's session list")
label := fs.String("label", "", "label shown in the server's session list (default device_label from the config)")
_ = fs.Parse(args)
cfg, err := client.LoadConfig(*path)
if err != nil && !errors.Is(err, client.ErrNoConfig) {
return err
}
// A setting that fails validation may be the one this login is about to
// fix, so only an unreadable file stops here; Validate runs below.
cfg, err := client.EnsureConfig(*path)
if cfg == nil {
cfg = &client.Config{MirrorDir: client.DefaultMirrorDir(), PollSeconds: 2}
return err
}
if *server != "" {
cfg.ServerURL = strings.TrimRight(*server, "/")
@@ -60,20 +61,27 @@ func cmdLogin(ctx context.Context, args []string) error {
if *dir != "" {
cfg.MirrorDir = *dir
}
if cfg.MirrorDir == "" {
cfg.MirrorDir = client.DefaultMirrorDir()
if *label != "" {
cfg.DeviceLabel = *label
}
if err := cfg.Validate(); err != nil {
return err
}
api := client.NewAPI(cfg.ServerURL, "")
cfg.Token = ""
api, err := cfg.API()
if err != nil {
return err
}
var sess *client.Session
switch {
case *browser != "":
sess, err = browserLogin(ctx, api, *label)
sess, err = browserLogin(ctx, api, cfg.DeviceLabel)
default:
if *user == "" {
return errors.New("-user is required, or use -browser=yes")
}
sess, err = passwordLogin(ctx, api, *user, *label)
sess, err = passwordLogin(ctx, api, *user, cfg.DeviceLabel)
}
if err != nil {
return err
@@ -86,7 +94,7 @@ func cmdLogin(ctx context.Context, args []string) error {
if !sess.User.TOTPEnabled {
fmt.Println("tip: run `keeporg totp` to add a second factor")
}
fmt.Println("start the sync agent with `keeporg run`")
fmt.Println("start the sync agent with `keeporg tray`, or `keeporg run` without a desktop")
return nil
}
@@ -169,13 +177,20 @@ func cmdRun(ctx context.Context, args []string) error {
if err != nil {
return err
}
out := os.Stderr
var out io.Writer = os.Stderr
if cfg.LogFile != "" {
f, err := openLog(cfg.LogFile)
if err != nil {
return err
}
defer f.Close()
out = f
}
lg := log.New(out, "", log.LstdFlags)
if *quiet {
lg = log.New(io.Discard, "", 0)
}
mir, err := client.NewMirror(api, cfg.MirrorDir, lg,
time.Duration(cfg.PollSeconds)*time.Second)
mir, err := client.NewMirror(api, cfg.MirrorDir, lg, cfg.Poll())
if err != nil {
return err
}
@@ -196,11 +211,15 @@ func cmdSync(ctx context.Context, args []string) error {
if err != nil {
return err
}
mir, err := client.NewMirror(api, cfg.MirrorDir, log.New(os.Stderr, "", 0),
time.Duration(cfg.PollSeconds)*time.Second)
mir, err := client.NewMirror(api, cfg.MirrorDir, log.New(os.Stderr, "", 0), cfg.Poll())
if err != nil {
return err
}
unlock, err := client.LockDir(cfg.MirrorDir)
if err != nil {
return fmt.Errorf("%w; it keeps the files in step already", err)
}
defer unlock()
if err := mir.SyncAll(ctx); err != nil {
return err
}
@@ -719,10 +738,10 @@ func openBrowser(url string) {
}
}
func hostLabel() string {
h, err := os.Hostname()
if err != nil {
return "keeporg client"
// openLog opens a log file for appending, creating its directory.
func openLog(path string) (*os.File, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, err
}
return h
return os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
}
+58
View File
@@ -0,0 +1,58 @@
package main
import (
"fmt"
"os/exec"
"runtime"
)
// homepage is where About points.
const homepage = "https://garage.rcmd.space/keeporg"
// openPath opens a file, directory or URL with whatever the desktop
// associates with it. It is best effort: a caller that matters also logs or
// prints the target.
func openPath(target string) {
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = exec.Command("open", target)
case "windows":
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", target)
default:
cmd = exec.Command("xdg-open", target)
}
_ = cmd.Start()
}
// openText opens a file in a text editor. JSON is associated with an IDE or
// a browser often enough that "open" is not good enough for a config file.
func openText(path string) {
switch runtime.GOOS {
case "darwin":
_ = exec.Command("open", "-t", path).Start()
case "windows":
_ = exec.Command("notepad", path).Start()
default:
openPath(path)
}
}
// notify shows a desktop notification, best effort. Windows has none without
// a third-party library; the menu's first line carries the same news there.
func notify(title, message string) {
switch runtime.GOOS {
case "darwin":
_ = exec.Command("osascript", "-e",
fmt.Sprintf("display notification %q with title %q", message, title)).Start()
case "windows":
default:
_ = exec.Command("notify-send", "--app-name=KeepOrg", "--icon=keeporg", title, message).Start()
}
}
// aboutText is the About dialog's body.
func aboutText(configPath string) string {
return fmt.Sprintf("KeepOrg %s\n\nKeePass database synchronization.\n\nConfiguration: %s\n%s",
Version, configPath, homepage)
}
+30 -6
View File
@@ -3,6 +3,9 @@
// It mirrors every KDBX database assigned to you into one directory as real
// files, pushes each save to the server and applies what other clients push.
// Point KeePassXC, KeePass2 or any compatible application at those files.
//
// Started without a command it is a system tray application; the commands
// below are the same engine for terminals, scripts and headless machines.
package main
import (
@@ -11,14 +14,19 @@ import (
"fmt"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"text/tabwriter"
"cc.tiredsysadmin.keeporg/internal/client"
)
// Version is stamped at build time from versions.mk.
var Version = "dev"
// Version and AppID are stamped at build time from versions.mk.
var (
Version = "dev"
AppID = "cc.tiredsysadmin.keeporg"
)
type command struct {
name string
@@ -28,6 +36,7 @@ type command struct {
func main() {
cmds := []command{
{"tray", "run as a system tray application (the default)", cmdTray},
{"login", "sign in to a server and choose the mirror directory", cmdLogin},
{"logout", "revoke this device's session", cmdLogout},
{"run", "keep the mirror in sync until stopped", cmdRun},
@@ -50,14 +59,19 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
if len(os.Args) < 2 {
usage(cmds)
os.Exit(2)
// A double-click, a launcher or a login item starts the tray. Old macOS
// releases pass a process serial number to an application bundle.
if len(os.Args) < 2 || strings.HasPrefix(os.Args[1], "-psn_") {
os.Args = append(os.Args[:1], "tray")
}
name := os.Args[1]
if name == "version" || name == "-version" || name == "--version" {
switch name {
case "version", "-version", "--version":
fmt.Println("keeporg", Version)
return
case "help", "-h", "-help", "--help":
usage(cmds)
return
}
for _, c := range cmds {
if c.name != name {
@@ -87,3 +101,13 @@ func usage(cmds []command) {
fmt.Fprintf(os.Stderr, "\nRun `keeporg <command> -h` for a command's flags.\n"+
"Configuration lives in %s\n", client.DefaultConfigPath())
}
// executable is this program's resolved path, for login items that must
// survive the symlink a package manager or an app bundle put in front of it.
func executable() (string, error) {
exe, err := os.Executable()
if err != nil {
return "", err
}
return filepath.EvalSymlinks(exe)
}
+229
View File
@@ -0,0 +1,229 @@
//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()
}
}
+14
View File
@@ -0,0 +1,14 @@
//go:build darwin && !cgo
package main
import (
"context"
"errors"
)
// The macOS menu bar is reachable only through Cocoa, which needs cgo. A
// binary cross-compiled without it keeps every other command.
func cmdTray(context.Context, []string) error {
return errors.New("this build has no menu-bar support; build it on macOS with CGO_ENABLED=1, or use `keeporg run`")
}