package main import ( "bytes" "image" "image/color" "image/png" "github.com/getlantern/systray" "github.com/wailsapp/wails/v2/pkg/runtime" ) // Precomputed tray icons var ( iconGray = makeVpnIcon(130, 130, 130) iconGreen = makeVpnIcon(76, 175, 80) iconYellow = makeVpnIcon(253, 216, 53) iconRed = makeVpnIcon(229, 57, 53) ) func makeVpnIcon(r, g, b uint8) []byte { const size = 16 img := image.NewNRGBA(image.Rect(0, 0, size, size)) bg := color.NRGBA{R: r, G: g, B: b, A: 255} type pt struct{ x, y int } skip := make(map[pt]bool) // Make a horizontal pill-shaped rectangle // Skip rows 0, 1, 14, 15 for x := 0; x < size; x++ { skip[pt{x, 0}] = true; skip[pt{x, 1}] = true skip[pt{x, 14}] = true; skip[pt{x, 15}] = true } // Round corners of the remaining rectangle (rows 2-13) // y=2 and y=13 skip x=0,1, 14,15 skip[pt{0, 2}] = true; skip[pt{1, 2}] = true; skip[pt{14, 2}] = true; skip[pt{15, 2}] = true skip[pt{0, 13}] = true; skip[pt{1, 13}] = true; skip[pt{14, 13}] = true; skip[pt{15, 13}] = true // y=3 and y=12 skip x=0, 15 skip[pt{0, 3}] = true; skip[pt{15, 3}] = true skip[pt{0, 12}] = true; skip[pt{15, 12}] = true // Draw rounded background for y := 0; y < size; y++ { for x := 0; x < size; x++ { if !skip[pt{x, y}] { img.SetNRGBA(x, y, bg) } } } // Draw "VPN" text in white using pixel font white := color.NRGBA{255, 255, 255, 255} // V (x: 2-4, y: 5-9) img.SetNRGBA(2, 5, white); img.SetNRGBA(4, 5, white) img.SetNRGBA(2, 6, white); img.SetNRGBA(4, 6, white) img.SetNRGBA(2, 7, white); img.SetNRGBA(4, 7, white) img.SetNRGBA(3, 8, white); img.SetNRGBA(3, 9, white) // P (x: 6-8, y: 5-9) for y := 5; y <= 9; y++ { img.SetNRGBA(6, y, white) } img.SetNRGBA(7, 5, white); img.SetNRGBA(8, 5, white) img.SetNRGBA(8, 6, white) img.SetNRGBA(7, 7, white); img.SetNRGBA(8, 7, white) // N (x: 10-13, y: 5-9) for y := 5; y <= 9; y++ { img.SetNRGBA(10, y, white); img.SetNRGBA(13, y, white) } img.SetNRGBA(11, 6, white) img.SetNRGBA(11, 7, white); img.SetNRGBA(12, 7, white) img.SetNRGBA(12, 8, white) var buf bytes.Buffer _ = png.Encode(&buf, img) // Wrap PNG in ICO format pngData := buf.Bytes() var ico bytes.Buffer ico.Write([]byte{0, 0, 1, 0, 1, 0}) // ICONDIR ico.Write([]byte{16, 16, 0, 0, 1, 0, 32, 0}) // ICONDIRENTRY (16x16, 32bpp) sizeBytes := len(pngData) ico.WriteByte(byte(sizeBytes & 0xFF)) ico.WriteByte(byte((sizeBytes >> 8) & 0xFF)) ico.WriteByte(byte((sizeBytes >> 16) & 0xFF)) ico.WriteByte(byte((sizeBytes >> 24) & 0xFF)) ico.Write([]byte{22, 0, 0, 0}) // Image offset ico.Write(pngData) return ico.Bytes() } func (a *App) setupTray() { go systray.Run(a.onTrayReady, func() {}) } func (a *App) onTrayReady() { systray.SetIcon(iconGray) systray.SetTooltip("kettuRay — Disconnected") mToggle := systray.AddMenuItem("Connect", "Toggle VPN") systray.AddSeparator() mShow := systray.AddMenuItem("Show", "Show window") systray.AddSeparator() mQuit := systray.AddMenuItem("Exit", "Exit kettuRay") // Store menu item reference for text updates a.mu.Lock() a.trayToggleItem = mToggle a.mu.Unlock() go func() { for { select { case <-mToggle.ClickedCh: a.toggleVPN() case <-mShow.ClickedCh: runtime.WindowShow(a.ctx) case <-mQuit.ClickedCh: runtime.Quit(a.ctx) } } }() } func (a *App) updateTrayState(state string) { switch state { case "Connected": systray.SetIcon(iconGreen) systray.SetTooltip("kettuRay — Connected") a.mu.Lock() if a.trayToggleItem != nil { a.trayToggleItem.SetTitle("Disconnect") } a.mu.Unlock() case "Connecting": systray.SetIcon(iconYellow) systray.SetTooltip("kettuRay — Connecting...") a.mu.Lock() if a.trayToggleItem != nil { a.trayToggleItem.SetTitle("Connecting...") } a.mu.Unlock() case "Disconnecting": systray.SetIcon(iconYellow) systray.SetTooltip("kettuRay — Disconnecting...") case "Error": systray.SetIcon(iconRed) systray.SetTooltip("kettuRay — Error") a.mu.Lock() if a.trayToggleItem != nil { a.trayToggleItem.SetTitle("Connect") } a.mu.Unlock() default: // Disconnected systray.SetIcon(iconGray) systray.SetTooltip("kettuRay — Disconnected") a.mu.Lock() if a.trayToggleItem != nil { a.trayToggleItem.SetTitle("Connect") } a.mu.Unlock() } } func (a *App) toggleVPN() { a.mu.Lock() state := a.manager.State id := a.selectedConfigID a.mu.Unlock() switch state { case 2: // Connected go a.manager.Disconnect() case 1: // Connecting go a.manager.Disconnect() default: if id != "" { go a.connectByID(id) } } } // HideWindow hides the app window (minimize to tray). func (a *App) HideWindow() { runtime.WindowHide(a.ctx) }