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
+134 -12
View File
@@ -1,22 +1,48 @@
package client
import (
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
)
// Config is what a client remembers between runs. The session token lives
// here; the KDBX master passwords never do, because the client never opens a
// database, it only moves bytes.
// Config is what a client remembers between runs, and every setting a user
// can change. The session token lives here; the KDBX master passwords never
// do, because the client never opens a database, it only moves bytes.
//
// The file is written with every field present, so editing it is the whole
// of the client's settings interface: the tray's "Open Config" opens it, and
// the tray picks up a saved change without restarting.
type Config struct {
// ServerURL is the server's base URL, as a browser sees it.
ServerURL string `json:"server_url"`
Token string `json:"token"`
Username string `json:"username"`
// Username and Token are written by signing in, not by hand.
Username string `json:"username"`
Token string `json:"token"`
// DeviceLabel names this device in the server's session list.
DeviceLabel string `json:"device_label"`
// MirrorDir is where the databases appear as .kdbx files.
MirrorDir string `json:"mirror_dir"`
// PollSeconds is how often the mirror is rescanned for local saves.
PollSeconds int `json:"poll_seconds"`
// RequestTimeoutSeconds bounds one API call. The event stream is exempt.
RequestTimeoutSeconds int `json:"request_timeout_seconds"`
// CAFile is a PEM bundle trusted in addition to the system roots, for a
// server behind a private certificate authority.
CAFile string `json:"ca_file"`
// LogFile is where the agent logs. Empty means standard error for
// `keeporg run`, and keeporg.log beside this file for the tray.
LogFile string `json:"log_file"`
// Notifications turns the tray's desktop notifications on or off.
Notifications bool `json:"notifications"`
}
// ErrNoConfig means the client has not been logged in yet.
@@ -40,7 +66,24 @@ func DefaultMirrorDir() string {
return filepath.Join(home, "KeepOrg")
}
// LoadConfig reads a configuration file.
// DefaultConfig is a configuration with every setting at its default and no
// server.
func DefaultConfig() *Config {
label, err := os.Hostname()
if err != nil || label == "" {
label = "keeporg client"
}
return &Config{
DeviceLabel: label,
MirrorDir: DefaultMirrorDir(),
PollSeconds: 2,
RequestTimeoutSeconds: 60,
Notifications: true,
}
}
// LoadConfig reads a configuration file. A setting the file leaves out keeps
// its default, so an older file reads the same as it always did.
func LoadConfig(path string) (*Config, error) {
b, err := os.ReadFile(path)
if os.IsNotExist(err) {
@@ -49,19 +92,85 @@ func LoadConfig(path string) (*Config, error) {
if err != nil {
return nil, err
}
var c Config
if err := json.Unmarshal(b, &c); err != nil {
return nil, err
c := DefaultConfig()
if err := json.Unmarshal(b, c); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
def := DefaultConfig()
if c.MirrorDir == "" {
c.MirrorDir = DefaultMirrorDir()
c.MirrorDir = def.MirrorDir
}
if c.DeviceLabel == "" {
c.DeviceLabel = def.DeviceLabel
}
if c.PollSeconds <= 0 {
c.PollSeconds = 2
c.PollSeconds = def.PollSeconds
}
return &c, nil
if c.RequestTimeoutSeconds <= 0 {
c.RequestTimeoutSeconds = def.RequestTimeoutSeconds
}
c.ServerURL = strings.TrimRight(c.ServerURL, "/")
c.MirrorDir = expandHome(c.MirrorDir)
c.CAFile = expandHome(c.CAFile)
c.LogFile = expandHome(c.LogFile)
return c, c.Validate()
}
// EnsureConfig returns the configuration at path, writing a default one
// first if there is none, so a user always has a complete file to edit.
func EnsureConfig(path string) (*Config, error) {
c, err := LoadConfig(path)
if !errors.Is(err, ErrNoConfig) {
return c, err
}
c = DefaultConfig()
return c, c.Save(path)
}
// Validate reports the first setting that cannot work.
func (c *Config) Validate() error {
if c.ServerURL != "" {
u, err := url.Parse(c.ServerURL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return fmt.Errorf("server_url %q must be an http:// or https:// URL", c.ServerURL)
}
}
if c.CAFile != "" {
if _, err := os.Stat(c.CAFile); err != nil {
return fmt.Errorf("ca_file: %w", err)
}
}
return nil
}
// API returns a client for the configured server, signed in with the stored
// token when there is one.
func (c *Config) API() (*API, error) {
api := NewAPI(c.ServerURL, c.Token)
api.HTTP.Timeout = time.Duration(c.RequestTimeoutSeconds) * time.Second
if c.CAFile == "" {
return api, nil
}
pem, err := os.ReadFile(c.CAFile)
if err != nil {
return nil, fmt.Errorf("ca_file: %w", err)
}
pool, err := x509.SystemCertPool()
if err != nil {
pool = x509.NewCertPool()
}
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("ca_file %s holds no PEM certificates", c.CAFile)
}
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.TLSClientConfig = &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}
api.HTTP.Transport = tr
return api, nil
}
// Poll is PollSeconds as a duration.
func (c *Config) Poll() time.Duration { return time.Duration(c.PollSeconds) * time.Second }
// Save writes the configuration with owner-only permissions, since it holds
// a bearer token.
func (c *Config) Save(path string) error {
@@ -78,3 +187,16 @@ func (c *Config) Save(path string) error {
}
return os.Rename(tmp, path)
}
// expandHome turns a leading ~ into the home directory, since the file is
// meant to be edited by hand.
func expandHome(p string) string {
if p != "~" && !strings.HasPrefix(p, "~/") && !strings.HasPrefix(p, `~\`) {
return p
}
home, err := os.UserHomeDir()
if err != nil {
return p
}
return filepath.Join(home, p[1:])
}