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
+123
-13
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user