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
@@ -0,0 +1,36 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// showAbout shows a native About dialog: AppleScript on macOS, zenity or
|
||||
// kdialog elsewhere, and the homepage when neither is installed.
|
||||
func showAbout(configPath string) {
|
||||
text := aboutText(configPath)
|
||||
if runtime.GOOS == "darwin" {
|
||||
go func() {
|
||||
out, _ := exec.Command("osascript", "-e", fmt.Sprintf(
|
||||
`button returned of (display dialog %q with title "About KeepOrg" buttons {"Homepage", "OK"} default button "OK" with icon note)`,
|
||||
text)).Output()
|
||||
if strings.TrimSpace(string(out)) == "Homepage" {
|
||||
openPath(homepage)
|
||||
}
|
||||
}()
|
||||
return
|
||||
}
|
||||
if p, err := exec.LookPath("zenity"); err == nil {
|
||||
go exec.Command(p, "--info", "--title=About KeepOrg", "--no-wrap", "--text="+text).Run()
|
||||
return
|
||||
}
|
||||
if p, err := exec.LookPath("kdialog"); err == nil {
|
||||
go exec.Command(p, "--title", "About KeepOrg", "--msgbox", text).Run()
|
||||
return
|
||||
}
|
||||
openPath(homepage)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import "golang.org/x/sys/windows"
|
||||
|
||||
// showAbout shows a message box. It runs on its own goroutine because the
|
||||
// box is modal and the tray's event loop must keep running.
|
||||
func showAbout(configPath string) {
|
||||
text, _ := windows.UTF16PtrFromString(aboutText(configPath))
|
||||
title, _ := windows.UTF16PtrFromString("About KeepOrg")
|
||||
go windows.MessageBox(0, text, title, windows.MB_OK|windows.MB_ICONINFORMATION)
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cc.tiredsysadmin.keeporg/internal/client"
|
||||
)
|
||||
|
||||
// agent is the tray application without the tray: the configuration, the
|
||||
// mirror's lifecycle and sign-in. The menu only reads view() and calls the
|
||||
// methods below, so everything that decides anything is testable without a
|
||||
// desktop session.
|
||||
type agent struct {
|
||||
path string
|
||||
log *log.Logger
|
||||
sink *logSink
|
||||
notify func(title, message string)
|
||||
|
||||
mu sync.Mutex
|
||||
cfg *client.Config
|
||||
cfgMod time.Time
|
||||
cfgErr error
|
||||
mir *client.Mirror
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
// run is why the current mirror stopped, or could not start. It has its
|
||||
// own lock because the mirror's goroutine sets it, and that goroutine
|
||||
// must never wait on mu: stopping the mirror holds mu while it waits for
|
||||
// the goroutine to finish.
|
||||
run *runErr
|
||||
pending string // the user code of a browser sign-in in progress
|
||||
expired bool // the server rejected the stored token
|
||||
statuses map[string]client.Status
|
||||
}
|
||||
|
||||
func newAgent(path string, notify func(title, message string)) *agent {
|
||||
sink := &logSink{}
|
||||
return &agent{path: path, sink: sink, log: log.New(sink, "", log.LstdFlags),
|
||||
notify: notify, run: &runErr{}, statuses: map[string]client.Status{}}
|
||||
}
|
||||
|
||||
type runErr struct {
|
||||
mu sync.Mutex
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *runErr) set(err error) {
|
||||
r.mu.Lock()
|
||||
r.err = err
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *runErr) get() error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.err
|
||||
}
|
||||
|
||||
// start loads the configuration, writing a complete default one on first
|
||||
// run, and starts synchronizing if this device is signed in.
|
||||
func (a *agent) start() {
|
||||
cfg, err := client.EnsureConfig(a.path)
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.cfgMod = modTime(a.path)
|
||||
if cfg == nil {
|
||||
cfg = client.DefaultConfig()
|
||||
}
|
||||
a.cfg, a.cfgErr = cfg, err
|
||||
a.openLogLocked()
|
||||
if err != nil {
|
||||
a.log.Printf("config: %v", err)
|
||||
return
|
||||
}
|
||||
a.startMirrorLocked()
|
||||
}
|
||||
|
||||
// stop ends synchronization, for quitting.
|
||||
func (a *agent) stop() {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.stopMirrorLocked()
|
||||
a.sink.set(nil)
|
||||
}
|
||||
|
||||
func (a *agent) startMirrorLocked() {
|
||||
if a.cancel != nil || a.cfg.Token == "" || a.cfg.ServerURL == "" {
|
||||
return
|
||||
}
|
||||
a.run = &runErr{}
|
||||
api, err := a.cfg.API()
|
||||
if err != nil {
|
||||
a.run.set(err)
|
||||
return
|
||||
}
|
||||
mir, err := client.NewMirror(api, a.cfg.MirrorDir, a.log, a.cfg.Poll())
|
||||
if err != nil {
|
||||
a.run.set(err)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
run, notify := a.run, a.cfg.Notifications
|
||||
a.mir, a.cancel, a.done, a.expired = mir, cancel, done, false
|
||||
a.log.Printf("mirroring %s into %s", a.cfg.ServerURL, a.cfg.MirrorDir)
|
||||
go func() {
|
||||
defer close(done)
|
||||
err := mir.Run(ctx)
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
a.log.Printf("agent stopped: %v", err)
|
||||
run.set(err)
|
||||
if notify && errors.Is(err, client.ErrMirrorBusy) {
|
||||
a.notify("KeepOrg is already running", err.Error())
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (a *agent) stopMirrorLocked() {
|
||||
if a.cancel == nil {
|
||||
return
|
||||
}
|
||||
a.cancel()
|
||||
<-a.done
|
||||
a.mir, a.cancel, a.done = nil, nil, nil
|
||||
a.statuses = map[string]client.Status{}
|
||||
}
|
||||
|
||||
// reload rereads the configuration when the file changed, and restarts the
|
||||
// mirror if a setting it depends on did. A file that no longer parses is
|
||||
// reported and the running configuration kept.
|
||||
func (a *agent) reload() {
|
||||
mod := modTime(a.path)
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if mod.Equal(a.cfgMod) {
|
||||
return
|
||||
}
|
||||
a.cfgMod = mod
|
||||
cfg, err := client.LoadConfig(a.path)
|
||||
if err != nil {
|
||||
if a.cfgErr == nil || a.cfgErr.Error() != err.Error() {
|
||||
a.log.Printf("config: %v", err)
|
||||
a.sayLocked("KeepOrg configuration error", err.Error())
|
||||
}
|
||||
a.cfgErr = err
|
||||
return
|
||||
}
|
||||
old := a.cfg
|
||||
a.cfg, a.cfgErr = cfg, nil
|
||||
if old.LogFile != cfg.LogFile {
|
||||
a.openLogLocked()
|
||||
}
|
||||
if mirrorSettingsChanged(old, cfg) || a.cancel == nil {
|
||||
a.log.Printf("config: reloaded")
|
||||
a.stopMirrorLocked()
|
||||
a.startMirrorLocked()
|
||||
}
|
||||
}
|
||||
|
||||
// mirrorSettingsChanged reports whether the running mirror was built from
|
||||
// settings that differ in b.
|
||||
func mirrorSettingsChanged(a, b *client.Config) bool {
|
||||
return a.ServerURL != b.ServerURL || a.Token != b.Token || a.MirrorDir != b.MirrorDir ||
|
||||
a.PollSeconds != b.PollSeconds || a.RequestTimeoutSeconds != b.RequestTimeoutSeconds ||
|
||||
a.CAFile != b.CAFile
|
||||
}
|
||||
|
||||
// tick runs once a second: it notices configuration edits, a revoked
|
||||
// session, and status changes worth a notification.
|
||||
func (a *agent) tick() {
|
||||
a.reload()
|
||||
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.mir == nil {
|
||||
return
|
||||
}
|
||||
if _, err := a.mir.LastSync(); client.Code(err) == "unauthorized" {
|
||||
a.log.Printf("the server no longer accepts this device's session")
|
||||
a.stopMirrorLocked()
|
||||
a.cfg.Token, a.expired = "", true
|
||||
a.saveLocked()
|
||||
a.sayLocked("KeepOrg signed out", "The session expired or was revoked. Sign in again from the menu.")
|
||||
return
|
||||
}
|
||||
next := map[string]client.Status{}
|
||||
for _, s := range a.mir.Status() {
|
||||
next[s.DatabaseID] = s
|
||||
if title, msg, ok := statusNotice(a.statuses[s.DatabaseID], s); ok {
|
||||
a.sayLocked(title, msg)
|
||||
}
|
||||
}
|
||||
a.statuses = next
|
||||
}
|
||||
|
||||
// statusNotice decides whether a database's change of state is worth
|
||||
// interrupting someone for. Routine pulls and pushes are not.
|
||||
func statusNotice(prev, cur client.Status) (title, msg string, ok bool) {
|
||||
if prev.State == cur.State && prev.Message == cur.Message {
|
||||
return "", "", false
|
||||
}
|
||||
switch cur.State {
|
||||
case "conflict":
|
||||
return cur.Name + ": conflict", cur.Message, true
|
||||
case "conflict-resolved":
|
||||
return cur.Name + ": your copy was kept aside", cur.Message, true
|
||||
case "merged":
|
||||
return cur.Name + ": merged", "Your save was merged with a colleague's edit.", true
|
||||
case "error":
|
||||
return cur.Name + ": synchronization failed", cur.Message, true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// syncNow asks the mirror for an immediate pass.
|
||||
func (a *agent) syncNow() {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.mir != nil {
|
||||
a.mir.Kick()
|
||||
}
|
||||
}
|
||||
|
||||
// signIn runs the browser-approved device flow, which serves passwords,
|
||||
// second factors and single sign-on alike, so the tray needs no dialogs.
|
||||
func (a *agent) signIn(ctx context.Context, open func(string)) error {
|
||||
a.mu.Lock()
|
||||
if a.pending != "" {
|
||||
a.mu.Unlock()
|
||||
return errors.New("a sign-in is already waiting for approval")
|
||||
}
|
||||
if a.cfgErr != nil {
|
||||
a.mu.Unlock()
|
||||
return a.cfgErr
|
||||
}
|
||||
if a.cfg.ServerURL == "" {
|
||||
a.mu.Unlock()
|
||||
return errNoServer
|
||||
}
|
||||
cfg := *a.cfg
|
||||
a.mu.Unlock()
|
||||
|
||||
cfg.Token = ""
|
||||
api, err := cfg.API()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
start, err := api.DeviceStart(ctx, cfg.DeviceLabel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.setPending(start.UserCode)
|
||||
defer a.setPending("")
|
||||
open(start.VerificationURL)
|
||||
|
||||
interval := time.Duration(max(start.Interval, 1)) * time.Second
|
||||
deadline := time.Now().Add(time.Duration(max(start.ExpiresIn, 60)) * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(interval):
|
||||
}
|
||||
sess, err := api.DevicePoll(ctx, start.DeviceCode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sess == nil {
|
||||
continue
|
||||
}
|
||||
a.mu.Lock()
|
||||
a.cfg.Token, a.cfg.Username = sess.Token, sess.User.Username
|
||||
a.saveLocked()
|
||||
a.stopMirrorLocked()
|
||||
a.startMirrorLocked()
|
||||
a.mu.Unlock()
|
||||
a.log.Printf("signed in as %s", sess.User.Username)
|
||||
return nil
|
||||
}
|
||||
return errors.New("the approval window expired")
|
||||
}
|
||||
|
||||
var errNoServer = errors.New("no server is configured: set server_url in the configuration file, save it, and sign in again")
|
||||
|
||||
func (a *agent) setPending(code string) {
|
||||
a.mu.Lock()
|
||||
a.pending = code
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
// signOut revokes the session on the server and forgets it here. The files
|
||||
// stay where they are.
|
||||
func (a *agent) signOut(ctx context.Context) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.stopMirrorLocked()
|
||||
if api, err := a.cfg.API(); err == nil && a.cfg.Token != "" {
|
||||
if err := api.Logout(ctx); err != nil {
|
||||
a.log.Printf("revoking the session on the server failed: %v", err)
|
||||
}
|
||||
}
|
||||
a.cfg.Token = ""
|
||||
a.saveLocked()
|
||||
a.log.Printf("signed out")
|
||||
}
|
||||
|
||||
// saveLocked writes the configuration and records the write, so the next
|
||||
// reload does not mistake it for an edit.
|
||||
func (a *agent) saveLocked() {
|
||||
if err := a.cfg.Save(a.path); err != nil {
|
||||
a.log.Printf("config: %v", err)
|
||||
}
|
||||
a.cfgMod = modTime(a.path)
|
||||
}
|
||||
|
||||
func (a *agent) sayLocked(title, msg string) {
|
||||
if a.cfg == nil || a.cfg.Notifications {
|
||||
a.notify(title, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// logPath is where the tray logs: the configured file, or keeporg.log beside
|
||||
// the configuration, since a tray application has no terminal.
|
||||
func (a *agent) logPath() string {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.logPathLocked()
|
||||
}
|
||||
|
||||
func (a *agent) logPathLocked() string {
|
||||
if a.cfg != nil && a.cfg.LogFile != "" {
|
||||
return a.cfg.LogFile
|
||||
}
|
||||
return filepath.Join(filepath.Dir(a.path), "keeporg.log")
|
||||
}
|
||||
|
||||
func (a *agent) openLogLocked() {
|
||||
f, err := openLog(a.logPathLocked())
|
||||
if err != nil {
|
||||
a.sink.set(nil)
|
||||
fmt.Fprintln(os.Stderr, "keeporg: log:", err)
|
||||
return
|
||||
}
|
||||
a.sink.set(f)
|
||||
}
|
||||
|
||||
func modTime(path string) time.Time {
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return fi.ModTime()
|
||||
}
|
||||
|
||||
// logSink is a log destination that can be swapped while loggers hold it.
|
||||
type logSink struct {
|
||||
mu sync.Mutex
|
||||
w io.WriteCloser
|
||||
}
|
||||
|
||||
func (s *logSink) set(w io.WriteCloser) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.w != nil {
|
||||
s.w.Close()
|
||||
}
|
||||
s.w = w
|
||||
}
|
||||
|
||||
func (s *logSink) Write(p []byte) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.w == nil {
|
||||
return os.Stderr.Write(p)
|
||||
}
|
||||
return s.w.Write(p)
|
||||
}
|
||||
|
||||
// view is what the menu shows, decided in one place.
|
||||
type view struct {
|
||||
Header string
|
||||
Tooltip string
|
||||
SignedIn bool
|
||||
Pending bool
|
||||
MirrorDir string
|
||||
WebURL string
|
||||
Databases []dbItem
|
||||
}
|
||||
|
||||
type dbItem struct {
|
||||
Label string
|
||||
Path string
|
||||
}
|
||||
|
||||
// snapshot is the agent's state at one moment, for buildView.
|
||||
type snapshot struct {
|
||||
cfg client.Config
|
||||
cfgErr error
|
||||
pending string
|
||||
expired bool
|
||||
running bool
|
||||
runErr error
|
||||
lastSync time.Time
|
||||
lastErr error
|
||||
statuses []client.Status
|
||||
}
|
||||
|
||||
func (a *agent) view() view {
|
||||
a.mu.Lock()
|
||||
s := snapshot{cfgErr: a.cfgErr, pending: a.pending, expired: a.expired,
|
||||
running: a.mir != nil, runErr: a.run.get()}
|
||||
if a.cfg != nil {
|
||||
s.cfg = *a.cfg
|
||||
}
|
||||
if a.mir != nil {
|
||||
s.lastSync, s.lastErr = a.mir.LastSync()
|
||||
s.statuses = a.mir.Status()
|
||||
}
|
||||
a.mu.Unlock()
|
||||
return buildView(s)
|
||||
}
|
||||
|
||||
func buildView(s snapshot) view {
|
||||
v := view{SignedIn: s.cfg.Token != "", Pending: s.pending != "", MirrorDir: s.cfg.MirrorDir}
|
||||
if s.cfg.ServerURL != "" {
|
||||
v.WebURL = s.cfg.ServerURL + "/web"
|
||||
}
|
||||
attention := 0
|
||||
for _, st := range s.statuses {
|
||||
label := st.Name
|
||||
switch st.State {
|
||||
case "ok":
|
||||
label = "✓ " + label
|
||||
case "merged":
|
||||
label = "✓ " + label + " (merged)"
|
||||
case "conflict":
|
||||
label, attention = "⚠ "+label+": conflict", attention+1
|
||||
case "conflict-resolved":
|
||||
label, attention = "⚠ "+label+": your copy kept aside", attention+1
|
||||
case "error":
|
||||
label, attention = "✗ "+label+": not synchronized", attention+1
|
||||
}
|
||||
if st.Perm == "read" {
|
||||
label += " (read-only)"
|
||||
}
|
||||
item := dbItem{Label: label}
|
||||
if st.File != "" {
|
||||
item.Path = filepath.Join(s.cfg.MirrorDir, st.File)
|
||||
}
|
||||
v.Databases = append(v.Databases, item)
|
||||
}
|
||||
|
||||
switch {
|
||||
case s.cfgErr != nil:
|
||||
v.Header = "Configuration error, see Open Config"
|
||||
case s.pending != "":
|
||||
v.Header = "Approve code " + s.pending + " in the browser"
|
||||
case s.cfg.ServerURL == "":
|
||||
v.Header = "No server configured"
|
||||
case s.expired:
|
||||
v.Header = "Session expired, sign in again"
|
||||
case !v.SignedIn:
|
||||
v.Header = "Signed out"
|
||||
case errors.Is(s.runErr, client.ErrMirrorBusy):
|
||||
v.Header = "Another KeepOrg agent is syncing"
|
||||
case s.runErr != nil:
|
||||
v.Header = "Stopped: " + s.runErr.Error()
|
||||
case !s.running || s.lastSync.IsZero():
|
||||
v.Header = "Connecting…"
|
||||
case s.lastErr != nil:
|
||||
v.Header = "Offline, retrying"
|
||||
case attention > 0:
|
||||
v.Header = fmt.Sprintf("%s · %d need attention", s.cfg.Username, attention)
|
||||
default:
|
||||
v.Header = s.cfg.Username + " · up to date"
|
||||
}
|
||||
v.Tooltip = "KeepOrg " + Version + "\n" + v.Header
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cc.tiredsysadmin.keeporg/internal/client"
|
||||
"cc.tiredsysadmin.keeporg/internal/kdbx"
|
||||
"cc.tiredsysadmin.keeporg/internal/secret"
|
||||
"cc.tiredsysadmin.keeporg/internal/server"
|
||||
"cc.tiredsysadmin.keeporg/internal/store"
|
||||
)
|
||||
|
||||
// TestAgentAgainstAServer drives the tray's agent against a real server:
|
||||
// it syncs a database into the mirror, and signs itself out when the server
|
||||
// revokes its session.
|
||||
func TestAgentAgainstAServer(t *testing.T) {
|
||||
srv, err := server.New(server.Config{
|
||||
DataDir: t.TempDir(),
|
||||
MasterKeyFile: filepath.Join(t.TempDir(), "master.key"),
|
||||
VaultKDF: kdbx.KDFParams{MemoryKiB: 64, Iterations: 1, Lanes: 1},
|
||||
}, log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := secret.Hash("admin-password-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := srv.Store().Update(func(st *store.State) error {
|
||||
st.Users["admin"] = &store.User{ID: "admin", Username: "admin", PassHash: hash, ServerAdmin: true}
|
||||
st.Groups["everyone"] = &store.Group{ID: "everyone", Name: "everyone"}
|
||||
st.Memberships = append(st.Memberships,
|
||||
store.Membership{UserID: "admin", GroupID: "everyone", Role: store.RoleAdmin})
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
ctx := context.Background()
|
||||
admin := client.NewAPI(ts.URL, "")
|
||||
sess, err := admin.Login(ctx, "admin", "admin-password-1", "", "admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin.Token = sess.Token
|
||||
if _, err := admin.CreateVault(ctx, "Team Vault", "everyone", "merge", "vault-password-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
device, err := client.NewAPI(ts.URL, "").Login(ctx, "admin", "admin-password-1", "", "tray")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := client.DefaultConfig()
|
||||
cfg.ServerURL, cfg.Token, cfg.Username = ts.URL, device.Token, "admin"
|
||||
cfg.MirrorDir = t.TempDir()
|
||||
a, said := newTestAgent(t, cfg)
|
||||
|
||||
waitFor(t, func() bool { a.tick(); return a.view().Header == "admin · up to date" })
|
||||
v := a.view()
|
||||
if len(v.Databases) != 1 || v.Databases[0].Path != filepath.Join(cfg.MirrorDir, "Team Vault.kdbx") {
|
||||
t.Fatalf("databases %+v", v.Databases)
|
||||
}
|
||||
|
||||
// Revoke the device's session from the server side.
|
||||
if err := client.NewAPI(ts.URL, device.Token).Logout(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.syncNow()
|
||||
waitFor(t, func() bool { a.tick(); return strings.HasPrefix(a.view().Header, "Session expired") })
|
||||
if got, _ := client.LoadConfig(a.path); got.Token != "" {
|
||||
t.Error("the revoked token was kept in the config")
|
||||
}
|
||||
if len(*said) == 0 || !strings.Contains((*said)[len(*said)-1], "signed out") {
|
||||
t.Errorf("no sign-out notification: %v", *said)
|
||||
}
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for !cond() {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("timed out")
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cc.tiredsysadmin.keeporg/internal/client"
|
||||
)
|
||||
|
||||
func TestBuildViewHeader(t *testing.T) {
|
||||
signed := client.Config{ServerURL: "https://k.example.com", Token: "t", Username: "alice", MirrorDir: "/m"}
|
||||
now := time.Now()
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
s snapshot
|
||||
want string
|
||||
}{
|
||||
{"config error", snapshot{cfg: signed, cfgErr: errors.New("x")}, "Configuration error"},
|
||||
{"no server", snapshot{}, "No server configured"},
|
||||
{"pending", snapshot{cfg: client.Config{ServerURL: "https://k"}, pending: "ABCD"}, "Approve code ABCD"},
|
||||
{"signed out", snapshot{cfg: client.Config{ServerURL: "https://k"}}, "Signed out"},
|
||||
{"expired", snapshot{cfg: client.Config{ServerURL: "https://k"}, expired: true}, "Session expired"},
|
||||
{"busy", snapshot{cfg: signed, runErr: client.ErrMirrorBusy}, "Another KeepOrg agent"},
|
||||
{"connecting", snapshot{cfg: signed, running: true}, "Connecting"},
|
||||
{"offline", snapshot{cfg: signed, running: true, lastSync: now, lastErr: errors.New("dial")}, "Offline"},
|
||||
{"attention", snapshot{cfg: signed, running: true, lastSync: now,
|
||||
statuses: []client.Status{{Name: "A", State: "conflict"}, {Name: "B", State: "ok"}}}, "alice · 1 need attention"},
|
||||
{"fine", snapshot{cfg: signed, running: true, lastSync: now,
|
||||
statuses: []client.Status{{Name: "A", State: "ok"}}}, "alice · up to date"},
|
||||
} {
|
||||
if got := buildView(c.s).Header; !strings.HasPrefix(got, c.want) {
|
||||
t.Errorf("%s: header %q, want prefix %q", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildViewDatabases(t *testing.T) {
|
||||
v := buildView(snapshot{cfg: client.Config{ServerURL: "https://k.example.com", MirrorDir: "/m"},
|
||||
statuses: []client.Status{
|
||||
{Name: "Team", File: "Team.kdbx", State: "ok", Perm: "write"},
|
||||
{Name: "Ops", File: "Ops.kdbx", State: "conflict-resolved", Perm: "read"},
|
||||
{Name: "New", State: "ok"},
|
||||
}})
|
||||
if v.WebURL != "https://k.example.com/web" {
|
||||
t.Errorf("web url %q", v.WebURL)
|
||||
}
|
||||
want := []dbItem{
|
||||
{"✓ Team", filepath.Join("/m", "Team.kdbx")},
|
||||
{"⚠ Ops: your copy kept aside (read-only)", filepath.Join("/m", "Ops.kdbx")},
|
||||
{"✓ New", ""},
|
||||
}
|
||||
for i, d := range want {
|
||||
if v.Databases[i] != d {
|
||||
t.Errorf("database %d = %+v, want %+v", i, v.Databases[i], d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusNoticeOnlyForNews(t *testing.T) {
|
||||
ok := client.Status{Name: "A", State: "ok"}
|
||||
conflict := client.Status{Name: "A", State: "conflict", Message: "server copy written beside it"}
|
||||
if _, _, n := statusNotice(client.Status{}, ok); n {
|
||||
t.Error("a routine sync notified")
|
||||
}
|
||||
if _, _, n := statusNotice(ok, conflict); !n {
|
||||
t.Error("a new conflict did not notify")
|
||||
}
|
||||
if _, _, n := statusNotice(conflict, conflict); n {
|
||||
t.Error("the same conflict notified twice")
|
||||
}
|
||||
}
|
||||
|
||||
// newTestAgent writes a configuration and starts an agent on it without a
|
||||
// tray, recording notifications.
|
||||
func newTestAgent(t *testing.T, cfg *client.Config) (*agent, *[]string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if cfg != nil {
|
||||
if err := cfg.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var said []string
|
||||
a := newAgent(path, func(title, msg string) { said = append(said, title) })
|
||||
a.start()
|
||||
t.Cleanup(a.stop)
|
||||
return a, &said
|
||||
}
|
||||
|
||||
func TestAgentWritesAFullConfigOnFirstStart(t *testing.T) {
|
||||
a, _ := newTestAgent(t, nil)
|
||||
if _, err := client.LoadConfig(a.path); err != nil {
|
||||
t.Fatalf("first start left no usable config: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(a.logPath()); err != nil {
|
||||
t.Errorf("no log file beside the config: %v", err)
|
||||
}
|
||||
if got := a.view().Header; got != "No server configured" {
|
||||
t.Errorf("header %q", got)
|
||||
}
|
||||
if err := a.signIn(context.Background(), func(string) {}); !errors.Is(err, errNoServer) {
|
||||
t.Errorf("sign in without a server: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentReloadsAndReportsABrokenEdit(t *testing.T) {
|
||||
a, said := newTestAgent(t, nil)
|
||||
|
||||
broken := []byte(`{"server_url": "not a url"}`)
|
||||
if err := os.WriteFile(a.path, broken, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
future := time.Now().Add(time.Second)
|
||||
_ = os.Chtimes(a.path, future, future)
|
||||
a.tick()
|
||||
if !strings.HasPrefix(a.view().Header, "Configuration error") || len(*said) != 1 {
|
||||
t.Fatalf("broken edit: header %q, notifications %v", a.view().Header, *said)
|
||||
}
|
||||
|
||||
fixed := client.DefaultConfig()
|
||||
fixed.ServerURL = "https://k.example.com"
|
||||
if err := fixed.Save(a.path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
future = future.Add(time.Second)
|
||||
_ = os.Chtimes(a.path, future, future)
|
||||
a.tick()
|
||||
if got := a.view().Header; got != "Signed out" {
|
||||
t.Fatalf("after fixing the file, header %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentHonoursNotificationsSetting(t *testing.T) {
|
||||
cfg := client.DefaultConfig()
|
||||
cfg.Notifications = false
|
||||
a, said := newTestAgent(t, cfg)
|
||||
a.mu.Lock()
|
||||
a.sayLocked("title", "message")
|
||||
a.mu.Unlock()
|
||||
if len(*said) != 0 {
|
||||
t.Fatalf("notified with notifications off: %v", *said)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMirrorSettingsChanged(t *testing.T) {
|
||||
a := client.DefaultConfig()
|
||||
b := *a
|
||||
b.Notifications, b.LogFile = !a.Notifications, "/tmp/x.log"
|
||||
if mirrorSettingsChanged(a, &b) {
|
||||
t.Error("cosmetic settings restarted the mirror")
|
||||
}
|
||||
b.MirrorDir = "/elsewhere"
|
||||
if !mirrorSettingsChanged(a, &b) {
|
||||
t.Error("a new mirror directory did not restart the mirror")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// launchAgentPath is the per-user LaunchAgent that starts the tray at login.
|
||||
func launchAgentPath() string {
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, "Library", "LaunchAgents", AppID+".plist")
|
||||
}
|
||||
|
||||
func startAtLoginEnabled() bool {
|
||||
_, err := os.Stat(launchAgentPath())
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func setStartAtLogin(enable bool) error {
|
||||
path := launchAgentPath()
|
||||
if !enable {
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
exe, err := executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
plist := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>%s</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>%s</string>
|
||||
<string>tray</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>LimitLoadToSessionType</key>
|
||||
<string>Aqua</string>
|
||||
<key>ProcessType</key>
|
||||
<string>Interactive</string>
|
||||
</dict>
|
||||
</plist>
|
||||
`, AppID, exe)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, []byte(plist), 0o644)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//go:build !(darwin || windows || linux || freebsd || openbsd || netbsd || dragonfly)
|
||||
|
||||
package main
|
||||
|
||||
import "errors"
|
||||
|
||||
func startAtLoginEnabled() bool { return false }
|
||||
|
||||
func setStartAtLogin(bool) error {
|
||||
return errors.New("starting at login is not supported on this platform")
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"golang.org/x/sys/windows/registry"
|
||||
)
|
||||
|
||||
// runKey is the per-user list of programs Windows starts at sign-in.
|
||||
const runKey = `Software\Microsoft\Windows\CurrentVersion\Run`
|
||||
|
||||
func startAtLoginEnabled() bool {
|
||||
k, err := registry.OpenKey(registry.CURRENT_USER, runKey, registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer k.Close()
|
||||
_, _, err = k.GetStringValue("KeepOrg")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func setStartAtLogin(enable bool) error {
|
||||
k, _, err := registry.CreateKey(registry.CURRENT_USER, runKey, registry.SET_VALUE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer k.Close()
|
||||
if !enable {
|
||||
if err := k.DeleteValue("KeepOrg"); err != nil && !errors.Is(err, registry.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
exe, err := executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return k.SetStringValue("KeepOrg", `"`+exe+`" tray`)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//go:build linux || freebsd || openbsd || netbsd || dragonfly
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// autostartPath is the XDG autostart entry every freedesktop session reads.
|
||||
func autostartPath() string {
|
||||
dir := os.Getenv("XDG_CONFIG_HOME")
|
||||
if dir == "" {
|
||||
home, _ := os.UserHomeDir()
|
||||
dir = filepath.Join(home, ".config")
|
||||
}
|
||||
return filepath.Join(dir, "autostart", "keeporg.desktop")
|
||||
}
|
||||
|
||||
func startAtLoginEnabled() bool {
|
||||
_, err := os.Stat(autostartPath())
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func setStartAtLogin(enable bool) error {
|
||||
path := autostartPath()
|
||||
if !enable {
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
exe, err := executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry := fmt.Sprintf(`[Desktop Entry]
|
||||
Type=Application
|
||||
Name=KeepOrg
|
||||
Comment=KeePass database synchronization
|
||||
Exec="%s" tray
|
||||
Icon=keeporg
|
||||
Terminal=false
|
||||
X-GNOME-Autostart-enabled=true
|
||||
`, exe)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, []byte(entry), 0o644)
|
||||
}
|
||||
+42
-23
@@ -10,6 +10,7 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
@@ -31,7 +32,8 @@ func session(path string) (*client.Config, *client.API, error) {
|
||||
if cfg.Token == "" {
|
||||
return nil, nil, client.ErrNoConfig
|
||||
}
|
||||
return cfg, client.NewAPI(cfg.ServerURL, cfg.Token), nil
|
||||
api, err := cfg.API()
|
||||
return cfg, api, err
|
||||
}
|
||||
|
||||
func cmdLogin(ctx context.Context, args []string) error {
|
||||
@@ -41,15 +43,14 @@ func cmdLogin(ctx context.Context, args []string) error {
|
||||
user := fs.String("user", "", "username (omit with -browser)")
|
||||
dir := fs.String("dir", "", "mirror directory (default "+client.DefaultMirrorDir()+")")
|
||||
browser := fs.String("browser", "", "approve in a browser instead: use -browser=sso for OIDC or -browser=yes")
|
||||
label := fs.String("label", hostLabel(), "label shown in the server's session list")
|
||||
label := fs.String("label", "", "label shown in the server's session list (default device_label from the config)")
|
||||
_ = fs.Parse(args)
|
||||
|
||||
cfg, err := client.LoadConfig(*path)
|
||||
if err != nil && !errors.Is(err, client.ErrNoConfig) {
|
||||
return err
|
||||
}
|
||||
// A setting that fails validation may be the one this login is about to
|
||||
// fix, so only an unreadable file stops here; Validate runs below.
|
||||
cfg, err := client.EnsureConfig(*path)
|
||||
if cfg == nil {
|
||||
cfg = &client.Config{MirrorDir: client.DefaultMirrorDir(), PollSeconds: 2}
|
||||
return err
|
||||
}
|
||||
if *server != "" {
|
||||
cfg.ServerURL = strings.TrimRight(*server, "/")
|
||||
@@ -60,20 +61,27 @@ func cmdLogin(ctx context.Context, args []string) error {
|
||||
if *dir != "" {
|
||||
cfg.MirrorDir = *dir
|
||||
}
|
||||
if cfg.MirrorDir == "" {
|
||||
cfg.MirrorDir = client.DefaultMirrorDir()
|
||||
if *label != "" {
|
||||
cfg.DeviceLabel = *label
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
api := client.NewAPI(cfg.ServerURL, "")
|
||||
cfg.Token = ""
|
||||
api, err := cfg.API()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var sess *client.Session
|
||||
switch {
|
||||
case *browser != "":
|
||||
sess, err = browserLogin(ctx, api, *label)
|
||||
sess, err = browserLogin(ctx, api, cfg.DeviceLabel)
|
||||
default:
|
||||
if *user == "" {
|
||||
return errors.New("-user is required, or use -browser=yes")
|
||||
}
|
||||
sess, err = passwordLogin(ctx, api, *user, *label)
|
||||
sess, err = passwordLogin(ctx, api, *user, cfg.DeviceLabel)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -86,7 +94,7 @@ func cmdLogin(ctx context.Context, args []string) error {
|
||||
if !sess.User.TOTPEnabled {
|
||||
fmt.Println("tip: run `keeporg totp` to add a second factor")
|
||||
}
|
||||
fmt.Println("start the sync agent with `keeporg run`")
|
||||
fmt.Println("start the sync agent with `keeporg tray`, or `keeporg run` without a desktop")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -169,13 +177,20 @@ func cmdRun(ctx context.Context, args []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := os.Stderr
|
||||
var out io.Writer = os.Stderr
|
||||
if cfg.LogFile != "" {
|
||||
f, err := openLog(cfg.LogFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
out = f
|
||||
}
|
||||
lg := log.New(out, "", log.LstdFlags)
|
||||
if *quiet {
|
||||
lg = log.New(io.Discard, "", 0)
|
||||
}
|
||||
mir, err := client.NewMirror(api, cfg.MirrorDir, lg,
|
||||
time.Duration(cfg.PollSeconds)*time.Second)
|
||||
mir, err := client.NewMirror(api, cfg.MirrorDir, lg, cfg.Poll())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -196,11 +211,15 @@ func cmdSync(ctx context.Context, args []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mir, err := client.NewMirror(api, cfg.MirrorDir, log.New(os.Stderr, "", 0),
|
||||
time.Duration(cfg.PollSeconds)*time.Second)
|
||||
mir, err := client.NewMirror(api, cfg.MirrorDir, log.New(os.Stderr, "", 0), cfg.Poll())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
unlock, err := client.LockDir(cfg.MirrorDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w; it keeps the files in step already", err)
|
||||
}
|
||||
defer unlock()
|
||||
if err := mir.SyncAll(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -719,10 +738,10 @@ func openBrowser(url string) {
|
||||
}
|
||||
}
|
||||
|
||||
func hostLabel() string {
|
||||
h, err := os.Hostname()
|
||||
if err != nil {
|
||||
return "keeporg client"
|
||||
// openLog opens a log file for appending, creating its directory.
|
||||
func openLog(path string) (*os.File, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return h
|
||||
return os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// homepage is where About points.
|
||||
const homepage = "https://garage.rcmd.space/keeporg"
|
||||
|
||||
// openPath opens a file, directory or URL with whatever the desktop
|
||||
// associates with it. It is best effort: a caller that matters also logs or
|
||||
// prints the target.
|
||||
func openPath(target string) {
|
||||
var cmd *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
cmd = exec.Command("open", target)
|
||||
case "windows":
|
||||
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", target)
|
||||
default:
|
||||
cmd = exec.Command("xdg-open", target)
|
||||
}
|
||||
_ = cmd.Start()
|
||||
}
|
||||
|
||||
// openText opens a file in a text editor. JSON is associated with an IDE or
|
||||
// a browser often enough that "open" is not good enough for a config file.
|
||||
func openText(path string) {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
_ = exec.Command("open", "-t", path).Start()
|
||||
case "windows":
|
||||
_ = exec.Command("notepad", path).Start()
|
||||
default:
|
||||
openPath(path)
|
||||
}
|
||||
}
|
||||
|
||||
// notify shows a desktop notification, best effort. Windows has none without
|
||||
// a third-party library; the menu's first line carries the same news there.
|
||||
func notify(title, message string) {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
_ = exec.Command("osascript", "-e",
|
||||
fmt.Sprintf("display notification %q with title %q", message, title)).Start()
|
||||
case "windows":
|
||||
default:
|
||||
_ = exec.Command("notify-send", "--app-name=KeepOrg", "--icon=keeporg", title, message).Start()
|
||||
}
|
||||
}
|
||||
|
||||
// aboutText is the About dialog's body.
|
||||
func aboutText(configPath string) string {
|
||||
return fmt.Sprintf("KeepOrg %s\n\nKeePass database synchronization.\n\nConfiguration: %s\n%s",
|
||||
Version, configPath, homepage)
|
||||
}
|
||||
+30
-6
@@ -3,6 +3,9 @@
|
||||
// It mirrors every KDBX database assigned to you into one directory as real
|
||||
// files, pushes each save to the server and applies what other clients push.
|
||||
// Point KeePassXC, KeePass2 or any compatible application at those files.
|
||||
//
|
||||
// Started without a command it is a system tray application; the commands
|
||||
// below are the same engine for terminals, scripts and headless machines.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -11,14 +14,19 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"text/tabwriter"
|
||||
|
||||
"cc.tiredsysadmin.keeporg/internal/client"
|
||||
)
|
||||
|
||||
// Version is stamped at build time from versions.mk.
|
||||
var Version = "dev"
|
||||
// Version and AppID are stamped at build time from versions.mk.
|
||||
var (
|
||||
Version = "dev"
|
||||
AppID = "cc.tiredsysadmin.keeporg"
|
||||
)
|
||||
|
||||
type command struct {
|
||||
name string
|
||||
@@ -28,6 +36,7 @@ type command struct {
|
||||
|
||||
func main() {
|
||||
cmds := []command{
|
||||
{"tray", "run as a system tray application (the default)", cmdTray},
|
||||
{"login", "sign in to a server and choose the mirror directory", cmdLogin},
|
||||
{"logout", "revoke this device's session", cmdLogout},
|
||||
{"run", "keep the mirror in sync until stopped", cmdRun},
|
||||
@@ -50,14 +59,19 @@ func main() {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
if len(os.Args) < 2 {
|
||||
usage(cmds)
|
||||
os.Exit(2)
|
||||
// A double-click, a launcher or a login item starts the tray. Old macOS
|
||||
// releases pass a process serial number to an application bundle.
|
||||
if len(os.Args) < 2 || strings.HasPrefix(os.Args[1], "-psn_") {
|
||||
os.Args = append(os.Args[:1], "tray")
|
||||
}
|
||||
name := os.Args[1]
|
||||
if name == "version" || name == "-version" || name == "--version" {
|
||||
switch name {
|
||||
case "version", "-version", "--version":
|
||||
fmt.Println("keeporg", Version)
|
||||
return
|
||||
case "help", "-h", "-help", "--help":
|
||||
usage(cmds)
|
||||
return
|
||||
}
|
||||
for _, c := range cmds {
|
||||
if c.name != name {
|
||||
@@ -87,3 +101,13 @@ func usage(cmds []command) {
|
||||
fmt.Fprintf(os.Stderr, "\nRun `keeporg <command> -h` for a command's flags.\n"+
|
||||
"Configuration lives in %s\n", client.DefaultConfigPath())
|
||||
}
|
||||
|
||||
// executable is this program's resolved path, for login items that must
|
||||
// survive the symlink a package manager or an app bundle put in front of it.
|
||||
func executable() (string, error) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.EvalSymlinks(exe)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
//go:build !darwin || cgo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"log"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"fyne.io/systray"
|
||||
|
||||
"cc.tiredsysadmin.keeporg/internal/icon"
|
||||
)
|
||||
|
||||
// maxDatabaseSlots bounds the Databases submenu. Menu items are created once
|
||||
// and retitled, shown and hidden as the list changes, which every tray
|
||||
// implementation handles; adding and removing them does not work everywhere.
|
||||
const maxDatabaseSlots = 32
|
||||
|
||||
func cmdTray(ctx context.Context, args []string) error {
|
||||
fs := flag.NewFlagSet("tray", flag.ExitOnError)
|
||||
path := configFlag(fs)
|
||||
_ = fs.Parse(args)
|
||||
|
||||
t := &trayApp{agent: newAgent(*path, notify), path: *path}
|
||||
// The tray library and anything else that uses the standard logger
|
||||
// write where the agent does.
|
||||
log.SetOutput(t.agent.sink)
|
||||
// SIGTERM, which a session sends at logout, quits as cleanly as the menu.
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
systray.Quit()
|
||||
}()
|
||||
systray.Run(t.onReady, t.onExit)
|
||||
return nil
|
||||
}
|
||||
|
||||
type trayApp struct {
|
||||
agent *agent
|
||||
path string
|
||||
|
||||
header, openDir, syncNow, signIn, signOut, web *systray.MenuItem
|
||||
noDatabases *systray.MenuItem
|
||||
|
||||
mu sync.Mutex
|
||||
slots [maxDatabaseSlots]struct {
|
||||
item *systray.MenuItem
|
||||
path string
|
||||
}
|
||||
last view
|
||||
}
|
||||
|
||||
func (t *trayApp) onReady() {
|
||||
if runtime.GOOS == "darwin" {
|
||||
systray.SetTemplateIcon(icon.PNG(icon.Tray(36, icon.Template)), icon.PNG(icon.Tray(36, icon.Brand)))
|
||||
} else if runtime.GOOS == "windows" {
|
||||
systray.SetIcon(icon.ICO(icon.Tray(16, icon.Brand), icon.Tray(32, icon.Brand), icon.Tray(48, icon.Brand)))
|
||||
} else {
|
||||
systray.SetIcon(icon.PNG(icon.Tray(64, icon.Brand)))
|
||||
}
|
||||
systray.SetTooltip("KeepOrg " + Version)
|
||||
|
||||
t.header = systray.AddMenuItem("Starting…", "")
|
||||
t.header.Disable()
|
||||
systray.AddSeparator()
|
||||
t.openDir = systray.AddMenuItem("Open Mirror Folder", "Show the synchronized .kdbx files")
|
||||
dbs := systray.AddMenuItem("Databases", "Open a database in your KeePass application")
|
||||
t.noDatabases = dbs.AddSubMenuItem("No databases", "")
|
||||
t.noDatabases.Disable()
|
||||
for i := range t.slots {
|
||||
t.slots[i].item = dbs.AddSubMenuItem("", "")
|
||||
t.slots[i].item.Hide()
|
||||
go t.onDatabase(i)
|
||||
}
|
||||
t.syncNow = systray.AddMenuItem("Sync Now", "Reconcile every database immediately")
|
||||
systray.AddSeparator()
|
||||
t.signIn = systray.AddMenuItem("Sign In…", "Approve this device in a browser")
|
||||
t.signOut = systray.AddMenuItem("Sign Out", "Revoke this device's session; the files stay")
|
||||
t.web = systray.AddMenuItem("Open Web Interface", "Create vaults and manage access")
|
||||
openConfig := systray.AddMenuItem("Open Config", "Edit the settings; changes apply when saved")
|
||||
openLog := systray.AddMenuItem("Open Log", "")
|
||||
login := systray.AddMenuItemCheckbox("Start at Login", "Start KeepOrg when you sign in", startAtLoginEnabled())
|
||||
systray.AddSeparator()
|
||||
about := systray.AddMenuItem("About", "")
|
||||
quit := systray.AddMenuItem("Quit", "Stop synchronizing and quit")
|
||||
|
||||
t.agent.start()
|
||||
t.refresh()
|
||||
|
||||
go func() {
|
||||
tick := time.NewTicker(time.Second)
|
||||
defer tick.Stop()
|
||||
for range tick.C {
|
||||
t.agent.tick()
|
||||
t.refresh()
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-t.openDir.ClickedCh:
|
||||
openPath(t.view().MirrorDir)
|
||||
case <-t.syncNow.ClickedCh:
|
||||
t.agent.syncNow()
|
||||
case <-t.signIn.ClickedCh:
|
||||
go t.runSignIn()
|
||||
case <-t.signOut.ClickedCh:
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
t.agent.signOut(ctx)
|
||||
cancel()
|
||||
t.refresh()
|
||||
case <-t.web.ClickedCh:
|
||||
if u := t.view().WebURL; u != "" {
|
||||
openPath(u)
|
||||
}
|
||||
case <-openConfig.ClickedCh:
|
||||
openText(t.path)
|
||||
case <-openLog.ClickedCh:
|
||||
openText(t.agent.logPath())
|
||||
case <-login.ClickedCh:
|
||||
enable := !login.Checked()
|
||||
if err := setStartAtLogin(enable); err != nil {
|
||||
t.agent.log.Printf("start at login: %v", err)
|
||||
notify("KeepOrg", "Could not change Start at Login: "+err.Error())
|
||||
} else if enable {
|
||||
login.Check()
|
||||
} else {
|
||||
login.Uncheck()
|
||||
}
|
||||
case <-about.ClickedCh:
|
||||
showAbout(t.path)
|
||||
case <-quit.ClickedCh:
|
||||
systray.Quit()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (t *trayApp) onExit() { t.agent.stop() }
|
||||
|
||||
func (t *trayApp) runSignIn() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
t.refresh()
|
||||
err := t.agent.signIn(ctx, openPath)
|
||||
switch {
|
||||
case err == errNoServer:
|
||||
openText(t.path)
|
||||
notify("KeepOrg", err.Error())
|
||||
case err != nil:
|
||||
t.agent.log.Printf("sign in: %v", err)
|
||||
notify("KeepOrg sign-in failed", err.Error())
|
||||
}
|
||||
t.refresh()
|
||||
}
|
||||
|
||||
func (t *trayApp) onDatabase(i int) {
|
||||
for range t.slots[i].item.ClickedCh {
|
||||
t.mu.Lock()
|
||||
path := t.slots[i].path
|
||||
t.mu.Unlock()
|
||||
if path != "" {
|
||||
openPath(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *trayApp) view() view {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.last
|
||||
}
|
||||
|
||||
// refresh brings the menu in line with the agent. Only what changed is sent,
|
||||
// because on Linux every change is a D-Bus round trip.
|
||||
func (t *trayApp) refresh() {
|
||||
v := t.agent.view()
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
old := t.last
|
||||
t.last = v
|
||||
|
||||
if v.Header != old.Header {
|
||||
t.header.SetTitle(v.Header)
|
||||
}
|
||||
if v.Tooltip != old.Tooltip {
|
||||
systray.SetTooltip(v.Tooltip)
|
||||
}
|
||||
showIf(t.signIn, !v.SignedIn && !v.Pending)
|
||||
showIf(t.signOut, v.SignedIn)
|
||||
enableIf(t.syncNow, v.SignedIn)
|
||||
enableIf(t.web, v.WebURL != "")
|
||||
showIf(t.noDatabases, len(v.Databases) == 0)
|
||||
for i := range t.slots {
|
||||
s := &t.slots[i]
|
||||
if i >= len(v.Databases) {
|
||||
s.path = ""
|
||||
s.item.Hide()
|
||||
continue
|
||||
}
|
||||
d := v.Databases[i]
|
||||
if i >= len(old.Databases) || old.Databases[i] != d {
|
||||
s.item.SetTitle(d.Label)
|
||||
}
|
||||
s.path = d.Path
|
||||
s.item.Show()
|
||||
}
|
||||
}
|
||||
|
||||
func showIf(item *systray.MenuItem, show bool) {
|
||||
if show {
|
||||
item.Show()
|
||||
} else {
|
||||
item.Hide()
|
||||
}
|
||||
}
|
||||
|
||||
func enableIf(item *systray.MenuItem, enable bool) {
|
||||
if enable {
|
||||
item.Enable()
|
||||
} else {
|
||||
item.Disable()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//go:build darwin && !cgo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// The macOS menu bar is reachable only through Cocoa, which needs cgo. A
|
||||
// binary cross-compiled without it keeps every other command.
|
||||
func cmdTray(context.Context, []string) error {
|
||||
return errors.New("this build has no menu-bar support; build it on macOS with CGO_ENABLED=1, or use `keeporg run`")
|
||||
}
|
||||
Reference in New Issue
Block a user