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>
203 lines
6.0 KiB
Go
203 lines
6.0 KiB
Go
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, 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"`
|
|
// 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.
|
|
var ErrNoConfig = errors.New("not logged in: run `keeporg login` first")
|
|
|
|
// DefaultConfigPath is the per-user configuration path on this platform.
|
|
func DefaultConfigPath() string {
|
|
dir, err := os.UserConfigDir()
|
|
if err != nil {
|
|
return filepath.Join(".", ".keeporg", "config.json")
|
|
}
|
|
return filepath.Join(dir, "keeporg", "config.json")
|
|
}
|
|
|
|
// DefaultMirrorDir is where databases are mirrored unless told otherwise.
|
|
func DefaultMirrorDir() string {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "keeporg"
|
|
}
|
|
return filepath.Join(home, "KeepOrg")
|
|
}
|
|
|
|
// 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) {
|
|
return nil, ErrNoConfig
|
|
}
|
|
if 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 = def.MirrorDir
|
|
}
|
|
if c.DeviceLabel == "" {
|
|
c.DeviceLabel = def.DeviceLabel
|
|
}
|
|
if c.PollSeconds <= 0 {
|
|
c.PollSeconds = def.PollSeconds
|
|
}
|
|
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 {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
return err
|
|
}
|
|
b, err := json.MarshalIndent(c, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmp := path + ".tmp"
|
|
if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil {
|
|
return err
|
|
}
|
|
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:])
|
|
}
|