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
@@ -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()...)
|
||||
}
|
||||
Reference in New Issue
Block a user