Files
kettuRay/tray.go
2026-03-31 14:40:03 +03:00

186 lines
4.6 KiB
Go

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