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
+121
View File
@@ -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()
}