Files
ReactorcoremeltdownandClaude Opus 5.5 ead82dc777 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>
2026-11-02 12:30:28 +01:00

150 lines
4.6 KiB
Go

// 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()...)
}