315 lines
7.3 KiB
Go
315 lines
7.3 KiB
Go
// Package tray owns the native tray menu wiring for the desktop shell.
|
|
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{}
|
|
SetTitle(title string)
|
|
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 {
|
|
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.
|
|
type Actions struct {
|
|
Show func()
|
|
Quit func()
|
|
}
|
|
|
|
// Labels contains the user-visible text for one tray menu locale.
|
|
type Labels struct {
|
|
ShowTitle string
|
|
ShowTooltip string
|
|
QuitTitle string
|
|
QuitTooltip string
|
|
}
|
|
|
|
func englishLabels() Labels {
|
|
return Labels{
|
|
ShowTitle: "Show Verstak",
|
|
ShowTooltip: "Show the Verstak window",
|
|
QuitTitle: "Quit",
|
|
QuitTooltip: "Quit Verstak",
|
|
}
|
|
}
|
|
|
|
func russianLabels() Labels {
|
|
return Labels{
|
|
ShowTitle: "Показать Верстак",
|
|
ShowTooltip: "Показать окно Верстака",
|
|
QuitTitle: "Выйти",
|
|
QuitTooltip: "Завершить Верстак",
|
|
}
|
|
}
|
|
|
|
// LabelsForPreference resolves the saved language preference for native tray UI.
|
|
// System locale values are intentionally passed in by the shell so this package
|
|
// stays independent of process environment and is deterministic in tests.
|
|
func LabelsForPreference(preference string, systemLocales ...string) Labels {
|
|
if strings.EqualFold(strings.TrimSpace(preference), "ru") {
|
|
return russianLabels()
|
|
}
|
|
if strings.EqualFold(strings.TrimSpace(preference), "system") {
|
|
for _, locale := range systemLocales {
|
|
locale = strings.ToLower(strings.TrimSpace(locale))
|
|
if locale == "" {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(locale, "ru") {
|
|
return russianLabels()
|
|
}
|
|
return englishLabels()
|
|
}
|
|
}
|
|
return englishLabels()
|
|
}
|
|
|
|
// Controller initializes one native tray and routes its menu actions.
|
|
type Controller struct {
|
|
backend Backend
|
|
icon []byte
|
|
|
|
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.
|
|
func New(backend Backend, icon []byte) *Controller {
|
|
return &Controller{
|
|
backend: backend,
|
|
icon: append([]byte(nil), icon...),
|
|
labels: englishLabels(),
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
return
|
|
}
|
|
c.mu.Lock()
|
|
c.labels = labels
|
|
show, quit := c.show, c.quit
|
|
c.mu.Unlock()
|
|
applyLabels(show, quit, labels)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
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")
|
|
}
|
|
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")
|
|
}
|
|
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)
|
|
show.SetTooltip(labels.ShowTooltip)
|
|
}
|
|
if quit != nil {
|
|
quit.SetTitle(labels.QuitTitle)
|
|
quit.SetTooltip(labels.QuitTooltip)
|
|
}
|
|
}
|
|
|
|
// Stop releases the native tray after Wails has begun application shutdown.
|
|
func (c *Controller) Stop() {
|
|
if c == nil || c.backend == nil || !c.started.Load() {
|
|
return
|
|
}
|
|
c.stopOnce.Do(func() {
|
|
c.setReady(false)
|
|
log.Printf("[tray] stopping native backend")
|
|
c.backend.Stop()
|
|
})
|
|
}
|
|
|
|
func routeClicks(clicked <-chan struct{}, action func()) {
|
|
for range clicked {
|
|
action()
|
|
}
|
|
}
|