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:])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user