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
+52 -11
View File
@@ -20,6 +20,14 @@ version_for = $(strip \
$(if $(filter windows,$(1)),$(VERSION_WINDOWS),\
$(if $(filter freebsd openbsd netbsd,$(1)),$(VERSION_BSD),0.0.0)))))
# So is the application identifier, which names the macOS login item.
appid_for = $(strip \
$(if $(filter linux,$(1)),$(APPID_LINUX),\
$(if $(filter darwin,$(1)),$(APPID_MACOS),\
$(if $(filter windows,$(1)),$(APPID_WINDOWS),$(APPID_BSD)))))
client_ldflags = -s -w -X main.Version=$(call version_for,$(1)) -X main.AppID=$(call appid_for,$(1))
os_of = $(word 1,$(subst -, ,$(1)))
arch_of = $(word 2,$(subst -, ,$(1)))
@@ -63,11 +71,23 @@ all: server client ## build the server and client for this host
server: $(HOST_SERVER) ## build the server for this host
client: $(HOST_CLIENT) ## build the desktop client for this host
# The macOS menu bar needs Cocoa, so the client links it with cgo when this
# host is a Mac. Built anywhere else it has every command but `tray`. This
# rule comes before the generic one because make 3.81, which macOS ships,
# takes the first pattern that matches.
DARWIN_CGO := $(if $(filter darwin,$(HOSTOS)),1,0)
$(BUILD)/darwin-%/keeporg: $(GO_SRC)
@mkdir -p $(dir $@)
GOOS=darwin GOARCH=$* CGO_ENABLED=$(DARWIN_CGO) \
$(GO) build $(GOFLAGS) -ldflags "$(call client_ldflags,darwin)" \
-o $@ ./cmd/keeporg
# Linux and the BSDs talk to the tray over D-Bus, which is pure Go.
$(BUILD)/%/keeporg: $(GO_SRC)
@mkdir -p $(dir $@)
GOOS=$(call os_of,$*) GOARCH=$(call arch_of,$*) CGO_ENABLED=0 \
$(GO) build $(GOFLAGS) \
-ldflags "-s -w -X main.Version=$(call version_for,$(call os_of,$*))" \
$(GO) build $(GOFLAGS) -ldflags "$(call client_ldflags,$(call os_of,$*))" \
-o $@ ./cmd/keeporg
$(BUILD)/%/keeporgd: $(GO_SRC)
@@ -77,19 +97,39 @@ $(BUILD)/%/keeporgd: $(GO_SRC)
-ldflags "-s -w -X main.Version=$(VERSION_SERVER)" \
-o $@ ./cmd/keeporgd
# Windows gets the program twice: keeporg.exe is a console program for the
# commands, keeporg-tray.exe the same code linked as a GUI program, so starting
# the tray opens no console window.
$(BUILD)/windows-%/keeporg.exe: $(GO_SRC)
@mkdir -p $(dir $@)
GOOS=windows GOARCH=$* CGO_ENABLED=0 \
$(GO) build $(GOFLAGS) -ldflags "-s -w -X main.Version=$(VERSION_WINDOWS)" \
$(GO) build $(GOFLAGS) -ldflags "$(call client_ldflags,windows)" \
-o $@ ./cmd/keeporg
$(BUILD)/windows-%/keeporg-tray.exe: $(GO_SRC)
@mkdir -p $(dir $@)
GOOS=windows GOARCH=$* CGO_ENABLED=0 \
$(GO) build $(GOFLAGS) -ldflags "$(call client_ldflags,windows) -H windowsgui" \
-o $@ ./cmd/keeporg
# The application icon, drawn by internal/icon, for the Linux icon theme and
# the macOS bundle.
ICONS_HICOLOR := $(BUILD)/icons/hicolor/.stamp
ICONSET := $(BUILD)/icons/KeepOrg.iconset/.stamp
$(ICONS_HICOLOR): internal/icon/icon.go tools/genicon/main.go
rm -rf $(dir $@) && $(GO) run ./tools/genicon -hicolor $(dir $@) && touch $@
$(ICONSET): internal/icon/icon.go tools/genicon/main.go
rm -rf $(dir $@) && $(GO) run ./tools/genicon -iconset $(dir $@) && touch $@
.PHONY: cross
cross: ## build every desktop and server binary
cross: $(BUILD)/linux-amd64/keeporg $(BUILD)/linux-arm64/keeporg \
$(BUILD)/linux-amd64/keeporgd $(BUILD)/linux-arm64/keeporgd \
$(BUILD)/darwin-amd64/keeporg $(BUILD)/darwin-arm64/keeporg \
$(BUILD)/freebsd-amd64/keeporg $(BUILD)/freebsd-arm64/keeporg \
$(BUILD)/windows-amd64/keeporg.exe
$(BUILD)/windows-amd64/keeporg.exe $(BUILD)/windows-amd64/keeporg-tray.exe
# ------------------------------------------------------------------ packaging
@@ -103,7 +143,8 @@ deb-client: $(DEB_CLIENT) ## build the client .deb
deb-server: $(DEB_SERVER) ## build the server .deb
$(DEB_CLIENT): $(BUILD)/linux-$(DEB_ARCH)/keeporg packaging/deb/client/control.in \
packaging/deb/client/changelog packaging/systemd/keeporg.service
packaging/deb/client/changelog packaging/systemd/keeporg.service \
packaging/linux/keeporg.desktop $(ICONS_HICOLOR)
scripts/build-deb.sh client $(VERSION_LINUX) $(DEB_ARCH) $@
$(DEB_SERVER): $(BUILD)/linux-$(DEB_ARCH)/keeporgd packaging/deb/server/control.in \
@@ -116,8 +157,7 @@ DMG := $(DIST)/KeepOrg-$(VERSION_MACOS)-$(DMG_ARCH).dmg
.PHONY: dmg
dmg: $(DMG) ## build the macOS DMG with an /Applications symlink (macOS only)
$(DMG): $(BUILD)/darwin-$(DMG_ARCH)/keeporg packaging/macos/Info.plist.in \
packaging/macos/launcher.sh packaging/macos/launch-agent.plist.in
$(DMG): $(BUILD)/darwin-$(DMG_ARCH)/keeporg packaging/macos/Info.plist.in $(ICONSET)
scripts/build-dmg.sh $(VERSION_MACOS) $(DMG_ARCH) $(APPID_MACOS) $@
ZIP := $(DIST)/keeporg-$(VERSION_WINDOWS)-windows-amd64.zip
@@ -125,10 +165,11 @@ ZIP := $(DIST)/keeporg-$(VERSION_WINDOWS)-windows-amd64.zip
.PHONY: windows
windows: $(ZIP) ## build the Windows zip
$(ZIP): $(BUILD)/windows-amd64/keeporg.exe packaging/windows/README.txt
$(ZIP): $(BUILD)/windows-amd64/keeporg.exe $(BUILD)/windows-amd64/keeporg-tray.exe \
packaging/windows/README.txt packaging/windows/keeporg-task.xml
@mkdir -p $(DIST)
rm -rf $(BUILD)/windows-stage && mkdir -p $(BUILD)/windows-stage
cp $(BUILD)/windows-amd64/keeporg.exe $(BUILD)/windows-stage/
cp $(BUILD)/windows-amd64/keeporg.exe $(BUILD)/windows-amd64/keeporg-tray.exe $(BUILD)/windows-stage/
cp packaging/windows/README.txt $(BUILD)/windows-stage/
cp packaging/windows/keeporg-task.xml $(BUILD)/windows-stage/
cd $(BUILD)/windows-stage && zip -q -r ../../$@ .
@@ -229,8 +270,8 @@ run-server: $(HOST_SERVER) ## run a server on 127.0.0.1:8765 with data in ./.dev
$(HOST_SERVER) -addr 127.0.0.1:8765 -data .devdata -url http://127.0.0.1:8765
.PHONY: run-client
run-client: $(HOST_CLIENT) ## run the sync agent with the local configuration
$(HOST_CLIENT) run
run-client: $(HOST_CLIENT) ## run the tray application with the local configuration
$(HOST_CLIENT) tray
nginx:
cat config/variables/main.json | tpl -f config/nginx/$(APP_NAME).conf.tmpl > /etc/nginx/sites-available/$(APP_NAME).conf
+51 -8
View File
@@ -18,15 +18,18 @@ keep their key off the server entirely, exactly as before.
with a tree of user groups, accepts registrations by invite link or OIDC,
merges concurrent edits, and pushes updates to connected clients. Its web
page at `/web` is for creating a vault and setting its password.
- **Desktop client** (`keeporg`, Linux, macOS, Windows, BSD): mirrors every
assigned database into one directory as a real file, pushes each save and
applies what others push.
- **Desktop client** (`keeporg`, Linux, macOS, Windows, BSD): a system tray
application that mirrors every assigned database into one directory as a
real file, pushes each save and applies what others push. The same binary
is the command line for administration and for headless machines.
- **Android client**: the same, published to every file picker as a storage
provider.
No third-party dependencies, no cgo, no FUSE. The KDBX 4 codec, including
ChaCha20, BLAKE2b and Argon2, is part of the repository and is tested against
both the relevant RFCs and a real KeePassXC.
No FUSE, and the server has no third-party dependencies and no cgo. The
KDBX 4 codec, including ChaCha20, BLAKE2b and Argon2, is part of the
repository and is tested against both the relevant RFCs and a real
KeePassXC. The desktop client's one dependency is `fyne.io/systray`, for the
tray icon.
## Quick start
@@ -41,13 +44,53 @@ KEEPORG_ADMIN_PASSWORD=dev-password-1 make run-server
build/linux-arm64/keeporg login -server http://127.0.0.1:8765 -user admin
build/linux-arm64/keeporg groups
build/linux-arm64/keeporg vault -create "Team Vault" -group <group-id>
build/linux-arm64/keeporg run
build/linux-arm64/keeporg # the tray; `keeporg run` without a desktop
```
`Team Vault.kdbx` appears in `~/KeepOrg`; open it in KeePassXC with the vault
password you just set. Every save is pushed, and a save that races with a
colleague's is merged rather than refused.
## The tray
Started without a command — from the applications menu, the DMG's
`KeepOrg.app`, or `keeporg-tray.exe` on Windows — the client puts a padlock
in the system tray:
| Item | What it does |
| --- | --- |
| *status line* | signed-in account and whether everything is up to date, or what needs attention |
| **Open Mirror Folder** | the directory holding the `.kdbx` files |
| **Databases ▶** | each database with its state; choosing one opens it in your KeePass application |
| **Sync Now** | reconcile immediately instead of at the next rescan |
| **Sign In… / Sign Out** | sign in by approving a code in the browser (password, second factor or SSO); sign out revokes the session and leaves the files |
| **Open Web Interface** | `/web` on the server, for creating vaults and managing access |
| **Open Config** | the settings file; a saved edit applies at once |
| **Open Log** | the agent's log |
| **Start at Login** | LaunchAgent on macOS, XDG autostart on Linux and BSD, the Run key on Windows |
| **About**, **Quit** | |
Conflicts, merges and failures raise a desktop notification.
## Configuration
`config.json` in the user configuration directory
(`~/.config/keeporg`, `~/Library/Application Support/keeporg`,
`%AppData%\keeporg`) holds every client setting. The first start writes it
in full, and the tray reloads it when it is saved.
| Key | Default | Meaning |
| --- | --- | --- |
| `server_url` | | the server's base URL, as a browser sees it |
| `username`, `token` | | written by signing in |
| `device_label` | host name | this device in the server's session list |
| `mirror_dir` | `~/KeepOrg` | where the `.kdbx` files are kept; `~` is expanded |
| `poll_seconds` | `2` | how often local saves are looked for |
| `request_timeout_seconds` | `60` | the limit on one API request (the event stream is exempt) |
| `ca_file` | | a PEM bundle trusted besides the system roots, for a private CA |
| `log_file` | | the log; empty means `keeporg.log` beside the config for the tray, standard error for `keeporg run` |
| `notifications` | `true` | desktop notifications from the tray |
Or open `http://127.0.0.1:8765/web` and create the vault from the browser.
## Packaging
@@ -56,7 +99,7 @@ Or open `http://127.0.0.1:8765/web` and create the vault from the browser.
make deb # Debian packages for the client and the server
make docker # server container image, scratch-based, about 9 MB
make dmg # macOS disk image with an /Applications symlink (on macOS)
make windows # Windows zip with a Task Scheduler job
make windows # Windows zip: keeporg-tray.exe, keeporg.exe and a Task Scheduler job
make android # unsigned release APK (needs the Android SDK)
make help # every target
```
+36
View File
@@ -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)
}
+13
View File
@@ -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)
}
+489
View File
@@ -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
}
+97
View File
@@ -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)
}
}
+161
View File
@@ -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")
}
}
+58
View File
@@ -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)
}
+11
View File
@@ -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")
}
+41
View File
@@ -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`)
}
+51
View File
@@ -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
View File
@@ -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)
}
+58
View File
@@ -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
View File
@@ -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)
}
+229
View File
@@ -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()
}
}
+14
View File
@@ -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`")
}
+123 -13
View File
@@ -25,13 +25,17 @@ trust boundary, and should treat the host accordingly.
```
cmd/keeporgd/ server entry point: flags, config file, bootstrap
cmd/keeporg/ desktop client: login, run, sync, status, administration
cmd/keeporg/ desktop client: the tray application (agent.go is its
logic, tray.go its menu), login, run, sync, status,
administration, start-at-login per platform
internal/store/ domain model, JSON metadata store, ACL resolution
internal/blob/ versioned KDBX payload storage on disk
internal/kdbx/ KDBX 4 codec and the merge engine, with its own crypto
internal/server/ HTTP surface: auth, OIDC, sync, admin, SSE hub, web,
web administration, page templates
internal/client/ shared client engine: API wrapper, merge policy, mirror
internal/client/ shared client engine: API wrapper, merge policy, mirror,
configuration, the mirror directory lock
internal/icon/ the padlock, drawn in code: tray PNG/ICO, app icons
internal/secret/ password verifiers, tokens, identifiers
internal/totp/ RFC 6238 second factor
android/ Android client: DocumentsProvider, sync service, UI
@@ -39,6 +43,7 @@ Dockerfile the server's container image, built from scratch
packaging/ deb, macOS bundle and DMG, Windows zip, systemd units,
container compose example
scripts/ package builders, version bump, changelog
tools/genicon/ writes the Linux icon theme and the macOS iconset
versions.mk the one place every platform's version lives
Makefile the only entry point for building and publishing
```
@@ -69,8 +74,9 @@ are cheap enough to call per request and easy to test exhaustively.
### Desktop client (`keeporg`)
`keeporg run` keeps a mirror directory (default `~/KeepOrg`) in step with the
server:
One binary, two faces. Started without a command it is a system tray
application; with a command it is the CLI. Both drive the same mirror, which
keeps a directory (default `~/KeepOrg`) in step with the server:
- a rescan every two seconds notices that a KeePass application saved a file;
- the server's event stream delivers other people's pushes immediately;
@@ -81,6 +87,64 @@ All of the synchronization policy is the pure `decide()` table in
`internal/client/decide.go`. Everything else in the mirror applies the action
it returns. To change behaviour, change that table and its test.
**One agent per directory.** `Mirror.Run` takes an OS lock on
`.keeporg.lock` in the mirror directory (`flock`, or an unshared handle on
Windows) and holds it until it stops; `keeporg sync` takes it too. Two agents
on one directory would each take the other's writes for local saves and
push them back, and with a tray, a systemd unit and a Task Scheduler job all
available that is easy to set up by accident. The OS drops the lock when
the process dies, so a crash never leaves a directory locked.
#### The tray
The tray is split so that nothing that decides anything needs a desktop:
- `agent.go` owns the configuration, the mirror's lifecycle, sign-in and
sign-out, and turns the agent's state into a `view` — the status line, the
database list, which items are enabled — in one pure function,
`buildView`. `statusNotice` decides which state changes are worth a
notification: conflicts, kept copies, merges, failures; never a routine
pull. Both are table-tested, and `agent_server_test.go` drives the agent
against a real in-process server through a sync and a server-side
revocation.
- `tray.go` is the menu: it builds the items once, and once a second calls
`agent.tick()` and applies whatever changed in the view. The database
submenu is 32 pre-made items that are retitled, shown and hidden, because
not every tray host copes with items being removed.
Behaviour worth knowing:
- **Sign-in is the device flow** the CLI's `-browser` option uses: the tray
opens the approval page and polls. It covers password, second factor and
single sign-on with no dialog in the tray itself. With no `server_url`
configured, Sign In opens the configuration file instead.
- **The configuration is the settings interface.** The first start writes
`config.json` with every key. A tick notices a saved edit by its
modification time; a file that no longer parses is reported once and the
running configuration kept; the mirror restarts only when a setting it was
built from changed (`mirrorSettingsChanged`). The tray's own writes record
the new modification time, so signing in does not look like an edit.
- **A revoked session signs the device out.** When the server answers
`unauthorized`, the agent stops, clears the token from the file and says
so, rather than retrying every two seconds forever.
- **The mirror's goroutine never takes the agent's lock.** Stopping the
mirror holds that lock while it waits for the goroutine; the goroutine
reports how it ended through its own small `runErr` instead.
- **SIGTERM quits** the same way the menu does, which matters at logout.
- **Start at Login** writes a LaunchAgent (`<AppID>.plist`) on macOS, an XDG
autostart entry on Linux and the BSDs, and the `HKCU\…\Run` value on
Windows, each running `keeporg tray` from the resolved executable path.
- **Platform specifics.** macOS gets a template icon, so the menu bar
recolours it; Windows gets an ICO. Notifications use `osascript` and
`notify-send`; Windows has none yet, and the status line carries the same
news. The About box is AppleScript, zenity or kdialog, or a Windows
message box.
Every client setting, with its default, is in the README's configuration
table and in the comments on `client.Config`. `LoadConfig` reads a file over
a fully defaulted `Config`, so a key a file leaves out keeps its default —
that is how an 0.4 file with no `notifications` key still means `true`.
### Android client
`KeepOrgProvider` is a `DocumentsProvider`, so the mirrored files appear in
@@ -421,13 +485,26 @@ be left behind.
## Decisions worth knowing
**No third-party Go dependencies, no cgo.** The whole server and desktop
client build with `CGO_ENABLED=0` from the standard library: SSE instead of a
WebSocket library, `crypto/pbkdf2` (new in Go 1.24) for account passwords,
hand-written ChaCha20, BLAKE2b and Argon2 for the KDBX format, 60 lines of
HMAC for TOTP, `html/template` for the pages.
Cross-compiling to five platforms is then one `go build` each, and there is
no dependency tree to audit for a program that guards passwords.
**No third-party Go dependencies in the server, no cgo outside the macOS
tray.** The server builds with `CGO_ENABLED=0` from the standard library
alone: SSE instead of a WebSocket library, `crypto/pbkdf2` (new in Go 1.24)
for account passwords, hand-written ChaCha20, BLAKE2b and Argon2 for the
KDBX format, 60 lines of HMAC for TOTP, `html/template` for the pages. There
is no dependency tree to audit in the program that holds the vault keys;
`go list -deps ./cmd/keeporgd` shows only the standard library and this
module, and the container builds without fetching anything.
The desktop client takes exactly one, `fyne.io/systray` (with
`godbus/dbus` and `golang.org/x/sys` under it), because a tray icon cannot
be had otherwise for less than several thousand lines: Linux and the BSDs
speak StatusNotifierItem over D-Bus, Windows needs Win32, and the macOS menu
bar is reachable only through Cocoa. It was chosen over
`getlantern/systray`, which `midi-webhook` uses, because it is maintained and
needs no cgo or GTK on Linux: Linux, BSD and Windows clients still
cross-compile from anywhere. Only macOS needs cgo, so the Makefile links the
darwin client with cgo when it is built on a Mac; built elsewhere it keeps
every command but `tray` (`tray_nocgo.go`). The client only moves
ciphertext, so the trade is in the program that never sees a key.
**A JSON document instead of SQLite.** The metadata is hundreds of records,
never thousands: users, groups, grants. One writer with atomic rename gives
@@ -616,7 +693,19 @@ Application identifiers follow `cc.tiredsysadmin.<os>.<app>`:
`.windows.keeporg`, `.bsd.keeporg`, and `.linux.keeporgd` for the server
package.
Desktop and server artifacts stay local (`dist/`). Android publishing is
Desktop and server artifacts stay local (`dist/`):
- `make dmg` (on a Mac): `KeepOrg.app`, whose executable is the client
itself, with an icon made by `iconutil` from `tools/genicon`'s iconset and
`LSUIElement` set, so there is a menu-bar item and no Dock icon.
- `make deb`: `/usr/bin/keeporg`, a desktop entry that starts the tray, the
hicolor icons, and the systemd user unit for headless `keeporg run`.
- `make windows`: `keeporg.exe`, a console program for the commands, and
`keeporg-tray.exe`, the same code linked `-H windowsgui` so the tray opens
no console window. They cannot be called `keeporg.exe` and `KeepOrg.exe`:
Windows file names are case-insensitive.
Android publishing is
manual, never automatic:
```sh
@@ -661,7 +750,18 @@ What the suite actually covers:
- `internal/client` for the decision table, and for the mirror driven against
a real in-process server: adopting a local file, following a remote push,
server-wins, client-wins, manual, read-only restore, server-side rename,
and an update arriving over the event stream.
and an update arriving over the event stream; for the configuration's
defaults, validation and `~` expansion; and for the mirror lock admitting
one agent.
- `cmd/keeporg` for the tray's status line and database list, which state
changes notify, configuration reload and a broken edit, the notifications
setting, and the agent against a real server through a sync and a
revocation.
- `internal/icon` for the padlock's shape and the ICO container.
The tray's menu itself has no automated test: it needs a desktop session.
It was run on macOS against a local server; on Linux and Windows it has been
compiled, not run.
The Android module is checked by `make android-lint`, which is kept at zero
findings, and `make android` builds the unsigned release APK. See
@@ -670,6 +770,16 @@ what an arm64 Linux build host needs.
## Known limits and the next things to do
- **The tray has only been run on macOS.** The Linux, BSD and Windows builds
compile and their logic is tested, but nobody has looked at the icon in a
KDE panel or the Windows notification area yet.
- **GNOME shows no tray** without the AppIndicator extension. `keeporg run`
or the systemd user unit work there regardless.
- **No notifications on Windows.** A toast needs either a COM interop layer
or PowerShell; the status line shows the same states meanwhile.
- **The DMG is per architecture.** A universal `.app` needs `lipo` over both
cgo builds, which `midi-webhook`'s Makefile shows how to do.
- **One server node.** The JSON store is single-writer by design. Horizontal
scale means replacing `internal/store`.
- **Merging requires the server to hold the vault key.** That is inherent, not
+7
View File
@@ -1,3 +1,10 @@
module cc.tiredsysadmin.keeporg
go 1.24
require (
fyne.io/systray v1.12.2
golang.org/x/sys v0.27.0
)
require github.com/godbus/dbus/v5 v5.2.2 // indirect
+10
View File
@@ -0,0 +1,10 @@
fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+134 -12
View File
@@ -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:])
}
+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()
}
+32
View File
@@ -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
}
+6
View File
@@ -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 }
+24
View File
@@ -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
}
+30
View File
@@ -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
View File
@@ -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()
+149
View File
@@ -0,0 +1,149 @@
// Package icon draws the KeepOrg padlock at any size.
//
// The glyph is geometry rather than a shipped image, so one definition serves
// the tray (a small PNG, or an ICO on Windows, made at run time) and the
// packages (the Linux icon theme and the macOS .icns, written at build time
// by tools/genicon).
package icon
import (
"bytes"
"encoding/binary"
"image"
"image/color"
"image/png"
"math"
)
// design is the edge length of the coordinate system the shapes are drawn in.
const design = 32.0
// subsamples is the per-axis supersampling rate that antialiases the curves.
const subsamples = 4
var (
// Brand is the padlock's colour where it stands on its own, in a tray
// that may be light or dark.
Brand = color.NRGBA{R: 0x2f, G: 0x6f, B: 0xd6, A: 0xff}
// Template is the macOS menu-bar form: black, which the system recolours
// for light and dark menu bars from the alpha channel alone.
Template = color.NRGBA{A: 0xff}
)
// lock reports whether a design-space point is inside the padlock.
func lock(x, y float64) bool {
if keyhole(x, y) {
return false
}
// Body: a rounded rectangle.
if roundedRect(x, y, 5, 14.5, 27, 29, 3) {
return true
}
// Shackle: the top half of a ring, with straight legs into the body.
d := math.Hypot(x-16, y-12)
if y <= 12 && d >= 5 && d <= 8.5 {
return true
}
return y > 12 && y < 15 && ((x >= 7.5 && x <= 11) || (x >= 21 && x <= 24.5))
}
func keyhole(x, y float64) bool {
return math.Hypot(x-16, y-20.5) <= 2.6 || (x >= 15 && x <= 17 && y >= 20.5 && y <= 25.5)
}
// badge is the app icon's background: a rounded square filling the canvas.
func badge(x, y float64) bool { return roundedRect(x, y, 0.5, 0.5, 31.5, 31.5, 7) }
func roundedRect(x, y, x0, y0, x1, y1, r float64) bool {
if x < x0 || x > x1 || y < y0 || y > y1 {
return false
}
cx := math.Max(x0+r, math.Min(x, x1-r))
cy := math.Max(y0+r, math.Min(y, y1-r))
return math.Hypot(x-cx, y-cy) <= r
}
// coverage estimates how much of an output pixel a shape covers.
func coverage(in func(x, y float64) bool, px, py int, scale float64) float64 {
step := scale / subsamples
hits := 0
for sy := 0; sy < subsamples; sy++ {
for sx := 0; sx < subsamples; sx++ {
if in(float64(px)*scale+(float64(sx)+0.5)*step, float64(py)*scale+(float64(sy)+0.5)*step) {
hits++
}
}
}
return float64(hits) / (subsamples * subsamples)
}
// Tray draws the bare padlock in c on a transparent background, for the
// system tray or the macOS menu bar.
func Tray(size int, c color.NRGBA) *image.NRGBA {
img := image.NewNRGBA(image.Rect(0, 0, max(size, 0), max(size, 0)))
scale := design / float64(max(size, 1))
for py := 0; py < size; py++ {
for px := 0; px < size; px++ {
if a := coverage(lock, px, py, scale); a > 0 {
img.SetNRGBA(px, py, color.NRGBA{c.R, c.G, c.B, uint8(float64(c.A)*a + 0.5)})
}
}
}
return img
}
// App draws the application icon: a white padlock on a brand-coloured
// rounded square, for launchers, the Dock and file managers.
func App(size int) *image.NRGBA {
img := image.NewNRGBA(image.Rect(0, 0, max(size, 0), max(size, 0)))
scale := design / float64(max(size, 1))
// The padlock is drawn smaller inside the badge, around its centre.
inner := func(x, y float64) bool { return lock((x-16)/0.72+16, (y-16.5)/0.72+16) }
for py := 0; py < size; py++ {
for px := 0; px < size; px++ {
bg := coverage(badge, px, py, scale)
if bg == 0 {
continue
}
fg := coverage(inner, px, py, scale)
mix := func(b, w uint8) uint8 { return uint8(float64(b)*(1-fg) + float64(w)*fg + 0.5) }
img.SetNRGBA(px, py, color.NRGBA{mix(Brand.R, 0xff), mix(Brand.G, 0xff),
mix(Brand.B, 0xff), uint8(255*bg + 0.5)})
}
}
return img
}
// PNG encodes an image. Encoding an in-memory NRGBA cannot fail.
func PNG(img image.Image) []byte {
var b bytes.Buffer
_ = png.Encode(&b, img)
return b.Bytes()
}
// ICO wraps PNG-encoded images in an ICO container, which is what the
// Windows tray takes. PNG entries are valid in ICO since Windows Vista.
func ICO(images ...*image.NRGBA) []byte {
var hdr, data bytes.Buffer
le := binary.LittleEndian
_ = binary.Write(&hdr, le, [3]uint16{0, 1, uint16(len(images))})
offset := 6 + 16*len(images)
for _, img := range images {
p := PNG(img)
w, h := img.Bounds().Dx(), img.Bounds().Dy()
dim := func(n int) uint8 { // 0 means 256 in an ICO directory entry
if n >= 256 {
return 0
}
return uint8(n)
}
_ = binary.Write(&hdr, le, struct {
W, H, Colors, Reserved uint8
Planes, Bits uint16
Size, Offset uint32
}{dim(w), dim(h), 0, 0, 1, 32, uint32(len(p)), uint32(offset)})
data.Write(p)
offset += len(p)
}
return append(hdr.Bytes(), data.Bytes()...)
}
+54
View File
@@ -0,0 +1,54 @@
package icon
import (
"bytes"
"encoding/binary"
"image/png"
"testing"
)
func TestTrayIsOpaqueOnTheBodyAndClearInTheKeyhole(t *testing.T) {
img := Tray(32, Brand)
if a := img.NRGBAAt(8, 26).A; a != 255 {
t.Errorf("body pixel alpha %d, want 255", a)
}
if a := img.NRGBAAt(16, 20).A; a != 0 {
t.Errorf("keyhole pixel alpha %d, want 0", a)
}
if a := img.NRGBAAt(1, 1).A; a != 0 {
t.Errorf("corner pixel alpha %d, want 0", a)
}
}
func TestAppHasAWhiteLockOnTheBadge(t *testing.T) {
img := App(64)
if c := img.NRGBAAt(32, 50); c.R != 255 || c.A != 255 {
t.Errorf("lock body %v, want opaque white", c)
}
if c := img.NRGBAAt(4, 32); c != Brand {
t.Errorf("badge edge %v, want %v", c, Brand)
}
}
func TestICOHoldsEachSizeAsPNG(t *testing.T) {
ico := ICO(Tray(16, Brand), Tray(256, Brand))
var hdr [3]uint16
if err := binary.Read(bytes.NewReader(ico), binary.LittleEndian, &hdr); err != nil {
t.Fatal(err)
}
if hdr != [3]uint16{0, 1, 2} {
t.Fatalf("header %v", hdr)
}
for i, want := range []int{16, 256} {
e := ico[6+16*i:]
size := binary.LittleEndian.Uint32(e[8:])
off := binary.LittleEndian.Uint32(e[12:])
img, err := png.Decode(bytes.NewReader(ico[off : off+size]))
if err != nil {
t.Fatalf("entry %d: %v", i, err)
}
if img.Bounds().Dx() != want {
t.Errorf("entry %d is %dpx, want %d", i, img.Bounds().Dx(), want)
}
}
}
+13
View File
@@ -1,3 +1,16 @@
keeporg (0.5.0) unstable; urgency=medium
* KeepOrg is now a system tray application: started without a command (or
from the applications menu) it shows a padlock whose menu signs in
through the browser, lists each database's state, opens the mirror
folder, the web interface, the settings and the log, and starts at
login. The configuration file now holds every setting (device_label,
request_timeout_seconds, ca_file, log_file, notifications), is written
in full on first start, and is reloaded when saved. Only one agent
synchronizes a mirror directory at a time.
-- Azer Abdullaev <github@reactorcoremeltdown.anonaddy.com> Mon, 02 Nov 2026 11:22:07 +0100
keeporg (0.4.0) unstable; urgency=medium
* keeporg db -delete now asks for your account password
+9 -2
View File
@@ -3,6 +3,8 @@ Version: @VERSION@
Section: utils
Priority: optional
Architecture: @ARCH@
Recommends: libnotify-bin
Suggests: keepassxc, zenity | kdialog
Maintainer: tiredsysadmin <rcmd@garage.rcmd.space>
Homepage: https://garage.rcmd.space/keeporg
Description: KeePass database synchronization client
@@ -13,5 +15,10 @@ Description: KeePass database synchronization client
Each save is pushed to the server and changes other people push are applied
locally. The client never opens a database and never sees a master password.
.
A systemd user unit is included: enable it with
`systemctl --user enable --now keeporg`.
KeepOrg starts as a system tray application from the applications menu; its
menu signs in, shows each database's state and opens the settings. The tray
needs a StatusNotifierItem host: KDE, XFCE, Cinnamon and most panels have
one, GNOME needs the AppIndicator extension.
.
Without a desktop, `keeporg run` is the same agent, and a systemd user unit
is included for it: `systemctl --user enable --now keeporg`.
+11
View File
@@ -0,0 +1,11 @@
[Desktop Entry]
Type=Application
Name=KeepOrg
GenericName=Password database synchronization
Comment=Keep shared KeePass databases in step with a KeepOrg server
Exec=keeporg tray
Icon=keeporg
Terminal=false
Categories=Utility;Security;
Keywords=keepass;kdbx;password;sync;
StartupNotify=false
+3 -1
View File
@@ -15,10 +15,12 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleExecutable</key>
<string>keeporg</string>
<key>CFBundleIconFile</key>
<string>KeepOrg</string>
<key>LSMinimumSystemVersion</key>
<string>11.0</string>
<!-- The agent has no window: it syncs in the background. -->
<!-- A menu-bar application: no Dock icon, no main window. -->
<key>LSUIElement</key>
<true/>
<key>NSHighResolutionCapable</key>
+38 -12
View File
@@ -1,27 +1,53 @@
KeepOrg for Windows
===================
keeporg.exe is the synchronization agent and the administration command.
keeporg-tray.exe is the tray application. keeporg.exe is the same program for a
command prompt: administration commands, and the agent without a tray.
First run
---------
1. Copy both files to %LOCALAPPDATA%\Programs\KeepOrg and start keeporg-tray.exe.
A padlock appears in the notification area.
2. Choose Open Config from its menu, set "server_url" to your server, for
example "https://keeporg.example.com", and save.
3. Choose Sign In... and approve the code in the browser that opens, with
your password or single sign-on.
Your databases appear in %UserProfile%\KeepOrg. Open them with KeePass,
KeePassXC or any compatible application: every save is pushed to the
server, and changes made by other people are applied to the files. The
Databases submenu shows each one's state and opens it.
Tick Start at Login to start KeepOrg when you sign in to Windows.
Settings
--------
%AppData%\keeporg\config.json holds every setting, and the tray applies an
edit as soon as it is saved:
server_url the server, as a browser sees it
device_label this computer's name in the server's session list
mirror_dir where the .kdbx files are kept
poll_seconds how often local saves are looked for
request_timeout_seconds the limit on one request to the server
ca_file a PEM bundle, for a server behind a private CA
log_file the log; empty means keeporg.log beside config.json
notifications desktop notifications (not shown on Windows yet)
username, token written by signing in
Without the tray
----------------
keeporg.exe login -server https://keeporg.example.com -user you
keeporg.exe run
`login` stores its configuration in
%AppData%\keeporg\config.json and mirrors your databases into
%UserProfile%\KeepOrg by default. Pass -dir to choose another directory.
Open the .kdbx files in that directory with KeePass, KeePassXC or any
compatible application. Every save is pushed to the server, and changes made
by other people are applied to the files.
Keeping it running
------------------
Import keeporg-task.xml into Task Scheduler to start the agent at logon:
keeporg-task.xml starts `keeporg.exe run` at logon through Task Scheduler,
for a machine where nobody uses the tray:
schtasks /create /tn KeepOrg /xml keeporg-task.xml
Only one agent synchronizes a directory at a time; a second one reports
that the first is running and stops.
Conflicts
---------
If someone else saved first, your save is kept beside the file as
+8 -1
View File
@@ -16,10 +16,17 @@ case $kind in
client)
pkg=keeporg
binary=keeporg
mkdir -p "$stage/usr/bin" "$stage/usr/lib/systemd/user"
mkdir -p "$stage/usr/bin" "$stage/usr/lib/systemd/user" \
"$stage/usr/share/applications" "$stage/usr/share/icons"
install -m 0755 "$root/build/linux-$arch/keeporg" "$stage/usr/bin/keeporg"
install -m 0644 "$root/packaging/systemd/keeporg.service" \
"$stage/usr/lib/systemd/user/keeporg.service"
# The tray application: a menu entry, and the icon it and the tray use.
install -m 0644 "$root/packaging/linux/keeporg.desktop" \
"$stage/usr/share/applications/keeporg.desktop"
cp -R "$root/build/icons/hicolor" "$stage/usr/share/icons/hicolor"
rm -f "$stage/usr/share/icons/hicolor/.stamp"
find "$stage/usr/share/icons" -type d -exec chmod 0755 {} + -o -type f -exec chmod 0644 {} +
;;
server)
pkg=keeporgd
+3 -3
View File
@@ -21,12 +21,12 @@ app=$stage/KeepOrg.app
rm -rf "$stage"
mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources"
# The bundle runs the client itself: started with no command it is the
# menu-bar application, and the same binary serves `keeporg` in a terminal.
install -m 0755 "$root/build/darwin-$arch/keeporg" "$app/Contents/MacOS/keeporg"
sed -e "s/@VERSION@/$version/" -e "s/@APPID@/$appid/" \
"$root/packaging/macos/Info.plist.in" > "$app/Contents/Info.plist"
install -m 0755 "$root/packaging/macos/launcher.sh" "$app/Contents/MacOS/KeepOrg"
sed -e "s/@APPID@/$appid/" "$root/packaging/macos/launch-agent.plist.in" \
> "$app/Contents/Resources/$appid.plist"
iconutil -c icns -o "$app/Contents/Resources/KeepOrg.icns" "$root/build/icons/KeepOrg.iconset"
cp "$root/docs/HANDOVER.md" "$stage/Read me first.md" 2>/dev/null || true
# The symlink every macOS DMG carries, so installing is one drag.
+46
View File
@@ -0,0 +1,46 @@
// Command genicon writes the application icon for packaging.
//
// genicon -hicolor <dir> <dir>/<n>x<n>/apps/keeporg.png, the Linux icon theme layout
// genicon -iconset <dir> <dir>/icon_<n>x<n>[@2x].png, the input iconutil turns into .icns
package main
import (
"flag"
"fmt"
"os"
"path/filepath"
"cc.tiredsysadmin.keeporg/internal/icon"
)
func main() {
hicolor := flag.String("hicolor", "", "write a hicolor icon theme tree under this directory")
iconset := flag.String("iconset", "", "write a macOS .iconset directory")
flag.Parse()
if *hicolor == "" && *iconset == "" {
flag.Usage()
os.Exit(2)
}
if *hicolor != "" {
for _, n := range []int{16, 32, 48, 64, 128, 256, 512} {
write(filepath.Join(*hicolor, fmt.Sprintf("%dx%d", n, n), "apps", "keeporg.png"), n)
}
}
if *iconset != "" {
for _, n := range []int{16, 32, 128, 256, 512} {
write(filepath.Join(*iconset, fmt.Sprintf("icon_%dx%d.png", n, n)), n)
write(filepath.Join(*iconset, fmt.Sprintf("icon_%dx%d@2x.png", n, n)), 2*n)
}
}
}
func write(path string, size int) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
fmt.Fprintln(os.Stderr, "genicon:", err)
os.Exit(1)
}
if err := os.WriteFile(path, icon.PNG(icon.App(size)), 0o644); err != nil {
fmt.Fprintln(os.Stderr, "genicon:", err)
os.Exit(1)
}
}
+4 -4
View File
@@ -1,10 +1,10 @@
# Single source of truth for all platform versions (semver).
# Bump with: make bump PLATFORM=<server|linux|macos|windows|bsd|android> PART=<major|minor|patch>
VERSION_SERVER := 0.7.0
VERSION_LINUX := 0.4.0
VERSION_MACOS := 0.4.0
VERSION_WINDOWS := 0.4.0
VERSION_BSD := 0.4.0
VERSION_LINUX := 0.5.0
VERSION_MACOS := 0.5.0
VERSION_WINDOWS := 0.5.0
VERSION_BSD := 0.5.0
VERSION_ANDROID := 0.2.0
# Monotonic Android versionCode, never decreases.
ANDROID_VERSION_CODE := 2