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
+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)
}
}
}