fix: restore Windows tray lifecycle
This commit is contained in:
@@ -2,10 +2,15 @@
|
||||
package tray
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
var errBackendUnavailable = errors.New("tray backend is unavailable")
|
||||
|
||||
// MenuItem exposes click events from a native tray menu item.
|
||||
type MenuItem interface {
|
||||
Clicked() <-chan struct{}
|
||||
@@ -13,13 +18,22 @@ type MenuItem interface {
|
||||
SetTooltip(tooltip string)
|
||||
}
|
||||
|
||||
// BackendCallbacks are invoked by the native tray implementation.
|
||||
// A backend keeps the platform-native secondary-click handler enabled so the
|
||||
// operating system opens the menu on a right click.
|
||||
type BackendCallbacks struct {
|
||||
Ready func()
|
||||
Exit func()
|
||||
LeftClick func()
|
||||
}
|
||||
|
||||
// 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()
|
||||
Start(callbacks BackendCallbacks) error
|
||||
SetIcon(icon []byte) error
|
||||
SetTooltip(tooltip string) error
|
||||
AddMenuItem(title, tooltip string) (MenuItem, error)
|
||||
Stop()
|
||||
}
|
||||
|
||||
// Actions are executed from the native tray menu.
|
||||
@@ -80,11 +94,20 @@ func LabelsForPreference(preference string, systemLocales ...string) Labels {
|
||||
type Controller struct {
|
||||
backend Backend
|
||||
icon []byte
|
||||
start sync.Once
|
||||
mu sync.RWMutex
|
||||
labels Labels
|
||||
show MenuItem
|
||||
quit MenuItem
|
||||
|
||||
startOnce sync.Once
|
||||
readyOnce sync.Once
|
||||
stopOnce sync.Once
|
||||
started atomic.Bool
|
||||
ready atomic.Bool
|
||||
|
||||
mu sync.RWMutex
|
||||
labels Labels
|
||||
actions Actions
|
||||
show MenuItem
|
||||
quit MenuItem
|
||||
startErr error
|
||||
readyChanged func(bool)
|
||||
}
|
||||
|
||||
// New creates a tray controller for one application process.
|
||||
@@ -96,6 +119,23 @@ func New(backend Backend, icon []byte) *Controller {
|
||||
}
|
||||
}
|
||||
|
||||
// SetReadyChangedHandler receives readiness changes after native initialization
|
||||
// has either completed or ended. It lets the Wails close policy avoid hiding a
|
||||
// window before the tray can bring it back.
|
||||
func (c *Controller) SetReadyChangedHandler(handler func(bool)) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.readyChanged = handler
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// Ready reports whether the tray has completed icon and menu initialization.
|
||||
func (c *Controller) Ready() bool {
|
||||
return c != nil && c.ready.Load()
|
||||
}
|
||||
|
||||
// SetLabels updates the current and future native tray menu labels.
|
||||
func (c *Controller) SetLabels(labels Labels) {
|
||||
if c == nil {
|
||||
@@ -108,35 +148,142 @@ func (c *Controller) SetLabels(labels Labels) {
|
||||
applyLabels(show, quit, labels)
|
||||
}
|
||||
|
||||
// Start registers the native tray without taking over the Wails event loop.
|
||||
func (c *Controller) Start(actions Actions) {
|
||||
// Start connects the tray to Wails without taking over Wails' GUI event loop.
|
||||
// Ready remains false until the backend callback and all required menu setup
|
||||
// finish successfully.
|
||||
func (c *Controller) Start(actions Actions) error {
|
||||
if c == nil || c.backend == nil {
|
||||
return errBackendUnavailable
|
||||
}
|
||||
c.startOnce.Do(func() {
|
||||
c.mu.Lock()
|
||||
c.actions = actions
|
||||
c.mu.Unlock()
|
||||
c.started.Store(true)
|
||||
log.Printf("[tray] starting native backend")
|
||||
err := c.backend.Start(BackendCallbacks{
|
||||
Ready: c.onReady,
|
||||
Exit: c.onExit,
|
||||
LeftClick: c.onLeftClick,
|
||||
})
|
||||
if err != nil {
|
||||
c.mu.Lock()
|
||||
c.startErr = err
|
||||
c.mu.Unlock()
|
||||
c.started.Store(false)
|
||||
c.setReady(false)
|
||||
log.Printf("[tray] backend start failed: %v; falling back to normal window close", err)
|
||||
}
|
||||
})
|
||||
c.mu.RLock()
|
||||
err := c.startErr
|
||||
c.mu.RUnlock()
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Controller) onReady() {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.start.Do(func() {
|
||||
c.backend.Register(func() {
|
||||
c.backend.SetIcon(c.icon)
|
||||
c.backend.SetTooltip("Verstak")
|
||||
c.mu.RLock()
|
||||
labels := c.labels
|
||||
c.mu.RUnlock()
|
||||
show := c.backend.AddMenuItem(labels.ShowTitle, labels.ShowTooltip)
|
||||
quit := c.backend.AddMenuItem(labels.QuitTitle, labels.QuitTooltip)
|
||||
c.mu.Lock()
|
||||
c.show, c.quit = show, quit
|
||||
labels = c.labels
|
||||
c.mu.Unlock()
|
||||
applyLabels(show, quit, labels)
|
||||
if actions.Show != nil && show != nil {
|
||||
go routeClicks(show.Clicked(), actions.Show)
|
||||
if !c.started.Load() {
|
||||
log.Printf("[tray] ready callback ignored after backend startup failed")
|
||||
return
|
||||
}
|
||||
c.readyOnce.Do(func() {
|
||||
log.Printf("[tray] native backend reported ready")
|
||||
if err := c.backend.SetIcon(c.icon); err != nil {
|
||||
c.fail("icon setup", err)
|
||||
return
|
||||
}
|
||||
if err := c.backend.SetTooltip("Verstak"); err != nil {
|
||||
c.fail("tooltip setup", err)
|
||||
return
|
||||
}
|
||||
c.mu.RLock()
|
||||
labels := c.labels
|
||||
actions := c.actions
|
||||
c.mu.RUnlock()
|
||||
show, err := c.backend.AddMenuItem(labels.ShowTitle, labels.ShowTooltip)
|
||||
if err != nil || show == nil {
|
||||
if err == nil {
|
||||
err = errors.New("show menu item is nil")
|
||||
}
|
||||
if actions.Quit != nil && quit != nil {
|
||||
go routeClicks(quit.Clicked(), actions.Quit)
|
||||
c.fail("show menu creation", err)
|
||||
return
|
||||
}
|
||||
quit, err := c.backend.AddMenuItem(labels.QuitTitle, labels.QuitTooltip)
|
||||
if err != nil || quit == nil {
|
||||
if err == nil {
|
||||
err = errors.New("quit menu item is nil")
|
||||
}
|
||||
}, nil)
|
||||
c.fail("quit menu creation", err)
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.show, c.quit = show, quit
|
||||
labels = c.labels
|
||||
c.mu.Unlock()
|
||||
applyLabels(show, quit, labels)
|
||||
if actions.Show != nil {
|
||||
go routeClicks(show.Clicked(), func() {
|
||||
log.Printf("[tray] Show command")
|
||||
actions.Show()
|
||||
})
|
||||
}
|
||||
if actions.Quit != nil {
|
||||
go routeClicks(quit.Clicked(), func() {
|
||||
log.Printf("[tray] Quit command")
|
||||
actions.Quit()
|
||||
})
|
||||
}
|
||||
c.setReady(true)
|
||||
log.Printf("[tray] native tray is ready")
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Controller) onLeftClick() {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
log.Printf("[tray] left click")
|
||||
if !c.Ready() {
|
||||
log.Printf("[tray] left click ignored while tray is not ready")
|
||||
return
|
||||
}
|
||||
c.mu.RLock()
|
||||
show := c.actions.Show
|
||||
c.mu.RUnlock()
|
||||
if show != nil {
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Controller) onExit() {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.setReady(false)
|
||||
log.Printf("[tray] native message loop ended; falling back to normal window close")
|
||||
}
|
||||
|
||||
func (c *Controller) fail(stage string, err error) {
|
||||
c.setReady(false)
|
||||
log.Printf("[tray] %s failed: %v; falling back to normal window close", stage, err)
|
||||
c.Stop()
|
||||
}
|
||||
|
||||
func (c *Controller) setReady(ready bool) {
|
||||
if c == nil || c.ready.Swap(ready) == ready {
|
||||
return
|
||||
}
|
||||
c.mu.RLock()
|
||||
handler := c.readyChanged
|
||||
c.mu.RUnlock()
|
||||
if handler != nil {
|
||||
handler(ready)
|
||||
}
|
||||
}
|
||||
|
||||
func applyLabels(show, quit MenuItem, labels Labels) {
|
||||
if show != nil {
|
||||
show.SetTitle(labels.ShowTitle)
|
||||
@@ -150,10 +297,14 @@ func applyLabels(show, quit MenuItem, labels Labels) {
|
||||
|
||||
// Stop releases the native tray after Wails has begun application shutdown.
|
||||
func (c *Controller) Stop() {
|
||||
if c == nil || c.backend == nil {
|
||||
if c == nil || c.backend == nil || !c.started.Load() {
|
||||
return
|
||||
}
|
||||
c.backend.Quit()
|
||||
c.stopOnce.Do(func() {
|
||||
c.setReady(false)
|
||||
log.Printf("[tray] stopping native backend")
|
||||
c.backend.Stop()
|
||||
})
|
||||
}
|
||||
|
||||
func routeClicks(clicked <-chan struct{}, action func()) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package tray
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -11,47 +13,65 @@ type fakeMenuItem struct {
|
||||
tooltip string
|
||||
}
|
||||
|
||||
func (i *fakeMenuItem) Clicked() <-chan struct{} {
|
||||
return i.clicked
|
||||
}
|
||||
|
||||
func (i *fakeMenuItem) SetTitle(title string) {
|
||||
i.title = title
|
||||
}
|
||||
|
||||
func (i *fakeMenuItem) SetTooltip(tooltip string) {
|
||||
i.tooltip = tooltip
|
||||
}
|
||||
func (i *fakeMenuItem) Clicked() <-chan struct{} { return i.clicked }
|
||||
func (i *fakeMenuItem) SetTitle(title string) { i.title = title }
|
||||
func (i *fakeMenuItem) SetTooltip(tooltip string) { i.tooltip = tooltip }
|
||||
|
||||
type fakeBackend struct {
|
||||
icon []byte
|
||||
tooltip string
|
||||
items map[string]*fakeMenuItem
|
||||
quitCalls int
|
||||
registering bool
|
||||
callbacks BackendCallbacks
|
||||
icon []byte
|
||||
tooltip string
|
||||
items map[string]*fakeMenuItem
|
||||
startErr error
|
||||
iconErr error
|
||||
menuErr error
|
||||
starts int
|
||||
stops int
|
||||
}
|
||||
|
||||
func (b *fakeBackend) Register(onReady func(), _ func()) {
|
||||
b.registering = true
|
||||
onReady()
|
||||
func (b *fakeBackend) Start(callbacks BackendCallbacks) error {
|
||||
b.starts++
|
||||
b.callbacks = callbacks
|
||||
return b.startErr
|
||||
}
|
||||
|
||||
func (b *fakeBackend) SetIcon(icon []byte) {
|
||||
func (b *fakeBackend) SetIcon(icon []byte) error {
|
||||
b.icon = append([]byte(nil), icon...)
|
||||
return b.iconErr
|
||||
}
|
||||
|
||||
func (b *fakeBackend) SetTooltip(tooltip string) {
|
||||
func (b *fakeBackend) SetTooltip(tooltip string) error {
|
||||
b.tooltip = tooltip
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *fakeBackend) AddMenuItem(title, _ string) MenuItem {
|
||||
item := &fakeMenuItem{clicked: make(chan struct{}, 1), title: title}
|
||||
func (b *fakeBackend) AddMenuItem(title, tooltip string) (MenuItem, error) {
|
||||
if b.menuErr != nil {
|
||||
return nil, b.menuErr
|
||||
}
|
||||
item := &fakeMenuItem{clicked: make(chan struct{}, 1), title: title, tooltip: tooltip}
|
||||
b.items[title] = item
|
||||
return item
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (b *fakeBackend) Quit() {
|
||||
b.quitCalls++
|
||||
func (b *fakeBackend) Stop() { b.stops++ }
|
||||
|
||||
func (b *fakeBackend) ready() {
|
||||
if b.callbacks.Ready != nil {
|
||||
b.callbacks.Ready()
|
||||
}
|
||||
}
|
||||
|
||||
func (b *fakeBackend) exited() {
|
||||
if b.callbacks.Exit != nil {
|
||||
b.callbacks.Exit()
|
||||
}
|
||||
}
|
||||
|
||||
func (b *fakeBackend) leftClick() {
|
||||
if b.callbacks.LeftClick != nil {
|
||||
b.callbacks.LeftClick()
|
||||
}
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, signal <-chan struct{}) {
|
||||
@@ -63,64 +83,112 @@ func waitFor(t *testing.T, signal <-chan struct{}) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestControllerInitializesTrayAndRoutesMenuActions(t *testing.T) {
|
||||
func TestControllerBecomesReadyOnlyAfterBackendCallback(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{
|
||||
if err := controller.Start(Actions{}); err != nil {
|
||||
t.Fatalf("Start() error = %v", err)
|
||||
}
|
||||
if controller.Ready() {
|
||||
t.Fatal("tray became ready before backend callback")
|
||||
}
|
||||
|
||||
backend.ready()
|
||||
if !controller.Ready() {
|
||||
t.Fatal("tray did not become ready after backend callback")
|
||||
}
|
||||
if string(backend.icon) != string([]byte{1, 2, 3}) || backend.tooltip != "Verstak" {
|
||||
t.Fatalf("tray initialization = icon:%v tooltip:%q", backend.icon, backend.tooltip)
|
||||
}
|
||||
}
|
||||
|
||||
func TestControllerRoutesLeftClickAndMenuActionsToShowAndQuit(t *testing.T) {
|
||||
backend := &fakeBackend{items: make(map[string]*fakeMenuItem)}
|
||||
showCalls := make(chan struct{}, 2)
|
||||
quitCalls := make(chan struct{}, 1)
|
||||
controller := New(backend, []byte{1})
|
||||
|
||||
if err := 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)
|
||||
}); err != nil {
|
||||
t.Fatalf("Start() error = %v", err)
|
||||
}
|
||||
backend.ready()
|
||||
|
||||
backend.leftClick()
|
||||
waitFor(t, showCalls)
|
||||
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) {
|
||||
func TestControllerStopCallsBackendOnce(t *testing.T) {
|
||||
backend := &fakeBackend{items: make(map[string]*fakeMenuItem)}
|
||||
controller := New(backend, []byte{1})
|
||||
if err := controller.Start(Actions{}); err != nil {
|
||||
t.Fatalf("Start() error = %v", err)
|
||||
}
|
||||
|
||||
controller.Stop()
|
||||
controller.Stop()
|
||||
|
||||
if backend.quitCalls != 1 {
|
||||
t.Fatalf("backend quit calls = %d, want 1", backend.quitCalls)
|
||||
if backend.stops != 1 {
|
||||
t.Fatalf("backend stop calls = %d, want 1", backend.stops)
|
||||
}
|
||||
}
|
||||
|
||||
func TestControllerUsesAndUpdatesLocalizedLabels(t *testing.T) {
|
||||
func TestControllerStartupAndSetupFailuresNeverBecomeReady(t *testing.T) {
|
||||
for name, backend := range map[string]*fakeBackend{
|
||||
"start": {items: make(map[string]*fakeMenuItem), startErr: errors.New("start failed")},
|
||||
"icon": {items: make(map[string]*fakeMenuItem), iconErr: errors.New("icon failed")},
|
||||
"menu": {items: make(map[string]*fakeMenuItem), menuErr: errors.New("menu failed")},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
controller := New(backend, []byte{1})
|
||||
err := controller.Start(Actions{})
|
||||
if name == "start" && err == nil {
|
||||
t.Fatal("Start() error = nil, want startup failure")
|
||||
}
|
||||
if name != "start" && err != nil {
|
||||
t.Fatalf("Start() error = %v, want nil before ready callback", err)
|
||||
}
|
||||
backend.ready()
|
||||
if controller.Ready() {
|
||||
t.Fatal("failed tray became ready")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestControllerExitRevokesReadinessAndKeepsLocalizedMenuItems(t *testing.T) {
|
||||
backend := &fakeBackend{items: make(map[string]*fakeMenuItem)}
|
||||
controller := New(backend, []byte{1})
|
||||
controller.SetLabels(LabelsForPreference("ru"))
|
||||
controller.Start(Actions{})
|
||||
if err := controller.Start(Actions{}); err != nil {
|
||||
t.Fatalf("Start() error = %v", err)
|
||||
}
|
||||
backend.ready()
|
||||
|
||||
show := backend.items["Показать Верстак"]
|
||||
quit := backend.items["Выйти"]
|
||||
if show == nil || quit == nil {
|
||||
t.Fatalf("Russian tray menu = %#v", backend.items)
|
||||
}
|
||||
if show.tooltip != "Показать окно Верстака" || quit.tooltip != "Завершить Верстак" {
|
||||
t.Fatalf("Russian tray tooltips = show:%q quit:%q", show.tooltip, quit.tooltip)
|
||||
}
|
||||
|
||||
controller.SetLabels(LabelsForPreference("en"))
|
||||
if show.title != "Show Verstak" || quit.title != "Quit" {
|
||||
t.Fatalf("English tray menu after update = show:%q quit:%q", show.title, quit.title)
|
||||
}
|
||||
if show.tooltip != "Show the Verstak window" || quit.tooltip != "Quit Verstak" {
|
||||
t.Fatalf("English tray tooltips after update = show:%q quit:%q", show.tooltip, quit.tooltip)
|
||||
backend.exited()
|
||||
if controller.Ready() {
|
||||
t.Fatal("tray stayed ready after backend exit")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,3 +202,26 @@ func TestLabelsForSystemRussianLocale(t *testing.T) {
|
||||
t.Fatalf("higher-priority system locale must win, got %#v", labels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestControllerReadyNotificationIsSafeAcrossCallbacks(t *testing.T) {
|
||||
backend := &fakeBackend{items: make(map[string]*fakeMenuItem)}
|
||||
controller := New(backend, []byte{1})
|
||||
var changes []bool
|
||||
var mu sync.Mutex
|
||||
controller.SetReadyChangedHandler(func(ready bool) {
|
||||
mu.Lock()
|
||||
changes = append(changes, ready)
|
||||
mu.Unlock()
|
||||
})
|
||||
if err := controller.Start(Actions{}); err != nil {
|
||||
t.Fatalf("Start() error = %v", err)
|
||||
}
|
||||
backend.ready()
|
||||
backend.exited()
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(changes) != 2 || !changes[0] || changes[1] {
|
||||
t.Fatalf("ready changes = %#v, want [true false]", changes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//go:build linux
|
||||
|
||||
package tray
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed verstak.png
|
||||
var defaultIcon []byte
|
||||
|
||||
// DefaultIcon returns the Linux PNG tray icon embedded in the binary.
|
||||
func DefaultIcon() []byte {
|
||||
return append([]byte(nil), defaultIcon...)
|
||||
}
|
||||
|
||||
// IconFileExtension is used when the backend materializes the embedded icon.
|
||||
func IconFileExtension() string { return ".png" }
|
||||
@@ -0,0 +1,18 @@
|
||||
//go:build linux
|
||||
|
||||
package tray
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDefaultIconUsesPlatformResource(t *testing.T) {
|
||||
icon := DefaultIcon()
|
||||
if len(icon) == 0 {
|
||||
t.Fatal("DefaultIcon() returned no data")
|
||||
}
|
||||
if IconFileExtension() != ".png" {
|
||||
t.Fatalf("IconFileExtension() = %q, want .png on Linux", IconFileExtension())
|
||||
}
|
||||
if string(icon[:8]) != "\x89PNG\r\n\x1a\n" {
|
||||
t.Fatal("DefaultIcon() is not PNG data on Linux")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//go:build windows
|
||||
|
||||
package tray
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed verstak.ico
|
||||
var defaultIcon []byte
|
||||
|
||||
// DefaultIcon returns the multi-resolution Windows ICO embedded in the binary.
|
||||
func DefaultIcon() []byte {
|
||||
return append([]byte(nil), defaultIcon...)
|
||||
}
|
||||
|
||||
// IconFileExtension is used when the backend materializes the embedded icon.
|
||||
func IconFileExtension() string { return ".ico" }
|
||||
@@ -0,0 +1,37 @@
|
||||
//go:build windows
|
||||
|
||||
package tray
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultIconIsMultiResolutionICO(t *testing.T) {
|
||||
icon := DefaultIcon()
|
||||
if IconFileExtension() != ".ico" || len(icon) < 6 {
|
||||
t.Fatalf("Windows tray icon is not ICO data")
|
||||
}
|
||||
if binary.LittleEndian.Uint16(icon[0:2]) != 0 || binary.LittleEndian.Uint16(icon[2:4]) != 1 {
|
||||
t.Fatal("Windows tray icon has an invalid ICO header")
|
||||
}
|
||||
count := int(binary.LittleEndian.Uint16(icon[4:6]))
|
||||
if count < 6 {
|
||||
t.Fatalf("ICO image count = %d, want at least 6", count)
|
||||
}
|
||||
want := map[int]bool{16: false, 20: false, 24: false, 32: false, 48: false, 256: false}
|
||||
for offset := 6; offset+16 <= len(icon) && offset < 6+count*16; offset += 16 {
|
||||
size := int(icon[offset])
|
||||
if size == 0 {
|
||||
size = 256
|
||||
}
|
||||
if _, ok := want[size]; ok {
|
||||
want[size] = true
|
||||
}
|
||||
}
|
||||
for size, found := range want {
|
||||
if !found {
|
||||
t.Errorf("ICO is missing %dx%d image", size, size)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,36 +1,141 @@
|
||||
package tray
|
||||
|
||||
import "github.com/getlantern/systray"
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
type systrayBackend struct{}
|
||||
"fyne.io/systray"
|
||||
)
|
||||
|
||||
var (
|
||||
runWithExternalLoop = systray.RunWithExternalLoop
|
||||
setOnTapped = systray.SetOnTapped
|
||||
)
|
||||
|
||||
type systrayBackend struct {
|
||||
mu sync.Mutex
|
||||
end func()
|
||||
iconPath string
|
||||
started bool
|
||||
stopRequested bool
|
||||
}
|
||||
|
||||
type systrayMenuItem struct {
|
||||
item *systray.MenuItem
|
||||
}
|
||||
|
||||
// NewNativeBackend creates the cross-platform native tray backend.
|
||||
// NewNativeBackend creates the cross-platform tray backend. It uses
|
||||
// RunWithExternalLoop so the native tray message loop runs alongside Wails.
|
||||
func NewNativeBackend() Backend {
|
||||
return systrayBackend{}
|
||||
return &systrayBackend{}
|
||||
}
|
||||
|
||||
func (systrayBackend) Register(onReady func(), onExit func()) {
|
||||
systray.Register(onReady, onExit)
|
||||
func (b *systrayBackend) Start(callbacks BackendCallbacks) (err error) {
|
||||
if b == nil {
|
||||
return errBackendUnavailable
|
||||
}
|
||||
b.mu.Lock()
|
||||
if b.started {
|
||||
b.mu.Unlock()
|
||||
return errors.New("native tray backend was already started")
|
||||
}
|
||||
b.started = true
|
||||
b.mu.Unlock()
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
err = fmt.Errorf("native tray startup panic: %v", recovered)
|
||||
}
|
||||
}()
|
||||
start, end := runWithExternalLoop(callbacks.Ready, callbacks.Exit)
|
||||
if callbacks.LeftClick != nil {
|
||||
setOnTapped(callbacks.LeftClick)
|
||||
}
|
||||
// The Ready callback is asynchronous on Windows and can synchronously
|
||||
// decide that startup failed. Start the native loop before publishing the
|
||||
// end function so a concurrent Stop is never left with a half-started loop.
|
||||
start()
|
||||
b.mu.Lock()
|
||||
stopRequested := b.stopRequested
|
||||
if !stopRequested {
|
||||
b.end = end
|
||||
}
|
||||
b.mu.Unlock()
|
||||
if stopRequested {
|
||||
end()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (systrayBackend) SetIcon(icon []byte) {
|
||||
systray.SetIcon(icon)
|
||||
func (b *systrayBackend) SetIcon(icon []byte) error {
|
||||
if b == nil || len(icon) == 0 {
|
||||
return errors.New("tray icon is empty")
|
||||
}
|
||||
file, err := os.CreateTemp("", "verstak-tray-*"+IconFileExtension())
|
||||
if err != nil {
|
||||
return fmt.Errorf("create tray icon file: %w", err)
|
||||
}
|
||||
path := file.Name()
|
||||
if _, err := file.Write(icon); err != nil {
|
||||
file.Close()
|
||||
os.Remove(path)
|
||||
return fmt.Errorf("write tray icon file: %w", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
os.Remove(path)
|
||||
return fmt.Errorf("close tray icon file: %w", err)
|
||||
}
|
||||
if err := systray.SetIconFromFilePath(path); err != nil {
|
||||
os.Remove(path)
|
||||
return fmt.Errorf("set native tray icon: %w", err)
|
||||
}
|
||||
b.mu.Lock()
|
||||
previous := b.iconPath
|
||||
b.iconPath = path
|
||||
b.mu.Unlock()
|
||||
if previous != "" && previous != path {
|
||||
_ = os.Remove(previous)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (systrayBackend) SetTooltip(tooltip string) {
|
||||
func (b *systrayBackend) SetTooltip(tooltip string) error {
|
||||
if strings.TrimSpace(tooltip) == "" {
|
||||
return errors.New("tray tooltip is empty")
|
||||
}
|
||||
systray.SetTooltip(tooltip)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (systrayBackend) AddMenuItem(title, tooltip string) MenuItem {
|
||||
return systrayMenuItem{item: systray.AddMenuItem(title, tooltip)}
|
||||
func (b *systrayBackend) AddMenuItem(title, tooltip string) (MenuItem, error) {
|
||||
if strings.TrimSpace(title) == "" {
|
||||
return nil, errors.New("tray menu title is empty")
|
||||
}
|
||||
item := systray.AddMenuItem(title, tooltip)
|
||||
if item == nil {
|
||||
return nil, errors.New("native tray menu item is nil")
|
||||
}
|
||||
return systrayMenuItem{item: item}, nil
|
||||
}
|
||||
|
||||
func (systrayBackend) Quit() {
|
||||
systray.Quit()
|
||||
func (b *systrayBackend) Stop() {
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
b.mu.Lock()
|
||||
end, iconPath := b.end, b.iconPath
|
||||
b.end = nil
|
||||
b.iconPath = ""
|
||||
b.stopRequested = true
|
||||
b.mu.Unlock()
|
||||
if end != nil {
|
||||
end()
|
||||
}
|
||||
if iconPath != "" {
|
||||
_ = os.Remove(iconPath)
|
||||
}
|
||||
}
|
||||
|
||||
func (item systrayMenuItem) Clicked() <-chan struct{} {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package tray
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBackendStopsWhenReadyCallbackStopsDuringStartup(t *testing.T) {
|
||||
originalRun := runWithExternalLoop
|
||||
originalSetOnTapped := setOnTapped
|
||||
defer func() {
|
||||
runWithExternalLoop = originalRun
|
||||
setOnTapped = originalSetOnTapped
|
||||
}()
|
||||
|
||||
var startCalls, endCalls int
|
||||
runWithExternalLoop = func(onReady, _ func()) (func(), func()) {
|
||||
onReady()
|
||||
return func() { startCalls++ }, func() { endCalls++ }
|
||||
}
|
||||
setOnTapped = func(func()) {}
|
||||
|
||||
backend := &systrayBackend{}
|
||||
if err := backend.Start(BackendCallbacks{Ready: backend.Stop}); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if startCalls != 1 {
|
||||
t.Fatalf("start calls = %d, want 1", startCalls)
|
||||
}
|
||||
if endCalls != 1 {
|
||||
t.Fatalf("end calls = %d, want 1", endCalls)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Reference in New Issue
Block a user