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