Files
Behavision/desktop/icons.go
Suriyakumarvijayanayagam 4c62fc0ef3 The Loyaly mark everywhere a person sees the product
Brand assets in brand/ (the 512px mark, sizes for each surface, a
multi-size .ico). Windows executables carry it as a compiled-in
resource (rsrc_windows_amd64.syso from go-winres) so Explorer, the
taskbar and the installer show it; installer/build.ps1 therefore uses a
plain go build rather than wails build, which would add a second copy
and fail the link. The tray icon is the mark with a state dot over its
corner - a plain coloured circle read as a generic status light among
other icons - rendered from the embedded PNG at 32px so it survives
150% scaling. The desktop app's login, setup and sidebar marks, the
head-office web app's mark and favicon, and the engine dashboard's
favicon are the same file.

Also found while packaging: no wheel so far shipped static/, so the
engine's own dashboard at :8010 on a Windows source install would have
failed with a missing file. package-data now includes it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-19 12:54:19 +05:30

198 lines
6.9 KiB
Go

package main
import (
"bytes"
_ "embed"
"encoding/binary"
"image"
"image/color"
"image/png"
"runtime"
"sync"
agentpaths "github.com/loyaly/behavision-agent/pkg/paths"
)
// iconFor renders the tray icon at run time: the Loyaly mark with a state
// dot in the corner. Green, amber, red or grey is the only thing a taskbar
// conveys at this size, and the mark is what makes it OURS among a row of
// other icons - a plain coloured circle read as a generic status light.
//
// The mark is embedded once at 128px and scaled down here, so the four
// states cannot drift apart and no file can go missing from a build.
//
// The encoding is per-platform and is NOT cosmetic. systray writes these bytes
// to a temp file and, on Windows, hands the path to LoadImageW with
// IMAGE_ICON|LR_LOADFROMFILE - which decodes .ico and nothing else. A PNG
// there returns 0, systray logs "unable to set icon", and the product ships
// with no tray icon at all: the one control surface a shop manager has.
func iconFor(state string) []byte {
img := trayImage(colorFor(state))
if runtime.GOOS == "windows" {
return encodeICO(img)
}
var buf bytes.Buffer
_ = png.Encode(&buf, img)
return buf.Bytes()
}
// colorFor maps engine state to the only thing the taskbar conveys at 16px.
func colorFor(state string) color.RGBA {
switch state {
case "ok":
return color.RGBA{R: 0x2E, G: 0x9E, B: 0x60, A: 0xFF} // green
case "warn":
return color.RGBA{R: 0xE0, G: 0xA3, B: 0x3A, A: 0xFF} // amber
case "error":
return color.RGBA{R: 0xD1, G: 0x4B, B: 0x3F, A: 0xFF} // red
default:
return color.RGBA{R: 0x86, G: 0x93, B: 0x9E, A: 0xFF} // grey
}
}
// 32px rather than 16: Windows shows 16 at 100% scaling and 24 at 150%, and
// scaling a 32 down looks right at both, where a 16 scaled up looks like 2005.
const iconSize = 32
//go:embed tray-logo.png
var trayLogoPNG []byte
var (
trayLogoOnce sync.Once
trayLogo *image.RGBA
)
// logo is the embedded mark, decoded once and box-filtered down to iconSize.
// A box filter rather than nearest-neighbour: 128->32 is an exact 4x4 average
// and nearest would drop three pixels in four, which shreds the thin outline.
func logo() *image.RGBA {
trayLogoOnce.Do(func() {
src, err := png.Decode(bytes.NewReader(trayLogoPNG))
if err != nil {
trayLogo = image.NewRGBA(image.Rect(0, 0, iconSize, iconSize))
return
}
b := src.Bounds()
f := b.Dx() / iconSize
out := image.NewRGBA(image.Rect(0, 0, iconSize, iconSize))
for y := 0; y < iconSize; y++ {
for x := 0; x < iconSize; x++ {
var r, g, bl, a uint64
for dy := 0; dy < f; dy++ {
for dx := 0; dx < f; dx++ {
// Premultiplied so transparent pixels do not drag the
// colour of the edge towards black.
pr, pg, pb, pa := src.At(b.Min.X+x*f+dx, b.Min.Y+y*f+dy).RGBA()
r += uint64(pr)
g += uint64(pg)
bl += uint64(pb)
a += uint64(pa)
}
}
n := uint64(f * f)
out.SetRGBA(x, y, color.RGBA{
R: uint8(r / n >> 8), G: uint8(g / n >> 8), B: uint8(bl / n >> 8), A: uint8(a / n >> 8),
})
}
}
trayLogo = out
})
return trayLogo
}
// trayImage is the mark with a state dot over its bottom-right corner, ringed
// so it reads against both the yellow of the mark and a dark taskbar.
func trayImage(c color.RGBA) *image.RGBA {
base := logo()
img := image.NewRGBA(base.Bounds())
copy(img.Pix, base.Pix)
const r = 6.0
cx, cy := float64(iconSize)-r-0.5, float64(iconSize)-r-0.5
ring := color.RGBA{R: 0x11, G: 0x14, B: 0x18, A: 0xFF}
for y := 0; y < iconSize; y++ {
for x := 0; x < iconSize; x++ {
dx, dy := float64(x)-cx, float64(y)-cy
d := dx*dx + dy*dy
switch {
case d <= (r-1.5)*(r-1.5):
img.SetRGBA(x, y, c)
case d <= r*r:
img.SetRGBA(x, y, ring)
case d <= (r+1)*(r+1):
// One-pixel feathered edge; a hard-aliased circle looks broken
// next to every other icon in the tray.
a := uint8(255 * ((r+1)*(r+1) - d) / ((r+1)*(r+1) - r*r))
bg := img.RGBAAt(x, y)
img.SetRGBA(x, y, blend(bg, ring, a))
}
}
}
return img
}
func blend(under, over color.RGBA, a uint8) color.RGBA {
fa := float64(a) / 255
mix := func(u, o uint8) uint8 { return uint8(float64(u)*(1-fa) + float64(o)*fa) }
ua := float64(under.A)/255*(1-fa) + fa
return color.RGBA{R: mix(under.R, over.R), G: mix(under.G, over.G), B: mix(under.B, over.B), A: uint8(ua * 255)}
}
// encodeICO writes a single-image .ico holding an uncompressed 32-bit DIB.
//
// Vista and later also accept a PNG stored inside the .ico container, which
// would be a dozen lines instead of forty. It is not worth the risk: which
// Windows builds accept PNG-in-ICO through LoadImage rather than through the
// newer imaging APIs is genuinely murky, the failure is silent, and it would
// only ever be discovered on a customer's counter. A DIB is what every version
// of Windows has always loaded.
func encodeICO(img *image.RGBA) []byte {
w, h := img.Bounds().Dx(), img.Bounds().Dy()
// 1bpp AND mask, each row padded to a 4-byte boundary. Unused with a 32-bit
// alpha channel, but the format requires it to be present and sized.
maskRow := ((w + 31) / 32) * 4
xor := w * h * 4
dib := 40 + xor + maskRow*h
var b bytes.Buffer
// ICONDIR
binary.Write(&b, binary.LittleEndian, uint16(0)) // reserved
binary.Write(&b, binary.LittleEndian, uint16(1)) // type: icon
binary.Write(&b, binary.LittleEndian, uint16(1)) // image count
// ICONDIRENTRY. 256 is encoded as 0 in these byte fields; at 16px it is moot.
b.WriteByte(byte(w))
b.WriteByte(byte(h))
b.WriteByte(0) // palette size: none
b.WriteByte(0) // reserved
binary.Write(&b, binary.LittleEndian, uint16(1)) // colour planes
binary.Write(&b, binary.LittleEndian, uint16(32)) // bits per pixel
binary.Write(&b, binary.LittleEndian, uint32(dib)) // bytes in resource
binary.Write(&b, binary.LittleEndian, uint32(6+16)) // offset to that data
// BITMAPINFOHEADER. Height is doubled because the DIB nominally stacks the
// colour image and the AND mask; omitting the doubling renders half an icon
// stretched over the whole square.
binary.Write(&b, binary.LittleEndian, uint32(40))
binary.Write(&b, binary.LittleEndian, int32(w))
binary.Write(&b, binary.LittleEndian, int32(h*2))
binary.Write(&b, binary.LittleEndian, uint16(1))
binary.Write(&b, binary.LittleEndian, uint16(32))
binary.Write(&b, binary.LittleEndian, uint32(0)) // BI_RGB, uncompressed
binary.Write(&b, binary.LittleEndian, uint32(xor+maskRow*h))
for i := 0; i < 4; i++ { // resolution and palette counts, all unused
binary.Write(&b, binary.LittleEndian, uint32(0))
}
// Pixels: BGRA, bottom-up. Go's image is top-down and RGBA, so both the row
// order and the channel order invert here.
for y := h - 1; y >= 0; y-- {
for x := 0; x < w; x++ {
c := img.RGBAAt(x, y)
b.Write([]byte{c.B, c.G, c.R, c.A})
}
}
b.Write(make([]byte, maskRow*h)) // all-zero: every pixel opaque per the mask
return b.Bytes()
}
func logsDir() string { return agentpaths.StateRoot() }