Files
2026-05-13 20:40:14 +08:00

406 lines
8.5 KiB
Go

//go:build windows
package tray
import (
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"sync"
"sync/atomic"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
const (
wmClose = 0x0010
wmDestroy = 0x0002
wmApp = 0x8000
trayCallbackMessage = wmApp + 1
hwndMessage = ^uintptr(2)
nimAdd = 0x00000000
nimDelete = 0x00000002
nimSetVersion = 0x00000004
nifMessage = 0x00000001
nifIcon = 0x00000002
nifTip = 0x00000004
notifyIconVersion = 4
imageIcon = 1
lrLoadFromFile = 0x00000010
idiApplication = 32512
smCxSmallIcon = 49
smCySmallIcon = 50
)
var (
kernel32 = windows.NewLazySystemDLL("kernel32.dll")
user32 = windows.NewLazySystemDLL("user32.dll")
shell32 = windows.NewLazySystemDLL("shell32.dll")
procGetModuleHandle = kernel32.NewProc("GetModuleHandleW")
procRegisterClassEx = user32.NewProc("RegisterClassExW")
procCreateWindowEx = user32.NewProc("CreateWindowExW")
procDefWindowProc = user32.NewProc("DefWindowProcW")
procDestroyWindow = user32.NewProc("DestroyWindow")
procPostMessage = user32.NewProc("PostMessageW")
procGetMessage = user32.NewProc("GetMessageW")
procTranslateMsg = user32.NewProc("TranslateMessage")
procDispatchMsg = user32.NewProc("DispatchMessageW")
procPostQuitMessage = user32.NewProc("PostQuitMessage")
procLoadIcon = user32.NewProc("LoadIconW")
procLoadImage = user32.NewProc("LoadImageW")
procDestroyIcon = user32.NewProc("DestroyIcon")
procGetSystemMetric = user32.NewProc("GetSystemMetrics")
procShellNotifyIcon = shell32.NewProc("Shell_NotifyIconW")
procExtractIconEx = shell32.NewProc("ExtractIconExW")
)
type notifyIconData struct {
cbSize uint32
hwnd uintptr
uid uint32
flags uint32
callbackMessage uint32
icon uintptr
tip [128]uint16
state uint32
stateMask uint32
info [256]uint16
version uint32
infoTitle [64]uint16
infoFlags uint32
guidItem windows.GUID
balloonIcon uintptr
}
type wndClassEx struct {
cbSize uint32
style uint32
wndProc uintptr
clsExtra int32
wndExtra int32
instance uintptr
icon uintptr
cursor uintptr
background uintptr
menuName *uint16
className *uint16
iconSmall uintptr
}
type point struct {
x int32
y int32
}
type msg struct {
hwnd uintptr
message uint32
wparam uintptr
lparam uintptr
time uint32
pt point
}
type windowsTray struct {
controller *Controller
wndProc uintptr
hwnd atomic.Uintptr
icon uintptr
destroyIcon bool
ready chan error
done chan struct{}
stopOnce sync.Once
cleanupOnce sync.Once
}
func start(ctx context.Context, controller *Controller) (func(), error) {
tray := &windowsTray{
controller: controller,
ready: make(chan error, 1),
done: make(chan struct{}),
}
go tray.run()
if err := <-tray.ready; err != nil {
return nil, err
}
stop := func() {
tray.stop()
}
go func() {
<-ctx.Done()
stop()
}()
return stop, nil
}
func (t *windowsTray) run() {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
if err := t.init(); err != nil {
hwnd := t.hwnd.Load()
t.cleanup()
destroyWindow(hwnd)
t.ready <- err
close(t.done)
return
}
t.ready <- nil
t.messageLoop()
t.cleanup()
close(t.done)
}
func (t *windowsTray) init() error {
t.wndProc = windows.NewCallback(t.windowProc)
instance, _, _ := procGetModuleHandle.Call(0)
className, err := windows.UTF16PtrFromString(fmt.Sprintf("SGGAISkillManagerTrayWindow-%d", os.Getpid()))
if err != nil {
return err
}
class := wndClassEx{
cbSize: uint32(unsafe.Sizeof(wndClassEx{})),
wndProc: t.wndProc,
instance: instance,
className: className,
}
if ret, _, callErr := procRegisterClassEx.Call(uintptr(unsafe.Pointer(&class))); ret == 0 {
return syscallError("RegisterClassExW", callErr)
}
hwnd, _, callErr := procCreateWindowEx.Call(
0,
uintptr(unsafe.Pointer(className)),
0,
0,
0,
0,
0,
0,
hwndMessage,
0,
instance,
0,
)
if hwnd == 0 {
return syscallError("CreateWindowExW", callErr)
}
t.hwnd.Store(hwnd)
icon, destroyIcon, err := loadTrayIcon(t.controller.config.IconPath)
if err != nil {
return err
}
t.icon = icon
t.destroyIcon = destroyIcon
nid := t.newNotifyIconData()
if !shellNotifyIcon(nimAdd, &nid) {
return fmt.Errorf("Shell_NotifyIconW add failed")
}
nid.version = notifyIconVersion
if !shellNotifyIcon(nimSetVersion, &nid) {
return fmt.Errorf("Shell_NotifyIconW set version failed")
}
return nil
}
func (t *windowsTray) messageLoop() {
var message msg
for {
ret, _, _ := procGetMessage.Call(uintptr(unsafe.Pointer(&message)), 0, 0, 0)
if int32(ret) <= 0 {
return
}
procTranslateMsg.Call(uintptr(unsafe.Pointer(&message)))
procDispatchMsg.Call(uintptr(unsafe.Pointer(&message)))
}
}
func (t *windowsTray) stop() {
t.stopOnce.Do(func() {
if hwnd := t.hwnd.Load(); hwnd != 0 {
procPostMessage.Call(hwnd, wmClose, 0, 0)
<-t.done
}
})
}
func (t *windowsTray) windowProc(hwnd uintptr, message uint32, wparam uintptr, lparam uintptr) uintptr {
switch message {
case trayCallbackMessage:
if t.controller.handleTrayEvent(lparam) {
return 0
}
case wmClose:
t.cleanup()
procDestroyWindow.Call(hwnd)
return 0
case wmDestroy:
procPostQuitMessage.Call(0)
return 0
}
ret, _, _ := procDefWindowProc.Call(hwnd, uintptr(message), wparam, lparam)
return ret
}
func (t *windowsTray) cleanup() {
t.cleanupOnce.Do(func() {
if hwnd := t.hwnd.Load(); hwnd != 0 {
nid := t.newNotifyIconData()
shellNotifyIcon(nimDelete, &nid)
t.hwnd.Store(0)
}
if t.icon != 0 && t.destroyIcon {
procDestroyIcon.Call(t.icon)
}
})
}
func destroyWindow(hwnd uintptr) {
if hwnd != 0 {
procDestroyWindow.Call(hwnd)
}
}
func (t *windowsTray) newNotifyIconData() notifyIconData {
nid := notifyIconData{
cbSize: uint32(unsafe.Sizeof(notifyIconData{})),
hwnd: t.hwnd.Load(),
uid: 1,
flags: nifMessage | nifIcon | nifTip,
callbackMessage: trayCallbackMessage,
icon: t.icon,
}
copyUTF16(nid.tip[:], t.controller.config.Tooltip)
return nid
}
func loadTrayIcon(iconPath string) (uintptr, bool, error) {
if icon, ok := loadIconFromFile(iconPath); ok {
return icon, true, nil
}
if icon, ok := extractExecutableIcon(); ok {
return icon, true, nil
}
if icon, _, _ := procLoadIcon.Call(0, idiApplication); icon != 0 {
return icon, false, nil
}
return 0, false, fmt.Errorf("load tray icon failed")
}
func loadIconFromFile(iconPath string) (uintptr, bool) {
if iconPath == "" {
return 0, false
}
absolutePath, err := filepath.Abs(iconPath)
if err != nil {
return 0, false
}
if _, err := os.Stat(absolutePath); err != nil {
return 0, false
}
path, err := windows.UTF16PtrFromString(absolutePath)
if err != nil {
return 0, false
}
width := systemMetric(smCxSmallIcon, 16)
height := systemMetric(smCySmallIcon, 16)
icon, _, _ := procLoadImage.Call(
0,
uintptr(unsafe.Pointer(path)),
imageIcon,
uintptr(width),
uintptr(height),
lrLoadFromFile,
)
return icon, icon != 0
}
func extractExecutableIcon() (uintptr, bool) {
exePath, err := os.Executable()
if err != nil {
return 0, false
}
exe, err := windows.UTF16PtrFromString(exePath)
if err != nil {
return 0, false
}
var largeIcon uintptr
var smallIcon uintptr
count, _, _ := procExtractIconEx.Call(
uintptr(unsafe.Pointer(exe)),
0,
uintptr(unsafe.Pointer(&largeIcon)),
uintptr(unsafe.Pointer(&smallIcon)),
1,
)
if count == 0 {
return 0, false
}
if smallIcon != 0 {
if largeIcon != 0 {
procDestroyIcon.Call(largeIcon)
}
return smallIcon, true
}
return largeIcon, largeIcon != 0
}
func systemMetric(index int32, fallback int) int {
value, _, _ := procGetSystemMetric.Call(uintptr(index))
if value == 0 {
return fallback
}
return int(value)
}
func shellNotifyIcon(command uintptr, data *notifyIconData) bool {
ret, _, _ := procShellNotifyIcon.Call(command, uintptr(unsafe.Pointer(data)))
return ret != 0
}
func copyUTF16(destination []uint16, value string) {
encoded, err := windows.UTF16FromString(value)
if err != nil {
return
}
if len(encoded) > len(destination) {
encoded = encoded[:len(destination)]
encoded[len(encoded)-1] = 0
}
copy(destination, encoded)
}
func syscallError(operation string, err error) error {
if errno, ok := err.(syscall.Errno); ok && errno == 0 {
return fmt.Errorf("%s failed", operation)
}
return fmt.Errorf("%s failed: %w", operation, err)
}