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
+134
-12
@@ -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:])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnsureConfigWritesEverySetting(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "keeporg", "config.json")
|
||||
c, err := EnsureConfig(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.PollSeconds != 2 || c.RequestTimeoutSeconds != 60 || !c.Notifications {
|
||||
t.Fatalf("defaults not applied: %+v", c)
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var keys map[string]any
|
||||
if err := json.Unmarshal(b, &keys); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, k := range []string{"server_url", "username", "token", "device_label", "mirror_dir",
|
||||
"poll_seconds", "request_timeout_seconds", "ca_file", "log_file", "notifications"} {
|
||||
if _, ok := keys[k]; !ok {
|
||||
t.Errorf("written config lacks %q", k)
|
||||
}
|
||||
}
|
||||
if fi, _ := os.Stat(path); fi.Mode().Perm() != 0o600 {
|
||||
t.Errorf("config mode %v, want 0600", fi.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigKeepsDefaultsForMissingKeys(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
// What a 0.4 client wrote: no notifications key, which must not read as
|
||||
// false.
|
||||
old := `{"server_url":"https://k.example.com/","token":"t","username":"u","mirror_dir":"","poll_seconds":0}`
|
||||
if err := os.WriteFile(path, []byte(old), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := LoadConfig(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !c.Notifications || c.PollSeconds != 2 || c.MirrorDir == "" || c.DeviceLabel == "" {
|
||||
t.Fatalf("defaults lost: %+v", c)
|
||||
}
|
||||
if c.ServerURL != "https://k.example.com" {
|
||||
t.Fatalf("trailing slash kept: %q", c.ServerURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigExpandsHome(t *testing.T) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"mirror_dir":"~/Vaults","log_file":"~/keeporg.log"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := LoadConfig(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.MirrorDir != filepath.Join(home, "Vaults") || c.LogFile != filepath.Join(home, "keeporg.log") {
|
||||
t.Fatalf("~ not expanded: %q %q", c.MirrorDir, c.LogFile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigRejectsUnusableSettings(t *testing.T) {
|
||||
for name, body := range map[string]string{
|
||||
"scheme": `{"server_url":"keeporg.example.com"}`,
|
||||
"ca_file": `{"ca_file":"/nonexistent/ca.pem"}`,
|
||||
"syntax": `{"server_url":`,
|
||||
} {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadConfig(path); err == nil || errors.Is(err, ErrNoConfig) {
|
||||
t.Errorf("%s: want a validation error, got %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigAPIRejectsEmptyCAFile(t *testing.T) {
|
||||
ca := filepath.Join(t.TempDir(), "ca.pem")
|
||||
if err := os.WriteFile(ca, []byte("not a certificate"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := DefaultConfig()
|
||||
c.ServerURL, c.CAFile = "https://k.example.com", ca
|
||||
if _, err := c.API(); err == nil || !strings.Contains(err.Error(), "no PEM") {
|
||||
t.Fatalf("want a PEM error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockDirAdmitsOneAgent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
unlock, err := LockDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LockDir(dir); !errors.Is(err, ErrMirrorBusy) {
|
||||
t.Fatalf("second lock: want ErrMirrorBusy, got %v", err)
|
||||
}
|
||||
unlock()
|
||||
again, err := LockDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("lock after release: %v", err)
|
||||
}
|
||||
again()
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// lockFile marks a mirror directory as being synchronized by one agent. Two
|
||||
// agents on one directory would each think the other's writes were local
|
||||
// saves and push them back, so the tray, `keeporg run` and a service unit
|
||||
// started side by side must not all sync.
|
||||
const lockFile = ".keeporg.lock"
|
||||
|
||||
// ErrMirrorBusy means another agent already holds the mirror directory.
|
||||
var ErrMirrorBusy = errors.New("another keeporg agent is already synchronizing this directory")
|
||||
|
||||
// LockDir takes the mirror directory's lock for this process. The operating
|
||||
// system releases it when the process exits, however it exits, so a crash
|
||||
// never leaves a directory locked.
|
||||
func LockDir(dir string) (unlock func(), err error) {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path := filepath.Join(dir, lockFile)
|
||||
unlock, err = lockPath(path)
|
||||
if errors.Is(err, ErrMirrorBusy) {
|
||||
return nil, fmt.Errorf("%s: %w", dir, err)
|
||||
}
|
||||
return unlock, err
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
//go:build !(linux || darwin || freebsd || openbsd || netbsd || dragonfly || windows)
|
||||
|
||||
package client
|
||||
|
||||
// lockPath is a no-op where there is no advisory lock to take.
|
||||
func lockPath(string) (func(), error) { return func() {}, nil }
|
||||
@@ -0,0 +1,24 @@
|
||||
//go:build linux || darwin || freebsd || openbsd || netbsd || dragonfly
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func lockPath(path string) (func(), error) {
|
||||
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
|
||||
f.Close()
|
||||
if errors.Is(err, syscall.EWOULDBLOCK) {
|
||||
return nil, ErrMirrorBusy
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return func() { f.Close() }, nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//go:build windows
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// lockPath opens the file with no sharing at all, which Windows enforces
|
||||
// until the handle closes.
|
||||
func lockPath(path string) (func(), error) {
|
||||
p, err := syscall.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h, err := syscall.CreateFile(p, syscall.GENERIC_READ|syscall.GENERIC_WRITE,
|
||||
0, nil, syscall.OPEN_ALWAYS, syscall.FILE_ATTRIBUTE_HIDDEN, 0)
|
||||
if err != nil {
|
||||
if errors.Is(err, errSharingViolation) {
|
||||
return nil, ErrMirrorBusy
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return func() { syscall.CloseHandle(h) }, nil
|
||||
}
|
||||
|
||||
// errSharingViolation is ERROR_SHARING_VIOLATION, which package syscall
|
||||
// does not name.
|
||||
const errSharingViolation = syscall.Errno(32)
|
||||
+43
-12
@@ -57,9 +57,17 @@ type Mirror struct {
|
||||
log *log.Logger
|
||||
poll time.Duration
|
||||
|
||||
// kick asks Run for a reconcile now. It holds at most one request, so
|
||||
// a burst of them costs one pass.
|
||||
kick chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
st map[string]*fileState
|
||||
status map[string]*Status
|
||||
// lastSync and lastErr describe the most recent SyncAll as a whole, which
|
||||
// a per-database status cannot: an unreachable server has no databases.
|
||||
lastSync time.Time
|
||||
lastErr error
|
||||
}
|
||||
|
||||
// NewMirror prepares dir and loads any previous state.
|
||||
@@ -70,7 +78,7 @@ func NewMirror(api *API, dir string, lg *log.Logger, poll time.Duration) (*Mirro
|
||||
if poll <= 0 {
|
||||
poll = 2 * time.Second
|
||||
}
|
||||
m := &Mirror{api: api, dir: dir, log: lg, poll: poll,
|
||||
m := &Mirror{api: api, dir: dir, log: lg, poll: poll, kick: make(chan struct{}, 1),
|
||||
st: map[string]*fileState{}, status: map[string]*Status{}}
|
||||
b, err := os.ReadFile(filepath.Join(dir, stateFile))
|
||||
switch {
|
||||
@@ -88,18 +96,26 @@ func NewMirror(api *API, dir string, lg *log.Logger, poll time.Duration) (*Mirro
|
||||
// Dir is the mirror directory.
|
||||
func (m *Mirror) Dir() string { return m.dir }
|
||||
|
||||
// Run reconciles until ctx is done, driven by server push events and by a
|
||||
// local rescan for saves made by a KeePass application.
|
||||
func (m *Mirror) Run(ctx context.Context) error {
|
||||
trigger := make(chan struct{}, 1)
|
||||
wake := func() {
|
||||
select {
|
||||
case trigger <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
// Kick asks a running mirror to reconcile now instead of at the next rescan.
|
||||
func (m *Mirror) Kick() {
|
||||
select {
|
||||
case m.kick <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
go m.listen(ctx, wake)
|
||||
// Run reconciles until ctx is done, driven by server push events and by a
|
||||
// local rescan for saves made by a KeePass application. It holds the mirror
|
||||
// directory's lock throughout, so a second agent on the same directory fails
|
||||
// to start instead of racing this one.
|
||||
func (m *Mirror) Run(ctx context.Context) error {
|
||||
unlock, err := LockDir(m.dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
go m.listen(ctx, m.Kick)
|
||||
|
||||
if err := m.SyncAll(ctx); err != nil {
|
||||
m.log.Printf("sync: %v", err)
|
||||
@@ -114,7 +130,7 @@ func (m *Mirror) Run(ctx context.Context) error {
|
||||
if err := m.SyncAll(ctx); err != nil {
|
||||
m.log.Printf("sync: %v", err)
|
||||
}
|
||||
case <-trigger:
|
||||
case <-m.kick:
|
||||
// Let a burst of events settle before reconciling.
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
if err := m.SyncAll(ctx); err != nil {
|
||||
@@ -155,10 +171,14 @@ func (m *Mirror) listen(ctx context.Context, wake func()) {
|
||||
func (m *Mirror) SyncAll(ctx context.Context) error {
|
||||
dbs, err := m.api.Databases(ctx)
|
||||
if err != nil {
|
||||
m.mu.Lock()
|
||||
m.lastSync, m.lastErr = time.Now(), err
|
||||
m.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
defer func() { m.lastSync = time.Now() }()
|
||||
|
||||
seen := map[string]bool{}
|
||||
var firstErr error
|
||||
@@ -184,9 +204,20 @@ func (m *Mirror) SyncAll(ctx context.Context) error {
|
||||
if err := m.saveState(); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
// A failure of one database is in its own status; lastErr is for what
|
||||
// stopped the whole pass.
|
||||
m.lastErr = nil
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// LastSync reports when the last pass finished, and the error that stopped
|
||||
// it from reaching the server, if one did.
|
||||
func (m *Mirror) LastSync() (time.Time, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.lastSync, m.lastErr
|
||||
}
|
||||
|
||||
// Status reports the current state of each mirrored database.
|
||||
func (m *Mirror) Status() []Status {
|
||||
m.mu.Lock()
|
||||
|
||||
Reference in New Issue
Block a user