opencode-provider-switch/internal/desktop/tray_wails.go
apale7 8fc72e87e4 fix(desktop): finish Windows shell polish
Stabilize the tray icon and locale handling on Windows while aligning provider and alias management panels with the refreshed desktop branding.
2026-04-20 02:36:54 +08:00

330 lines
8.1 KiB
Go

//go:build desktop_wails
package desktop
import (
"context"
_ "embed"
"fmt"
"runtime"
"strings"
"sync"
"time"
"fyne.io/systray"
frontendassets "github.com/Apale7/opencode-provider-switch/frontend"
"github.com/Apale7/opencode-provider-switch/internal/app"
)
//go:embed assets/icon.ico
var trayIcon []byte
// Tray wires resident-mode controls into a native system tray.
type Tray struct {
service *app.Service
mu sync.Mutex
ctx context.Context
prefs app.DesktopPrefsView
language string
registered bool
ready bool
running bool
quitting bool
statusItem *systray.MenuItem
showItem *systray.MenuItem
hideItem *systray.MenuItem
startItem *systray.MenuItem
stopItem *systray.MenuItem
quitItem *systray.MenuItem
}
func NewTray(service *app.Service) *Tray {
return &Tray{service: service}
}
func (t *Tray) Attach(ctx context.Context) {
t.mu.Lock()
t.ctx = ctx
if t.registered {
t.mu.Unlock()
return
}
t.registered = true
t.mu.Unlock()
go func() {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
systray.Run(t.onReady, t.onExit)
}()
}
func (t *Tray) Detach() {
t.mu.Lock()
t.ctx = nil
registered := t.registered
t.mu.Unlock()
if registered {
systray.Quit()
}
}
func (t *Tray) Sync(ctx context.Context, prefs app.DesktopPrefsView) {
t.mu.Lock()
if ctx != nil {
t.ctx = ctx
}
t.prefs = prefs
t.language = trayLanguage(prefs.Language)
ready := t.ready
statusItem := t.statusItem
showItem := t.showItem
hideItem := t.hideItem
startItem := t.startItem
stopItem := t.stopItem
quitItem := t.quitItem
labels := t.trayLabelsLocked()
t.mu.Unlock()
if ready && statusItem != nil && showItem != nil && hideItem != nil && startItem != nil && stopItem != nil && quitItem != nil {
systray.SetTitle(labels.appTitle)
systray.SetTooltip(labels.appTooltip)
showItem.SetTitle(labels.openWindow)
showItem.SetTooltip(labels.openWindowHint)
hideItem.SetTitle(labels.hideWindow)
hideItem.SetTooltip(labels.hideWindowHint)
startItem.SetTitle(labels.startProxy)
startItem.SetTooltip(labels.startProxyHint)
stopItem.SetTitle(labels.stopProxy)
stopItem.SetTooltip(labels.stopProxyHint)
quitItem.SetTitle(labels.quit)
quitItem.SetTooltip(labels.quitHint)
}
t.refresh()
}
func (t *Tray) RefreshProxyStatus(ctx context.Context) {
t.mu.Lock()
if ctx != nil {
t.ctx = ctx
}
t.mu.Unlock()
t.refresh()
}
func (t *Tray) BeforeClose(ctx context.Context) (bool, error) {
t.mu.Lock()
t.ctx = ctx
quitting := t.quitting
minimize := t.prefs.MinimizeToTray
t.mu.Unlock()
if quitting || !minimize {
return false, nil
}
return true, hideWindow(ctx)
}
func (t *Tray) onReady() {
labels := t.labels()
if len(trayIcon) > 0 {
systray.SetIcon(trayIcon)
}
systray.SetTitle(labels.appTitle)
systray.SetTooltip(labels.appTooltip)
systray.SetOnTapped(func() {
_ = t.withContext(showWindow)
})
t.mu.Lock()
t.ready = true
t.running = true
t.statusItem = systray.AddMenuItem(labels.proxyChecking, labels.proxyStatusHint)
t.statusItem.Disable()
systray.AddSeparator()
t.showItem = systray.AddMenuItem(labels.openWindow, labels.openWindowHint)
t.hideItem = systray.AddMenuItem(labels.hideWindow, labels.hideWindowHint)
systray.AddSeparator()
t.startItem = systray.AddMenuItem(labels.startProxy, labels.startProxyHint)
t.stopItem = systray.AddMenuItem(labels.stopProxy, labels.stopProxyHint)
systray.AddSeparator()
t.quitItem = systray.AddMenuItem(labels.quit, labels.quitHint)
t.mu.Unlock()
go t.loop()
t.refresh()
}
func (t *Tray) onExit() {
t.mu.Lock()
t.ready = false
t.running = false
t.registered = false
t.statusItem = nil
t.showItem = nil
t.hideItem = nil
t.startItem = nil
t.stopItem = nil
t.quitItem = nil
t.mu.Unlock()
}
func (t *Tray) loop() {
for {
t.mu.Lock()
showItem := t.showItem
hideItem := t.hideItem
startItem := t.startItem
stopItem := t.stopItem
quitItem := t.quitItem
running := t.running
t.mu.Unlock()
if !running || showItem == nil || hideItem == nil || startItem == nil || stopItem == nil || quitItem == nil {
return
}
select {
case <-showItem.ClickedCh:
_ = t.withContext(showWindow)
case <-hideItem.ClickedCh:
_ = t.withContext(hideWindow)
case <-startItem.ClickedCh:
t.startProxy()
case <-stopItem.ClickedCh:
t.stopProxy()
case <-quitItem.ClickedCh:
t.requestQuit()
return
}
}
}
func (t *Tray) startProxy() {
if t.service == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = t.service.StartProxy(ctx)
t.refresh()
}
func (t *Tray) stopProxy() {
if t.service == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = t.service.StopProxy(ctx)
t.refresh()
}
func (t *Tray) requestQuit() {
t.mu.Lock()
t.quitting = true
ctx := t.ctx
t.mu.Unlock()
if ctx == nil {
return
}
_ = quitWindow(ctx)
}
func (t *Tray) withContext(fn func(context.Context) error) error {
t.mu.Lock()
ctx := t.ctx
t.mu.Unlock()
if ctx == nil {
return nil
}
return fn(ctx)
}
func (t *Tray) refresh() {
t.mu.Lock()
ready := t.ready
statusItem := t.statusItem
startItem := t.startItem
stopItem := t.stopItem
labels := t.trayLabelsLocked()
t.mu.Unlock()
if !ready || statusItem == nil || startItem == nil || stopItem == nil || t.service == nil {
return
}
status, err := t.service.GetProxyStatus(context.Background())
if err != nil {
statusItem.SetTitle(labels.proxyUnavailable)
startItem.Enable()
stopItem.Disable()
return
}
if status.Running {
statusItem.SetTitle(fmt.Sprintf(labels.proxyRunning, status.BindAddress))
startItem.Disable()
stopItem.Enable()
return
}
statusItem.SetTitle(fmt.Sprintf(labels.proxyStopped, status.BindAddress))
startItem.Enable()
stopItem.Disable()
}
func (t *Tray) labels() trayLabels {
t.mu.Lock()
defer t.mu.Unlock()
return t.trayLabelsLocked()
}
func (t *Tray) trayLabelsLocked() trayLabels {
return trayLabels{
appTitle: trayLocaleString(t.language, "tray", "appTitle", "ocswitch"),
appTooltip: trayLocaleString(t.language, "tray", "appTooltip", "ocswitch desktop"),
proxyChecking: trayLocaleString(t.language, "tray", "proxyChecking", "Proxy: checking..."),
proxyStatusHint: trayLocaleString(t.language, "tray", "proxyStatusHint", "Current proxy status"),
proxyUnavailable: trayLocaleString(t.language, "tray", "proxyUnavailable", "Proxy: unavailable"),
proxyRunning: trayLocaleString(t.language, "tray", "proxyRunning", "Proxy: running (%s)"),
proxyStopped: trayLocaleString(t.language, "tray", "proxyStopped", "Proxy: stopped (%s)"),
openWindow: trayLocaleString(t.language, "tray", "openWindow", "Open window"),
openWindowHint: trayLocaleString(t.language, "tray", "openWindowHint", "Show desktop window"),
hideWindow: trayLocaleString(t.language, "tray", "hideWindow", "Hide window"),
hideWindowHint: trayLocaleString(t.language, "tray", "hideWindowHint", "Hide desktop window"),
startProxy: trayLocaleString(t.language, "tray", "startProxy", "Start proxy"),
startProxyHint: trayLocaleString(t.language, "tray", "startProxyHint", "Start local proxy"),
stopProxy: trayLocaleString(t.language, "tray", "stopProxy", "Stop proxy"),
stopProxyHint: trayLocaleString(t.language, "tray", "stopProxyHint", "Stop local proxy"),
quit: trayLocaleString(t.language, "tray", "quit", "Quit"),
quitHint: trayLocaleString(t.language, "tray", "quitHint", "Exit application"),
}
}
func trayLocaleString(language string, key string, field string, fallback string) string {
value, ok, err := frontendassets.LocaleValue(language, key, field)
if err == nil && ok && strings.TrimSpace(value) != "" {
return value
}
return fallback
}
type trayLabels struct {
appTitle string
appTooltip string
proxyChecking string
proxyStatusHint string
proxyUnavailable string
proxyRunning string
proxyStopped string
openWindow string
openWindowHint string
hideWindow string
hideWindowHint string
startProxy string
startProxyHint string
stopProxy string
stopProxyHint string
quit string
quitHint string
}