feat: keep Verstak running in the system tray

This commit is contained in:
2026-07-14 02:54:30 +08:00
parent e33c63d3ad
commit d8a4213569
9 changed files with 354 additions and 2 deletions
+74
View File
@@ -0,0 +1,74 @@
// Package tray owns the native tray menu wiring for the desktop shell.
package tray
import "sync"
// MenuItem exposes click events from a native tray menu item.
type MenuItem interface {
Clicked() <-chan struct{}
}
// Backend is the platform tray implementation.
type Backend interface {
Register(onReady func(), onExit func())
SetIcon(icon []byte)
SetTooltip(tooltip string)
AddMenuItem(title, tooltip string) MenuItem
Quit()
}
// Actions are executed from the native tray menu.
type Actions struct {
Show func()
Quit func()
}
// Controller initializes one native tray and routes its menu actions.
type Controller struct {
backend Backend
icon []byte
start sync.Once
}
// New creates a tray controller for one application process.
func New(backend Backend, icon []byte) *Controller {
return &Controller{
backend: backend,
icon: append([]byte(nil), icon...),
}
}
// Start registers the native tray without taking over the Wails event loop.
func (c *Controller) Start(actions Actions) {
if c == nil || c.backend == nil {
return
}
c.start.Do(func() {
c.backend.Register(func() {
c.backend.SetIcon(c.icon)
c.backend.SetTooltip("Verstak")
show := c.backend.AddMenuItem("Show Verstak", "Show the Verstak window")
quit := c.backend.AddMenuItem("Quit", "Quit Verstak")
if actions.Show != nil && show != nil {
go routeClicks(show.Clicked(), actions.Show)
}
if actions.Quit != nil && quit != nil {
go routeClicks(quit.Clicked(), actions.Quit)
}
}, nil)
})
}
// Stop releases the native tray after Wails has begun application shutdown.
func (c *Controller) Stop() {
if c == nil || c.backend == nil {
return
}
c.backend.Quit()
}
func routeClicks(clicked <-chan struct{}, action func()) {
for range clicked {
action()
}
}
+91
View File
@@ -0,0 +1,91 @@
package tray
import (
"testing"
"time"
)
type fakeMenuItem struct {
clicked chan struct{}
}
func (i *fakeMenuItem) Clicked() <-chan struct{} {
return i.clicked
}
type fakeBackend struct {
icon []byte
tooltip string
items map[string]*fakeMenuItem
quitCalls int
registering bool
}
func (b *fakeBackend) Register(onReady func(), _ func()) {
b.registering = true
onReady()
}
func (b *fakeBackend) SetIcon(icon []byte) {
b.icon = append([]byte(nil), icon...)
}
func (b *fakeBackend) SetTooltip(tooltip string) {
b.tooltip = tooltip
}
func (b *fakeBackend) AddMenuItem(title, _ string) MenuItem {
item := &fakeMenuItem{clicked: make(chan struct{}, 1)}
b.items[title] = item
return item
}
func (b *fakeBackend) Quit() {
b.quitCalls++
}
func waitFor(t *testing.T, signal <-chan struct{}) {
t.Helper()
select {
case <-signal:
case <-time.After(time.Second):
t.Fatal("timed out waiting for tray action")
}
}
func TestControllerInitializesTrayAndRoutesMenuActions(t *testing.T) {
backend := &fakeBackend{items: make(map[string]*fakeMenuItem)}
showCalls := make(chan struct{}, 1)
quitCalls := make(chan struct{}, 1)
controller := New(backend, []byte{1, 2, 3})
controller.Start(Actions{
Show: func() { showCalls <- struct{}{} },
Quit: func() { quitCalls <- struct{}{} },
})
if !backend.registering || string(backend.icon) != string([]byte{1, 2, 3}) || backend.tooltip != "Verstak" {
t.Fatalf("tray initialization = registering:%t icon:%v tooltip:%q", backend.registering, backend.icon, backend.tooltip)
}
showItem := backend.items["Show Verstak"]
quitItem := backend.items["Quit"]
if showItem == nil || quitItem == nil {
t.Fatalf("tray menu = %#v, want Show Verstak and Quit", backend.items)
}
showItem.clicked <- struct{}{}
waitFor(t, showCalls)
quitItem.clicked <- struct{}{}
waitFor(t, quitCalls)
}
func TestControllerStopsNativeTrayBackend(t *testing.T) {
backend := &fakeBackend{items: make(map[string]*fakeMenuItem)}
controller := New(backend, []byte{1})
controller.Stop()
if backend.quitCalls != 1 {
t.Fatalf("backend quit calls = %d, want 1", backend.quitCalls)
}
}
+14
View File
@@ -0,0 +1,14 @@
package tray
import "encoding/base64"
const iconPNGBase64 = "iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAACT1BMVEUAAACOjo7///8RERHY2NghISEDAwOjo6MODg7GxsYQEBDIyMgPDw/Hx8cKCgq9vb12dnaHh4fFxcV1dXWjo6OoqKinp6eNjY25ubnr6+v29vb29vb29vbx8fHV1dXm5ub9/f3z8/OUlJQAAADu7u74+Pi5ubkAAADu7u74+Pi6urq6urrs7Oz39/ezs7PZ2dn5+fn8/Pzq6uobGxtdXV3U1NTl5eXl5eXl5eXm5ubd3d2urq7////+/v7//v7//v/s7OzLy8vLy8ru7u708/POzc3Kysrd3d39/f3Z2dnU1NT7+/u0tLQpKSkoKCcnJycsLCy+vr63t7crKyskJCRzc3NQUFAlJSUoKChvb28gICAdHR0cHBzDwsJ6enoaGhkZGRltbW3MzMwqKioeHh6pqam+vb0iIiJGRkYbGxseHR0aGhpubm6cnJw6Ojrg4ODExMQmJiYeHh36+vppaWhxcXH8/PwvLy/a2tmKiorl5eU+Pj6zs7PQ0NA1NTXR0dFSUlIbGhq/v79BQUHn5+fW1tYuLi48PDyysrItLS02NjaNjY17e3v///7b29syMjJ/f38hISEqKSlqampaWlq9vb3f398cGxs9PT1KSko3NzeioqJRUVFJSUnk5OQaGRljY2OGhobp6ek/Pz+dnZ3IyMgoKSjHx8ZDQ0PHx8fx8fHw8PBVVVX39/cdHh2RkZHz8/NPT0+Ojo7Dw8PPz8/29vZUVFTCwsL19fX4+PhYWFgWFhYXFxfAwMCqqqqFhYWHhoaEhITe3t6MjIzi4uIwLXXJAAAAPHRSTlMAAAAAAAAAAAAAAAAAAAAAAAAABRIWFQoUis3R0K82b/22DQGZ2x0Cm9wfHo/UGULi+IIEAzZudHNzUA0lLC8OAAAAAWJLR0QCZgt8ZAAAAAd0SU1FB+oHDRIyN3EuEOkAAAHzSURBVDjLvdPXXxNBEAdwPHvvFewde8WyG3NGT41KlFxMwoyKguLeCCZRUCzRgIXYUexEjR17713/MPfCB+Vy8ZXfw+3tzvdlbueyslolbfr26z8gQwYqbVP1doMGD8nOsSU7Z+gwpb2sd1CGj2CZM3JURwk6jR4j37l5wDlvejrkssjBxo7rLEGX8RPk1qmqi1UXW7JU05bx5Ss0VXOvZBNzu/4Fq1bne9asZQVej+7jrnX+gB6UYNI/UFAIqK9nGzZC0Sa2uRihZAuzgK2lAB7VtU0YtJ2VIWD5DisI+QG87lAYIhTcuYsiVFFpBVo+4O49VQKAKvbuE1C6XzbWEhwoBowePBSDEvJV+0HUHE4DR44iHquN43G9yHdCROgkP2UFp8+gqDkLdcFzdL6ewH9Bfi8LuBjFS5ev0NVrAdEQRZFwpoPr5QiB8I2b1WHw1oFIMkcauHVbGAB4565sF+De/dS9tATOB8IwMO5uNEGs4aENPEpIQI+fhHR4CvjMrFvB8xcxA8RL9kqHCMZf28Gbt2RAoJG9k4Dq39vBh4+f6HPCxb4UElFZqm4FlV+/ff9Ry9nPqmTy128L6GYCR/OwNY9eE8jtLkEPZTL/z9BOmdrTHHtl2nSeMTNmzuplgt6z58zNm2dL3vwFC/u0yo/7B9C02RGfGBOeAAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDI2LTA3LTEzVDE0OjIyOjMzKzAwOjAwJHU36gAAACV0RVh0ZGF0ZTptb2RpZnkAMjAyNi0wNy0xM1QxNDoyMjozMyswMDowMFUoj1YAAAAASUVORK5CYII="
// DefaultIcon returns the compact source-controlled Verstak tray icon.
func DefaultIcon() []byte {
icon, err := base64.StdEncoding.DecodeString(iconPNGBase64)
if err != nil {
return nil
}
return icon
}
+41
View File
@@ -0,0 +1,41 @@
package tray
import "github.com/getlantern/systray"
type systrayBackend struct{}
type systrayMenuItem struct {
item *systray.MenuItem
}
// NewNativeBackend creates the cross-platform native tray backend.
func NewNativeBackend() Backend {
return systrayBackend{}
}
func (systrayBackend) Register(onReady func(), onExit func()) {
systray.Register(onReady, onExit)
}
func (systrayBackend) SetIcon(icon []byte) {
systray.SetIcon(icon)
}
func (systrayBackend) SetTooltip(tooltip string) {
systray.SetTooltip(tooltip)
}
func (systrayBackend) AddMenuItem(title, tooltip string) MenuItem {
return systrayMenuItem{item: systray.AddMenuItem(title, tooltip)}
}
func (systrayBackend) Quit() {
systray.Quit()
}
func (item systrayMenuItem) Clicked() <-chan struct{} {
if item.item == nil {
return nil
}
return item.item.ClickedCh
}