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>
150 lines
4.6 KiB
Go
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()...)
|
|
}
|