Turn the desktop client into a system tray application
Started without a command, keeporg now shows a padlock in the system tray with a menu like midi-webhook's: a status line, the mirror folder, each database with its state, Sync Now, browser sign-in and sign-out, the web interface, the configuration, the log, Start at Login, About and Quit. The commands are unchanged. config.json now carries every client setting (device_label, request_timeout_seconds, ca_file, log_file, notifications), is written in full on first start and is reloaded by the tray when saved. A lock in the mirror directory admits one agent at a time, since the tray, `keeporg run` and a service unit could otherwise race each other. The tray uses fyne.io/systray: pure Go on Linux, BSD and Windows, cgo on macOS only. The server keeps no third-party dependencies. Desktop versions go to 0.5.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d314188247
commit
ead82dc777
+134
-12
@@ -1,22 +1,48 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config is what a client remembers between runs. The session token lives
|
||||
// here; the KDBX master passwords never do, because the client never opens a
|
||||
// database, it only moves bytes.
|
||||
// Config is what a client remembers between runs, and every setting a user
|
||||
// can change. The session token lives here; the KDBX master passwords never
|
||||
// do, because the client never opens a database, it only moves bytes.
|
||||
//
|
||||
// The file is written with every field present, so editing it is the whole
|
||||
// of the client's settings interface: the tray's "Open Config" opens it, and
|
||||
// the tray picks up a saved change without restarting.
|
||||
type Config struct {
|
||||
// ServerURL is the server's base URL, as a browser sees it.
|
||||
ServerURL string `json:"server_url"`
|
||||
Token string `json:"token"`
|
||||
Username string `json:"username"`
|
||||
// Username and Token are written by signing in, not by hand.
|
||||
Username string `json:"username"`
|
||||
Token string `json:"token"`
|
||||
// DeviceLabel names this device in the server's session list.
|
||||
DeviceLabel string `json:"device_label"`
|
||||
// MirrorDir is where the databases appear as .kdbx files.
|
||||
MirrorDir string `json:"mirror_dir"`
|
||||
// PollSeconds is how often the mirror is rescanned for local saves.
|
||||
PollSeconds int `json:"poll_seconds"`
|
||||
// RequestTimeoutSeconds bounds one API call. The event stream is exempt.
|
||||
RequestTimeoutSeconds int `json:"request_timeout_seconds"`
|
||||
// CAFile is a PEM bundle trusted in addition to the system roots, for a
|
||||
// server behind a private certificate authority.
|
||||
CAFile string `json:"ca_file"`
|
||||
// LogFile is where the agent logs. Empty means standard error for
|
||||
// `keeporg run`, and keeporg.log beside this file for the tray.
|
||||
LogFile string `json:"log_file"`
|
||||
// Notifications turns the tray's desktop notifications on or off.
|
||||
Notifications bool `json:"notifications"`
|
||||
}
|
||||
|
||||
// ErrNoConfig means the client has not been logged in yet.
|
||||
@@ -40,7 +66,24 @@ func DefaultMirrorDir() string {
|
||||
return filepath.Join(home, "KeepOrg")
|
||||
}
|
||||
|
||||
// LoadConfig reads a configuration file.
|
||||
// DefaultConfig is a configuration with every setting at its default and no
|
||||
// server.
|
||||
func DefaultConfig() *Config {
|
||||
label, err := os.Hostname()
|
||||
if err != nil || label == "" {
|
||||
label = "keeporg client"
|
||||
}
|
||||
return &Config{
|
||||
DeviceLabel: label,
|
||||
MirrorDir: DefaultMirrorDir(),
|
||||
PollSeconds: 2,
|
||||
RequestTimeoutSeconds: 60,
|
||||
Notifications: true,
|
||||
}
|
||||
}
|
||||
|
||||
// LoadConfig reads a configuration file. A setting the file leaves out keeps
|
||||
// its default, so an older file reads the same as it always did.
|
||||
func LoadConfig(path string) (*Config, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
@@ -49,19 +92,85 @@ func LoadConfig(path string) (*Config, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var c Config
|
||||
if err := json.Unmarshal(b, &c); err != nil {
|
||||
return nil, err
|
||||
c := DefaultConfig()
|
||||
if err := json.Unmarshal(b, c); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
def := DefaultConfig()
|
||||
if c.MirrorDir == "" {
|
||||
c.MirrorDir = DefaultMirrorDir()
|
||||
c.MirrorDir = def.MirrorDir
|
||||
}
|
||||
if c.DeviceLabel == "" {
|
||||
c.DeviceLabel = def.DeviceLabel
|
||||
}
|
||||
if c.PollSeconds <= 0 {
|
||||
c.PollSeconds = 2
|
||||
c.PollSeconds = def.PollSeconds
|
||||
}
|
||||
return &c, nil
|
||||
if c.RequestTimeoutSeconds <= 0 {
|
||||
c.RequestTimeoutSeconds = def.RequestTimeoutSeconds
|
||||
}
|
||||
c.ServerURL = strings.TrimRight(c.ServerURL, "/")
|
||||
c.MirrorDir = expandHome(c.MirrorDir)
|
||||
c.CAFile = expandHome(c.CAFile)
|
||||
c.LogFile = expandHome(c.LogFile)
|
||||
return c, c.Validate()
|
||||
}
|
||||
|
||||
// EnsureConfig returns the configuration at path, writing a default one
|
||||
// first if there is none, so a user always has a complete file to edit.
|
||||
func EnsureConfig(path string) (*Config, error) {
|
||||
c, err := LoadConfig(path)
|
||||
if !errors.Is(err, ErrNoConfig) {
|
||||
return c, err
|
||||
}
|
||||
c = DefaultConfig()
|
||||
return c, c.Save(path)
|
||||
}
|
||||
|
||||
// Validate reports the first setting that cannot work.
|
||||
func (c *Config) Validate() error {
|
||||
if c.ServerURL != "" {
|
||||
u, err := url.Parse(c.ServerURL)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return fmt.Errorf("server_url %q must be an http:// or https:// URL", c.ServerURL)
|
||||
}
|
||||
}
|
||||
if c.CAFile != "" {
|
||||
if _, err := os.Stat(c.CAFile); err != nil {
|
||||
return fmt.Errorf("ca_file: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// API returns a client for the configured server, signed in with the stored
|
||||
// token when there is one.
|
||||
func (c *Config) API() (*API, error) {
|
||||
api := NewAPI(c.ServerURL, c.Token)
|
||||
api.HTTP.Timeout = time.Duration(c.RequestTimeoutSeconds) * time.Second
|
||||
if c.CAFile == "" {
|
||||
return api, nil
|
||||
}
|
||||
pem, err := os.ReadFile(c.CAFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ca_file: %w", err)
|
||||
}
|
||||
pool, err := x509.SystemCertPool()
|
||||
if err != nil {
|
||||
pool = x509.NewCertPool()
|
||||
}
|
||||
if !pool.AppendCertsFromPEM(pem) {
|
||||
return nil, fmt.Errorf("ca_file %s holds no PEM certificates", c.CAFile)
|
||||
}
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
tr.TLSClientConfig = &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}
|
||||
api.HTTP.Transport = tr
|
||||
return api, nil
|
||||
}
|
||||
|
||||
// Poll is PollSeconds as a duration.
|
||||
func (c *Config) Poll() time.Duration { return time.Duration(c.PollSeconds) * time.Second }
|
||||
|
||||
// Save writes the configuration with owner-only permissions, since it holds
|
||||
// a bearer token.
|
||||
func (c *Config) Save(path string) error {
|
||||
@@ -78,3 +187,16 @@ func (c *Config) Save(path string) error {
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// expandHome turns a leading ~ into the home directory, since the file is
|
||||
// meant to be edited by hand.
|
||||
func expandHome(p string) string {
|
||||
if p != "~" && !strings.HasPrefix(p, "~/") && !strings.HasPrefix(p, `~\`) {
|
||||
return p
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return p
|
||||
}
|
||||
return filepath.Join(home, p[1:])
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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()...)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user