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
+123 -13
View File
@@ -25,13 +25,17 @@ trust boundary, and should treat the host accordingly.
```
cmd/keeporgd/ server entry point: flags, config file, bootstrap
cmd/keeporg/ desktop client: login, run, sync, status, administration
cmd/keeporg/ desktop client: the tray application (agent.go is its
logic, tray.go its menu), login, run, sync, status,
administration, start-at-login per platform
internal/store/ domain model, JSON metadata store, ACL resolution
internal/blob/ versioned KDBX payload storage on disk
internal/kdbx/ KDBX 4 codec and the merge engine, with its own crypto
internal/server/ HTTP surface: auth, OIDC, sync, admin, SSE hub, web,
web administration, page templates
internal/client/ shared client engine: API wrapper, merge policy, mirror
internal/client/ shared client engine: API wrapper, merge policy, mirror,
configuration, the mirror directory lock
internal/icon/ the padlock, drawn in code: tray PNG/ICO, app icons
internal/secret/ password verifiers, tokens, identifiers
internal/totp/ RFC 6238 second factor
android/ Android client: DocumentsProvider, sync service, UI
@@ -39,6 +43,7 @@ Dockerfile the server's container image, built from scratch
packaging/ deb, macOS bundle and DMG, Windows zip, systemd units,
container compose example
scripts/ package builders, version bump, changelog
tools/genicon/ writes the Linux icon theme and the macOS iconset
versions.mk the one place every platform's version lives
Makefile the only entry point for building and publishing
```
@@ -69,8 +74,9 @@ are cheap enough to call per request and easy to test exhaustively.
### Desktop client (`keeporg`)
`keeporg run` keeps a mirror directory (default `~/KeepOrg`) in step with the
server:
One binary, two faces. Started without a command it is a system tray
application; with a command it is the CLI. Both drive the same mirror, which
keeps a directory (default `~/KeepOrg`) in step with the server:
- a rescan every two seconds notices that a KeePass application saved a file;
- the server's event stream delivers other people's pushes immediately;
@@ -81,6 +87,64 @@ All of the synchronization policy is the pure `decide()` table in
`internal/client/decide.go`. Everything else in the mirror applies the action
it returns. To change behaviour, change that table and its test.
**One agent per directory.** `Mirror.Run` takes an OS lock on
`.keeporg.lock` in the mirror directory (`flock`, or an unshared handle on
Windows) and holds it until it stops; `keeporg sync` takes it too. Two agents
on one directory would each take the other's writes for local saves and
push them back, and with a tray, a systemd unit and a Task Scheduler job all
available that is easy to set up by accident. The OS drops the lock when
the process dies, so a crash never leaves a directory locked.
#### The tray
The tray is split so that nothing that decides anything needs a desktop:
- `agent.go` owns the configuration, the mirror's lifecycle, sign-in and
sign-out, and turns the agent's state into a `view` — the status line, the
database list, which items are enabled — in one pure function,
`buildView`. `statusNotice` decides which state changes are worth a
notification: conflicts, kept copies, merges, failures; never a routine
pull. Both are table-tested, and `agent_server_test.go` drives the agent
against a real in-process server through a sync and a server-side
revocation.
- `tray.go` is the menu: it builds the items once, and once a second calls
`agent.tick()` and applies whatever changed in the view. The database
submenu is 32 pre-made items that are retitled, shown and hidden, because
not every tray host copes with items being removed.
Behaviour worth knowing:
- **Sign-in is the device flow** the CLI's `-browser` option uses: the tray
opens the approval page and polls. It covers password, second factor and
single sign-on with no dialog in the tray itself. With no `server_url`
configured, Sign In opens the configuration file instead.
- **The configuration is the settings interface.** The first start writes
`config.json` with every key. A tick notices a saved edit by its
modification time; a file that no longer parses is reported once and the
running configuration kept; the mirror restarts only when a setting it was
built from changed (`mirrorSettingsChanged`). The tray's own writes record
the new modification time, so signing in does not look like an edit.
- **A revoked session signs the device out.** When the server answers
`unauthorized`, the agent stops, clears the token from the file and says
so, rather than retrying every two seconds forever.
- **The mirror's goroutine never takes the agent's lock.** Stopping the
mirror holds that lock while it waits for the goroutine; the goroutine
reports how it ended through its own small `runErr` instead.
- **SIGTERM quits** the same way the menu does, which matters at logout.
- **Start at Login** writes a LaunchAgent (`<AppID>.plist`) on macOS, an XDG
autostart entry on Linux and the BSDs, and the `HKCU\…\Run` value on
Windows, each running `keeporg tray` from the resolved executable path.
- **Platform specifics.** macOS gets a template icon, so the menu bar
recolours it; Windows gets an ICO. Notifications use `osascript` and
`notify-send`; Windows has none yet, and the status line carries the same
news. The About box is AppleScript, zenity or kdialog, or a Windows
message box.
Every client setting, with its default, is in the README's configuration
table and in the comments on `client.Config`. `LoadConfig` reads a file over
a fully defaulted `Config`, so a key a file leaves out keeps its default —
that is how an 0.4 file with no `notifications` key still means `true`.
### Android client
`KeepOrgProvider` is a `DocumentsProvider`, so the mirrored files appear in
@@ -421,13 +485,26 @@ be left behind.
## Decisions worth knowing
**No third-party Go dependencies, no cgo.** The whole server and desktop
client build with `CGO_ENABLED=0` from the standard library: SSE instead of a
WebSocket library, `crypto/pbkdf2` (new in Go 1.24) for account passwords,
hand-written ChaCha20, BLAKE2b and Argon2 for the KDBX format, 60 lines of
HMAC for TOTP, `html/template` for the pages.
Cross-compiling to five platforms is then one `go build` each, and there is
no dependency tree to audit for a program that guards passwords.
**No third-party Go dependencies in the server, no cgo outside the macOS
tray.** The server builds with `CGO_ENABLED=0` from the standard library
alone: SSE instead of a WebSocket library, `crypto/pbkdf2` (new in Go 1.24)
for account passwords, hand-written ChaCha20, BLAKE2b and Argon2 for the
KDBX format, 60 lines of HMAC for TOTP, `html/template` for the pages. There
is no dependency tree to audit in the program that holds the vault keys;
`go list -deps ./cmd/keeporgd` shows only the standard library and this
module, and the container builds without fetching anything.
The desktop client takes exactly one, `fyne.io/systray` (with
`godbus/dbus` and `golang.org/x/sys` under it), because a tray icon cannot
be had otherwise for less than several thousand lines: Linux and the BSDs
speak StatusNotifierItem over D-Bus, Windows needs Win32, and the macOS menu
bar is reachable only through Cocoa. It was chosen over
`getlantern/systray`, which `midi-webhook` uses, because it is maintained and
needs no cgo or GTK on Linux: Linux, BSD and Windows clients still
cross-compile from anywhere. Only macOS needs cgo, so the Makefile links the
darwin client with cgo when it is built on a Mac; built elsewhere it keeps
every command but `tray` (`tray_nocgo.go`). The client only moves
ciphertext, so the trade is in the program that never sees a key.
**A JSON document instead of SQLite.** The metadata is hundreds of records,
never thousands: users, groups, grants. One writer with atomic rename gives
@@ -616,7 +693,19 @@ Application identifiers follow `cc.tiredsysadmin.<os>.<app>`:
`.windows.keeporg`, `.bsd.keeporg`, and `.linux.keeporgd` for the server
package.
Desktop and server artifacts stay local (`dist/`). Android publishing is
Desktop and server artifacts stay local (`dist/`):
- `make dmg` (on a Mac): `KeepOrg.app`, whose executable is the client
itself, with an icon made by `iconutil` from `tools/genicon`'s iconset and
`LSUIElement` set, so there is a menu-bar item and no Dock icon.
- `make deb`: `/usr/bin/keeporg`, a desktop entry that starts the tray, the
hicolor icons, and the systemd user unit for headless `keeporg run`.
- `make windows`: `keeporg.exe`, a console program for the commands, and
`keeporg-tray.exe`, the same code linked `-H windowsgui` so the tray opens
no console window. They cannot be called `keeporg.exe` and `KeepOrg.exe`:
Windows file names are case-insensitive.
Android publishing is
manual, never automatic:
```sh
@@ -661,7 +750,18 @@ What the suite actually covers:
- `internal/client` for the decision table, and for the mirror driven against
a real in-process server: adopting a local file, following a remote push,
server-wins, client-wins, manual, read-only restore, server-side rename,
and an update arriving over the event stream.
and an update arriving over the event stream; for the configuration's
defaults, validation and `~` expansion; and for the mirror lock admitting
one agent.
- `cmd/keeporg` for the tray's status line and database list, which state
changes notify, configuration reload and a broken edit, the notifications
setting, and the agent against a real server through a sync and a
revocation.
- `internal/icon` for the padlock's shape and the ICO container.
The tray's menu itself has no automated test: it needs a desktop session.
It was run on macOS against a local server; on Linux and Windows it has been
compiled, not run.
The Android module is checked by `make android-lint`, which is kept at zero
findings, and `make android` builds the unsigned release APK. See
@@ -670,6 +770,16 @@ what an arm64 Linux build host needs.
## Known limits and the next things to do
- **The tray has only been run on macOS.** The Linux, BSD and Windows builds
compile and their logic is tested, but nobody has looked at the icon in a
KDE panel or the Windows notification area yet.
- **GNOME shows no tray** without the AppIndicator extension. `keeporg run`
or the systemd user unit work there regardless.
- **No notifications on Windows.** A toast needs either a COM interop layer
or PowerShell; the status line shows the same states meanwhile.
- **The DMG is per architecture.** A universal `.app` needs `lipo` over both
cgo builds, which `midi-webhook`'s Makefile shows how to do.
- **One server node.** The JSON store is single-writer by design. Horizontal
scale means replacing `internal/store`.
- **Merging requires the server to hold the vault key.** That is inherent, not