From bf088ea19c4137dd5347a82d1de07b72c84045f3 Mon Sep 17 00:00:00 2001 From: Lea Anthony Date: Fri, 30 Sep 2022 08:09:37 +1000 Subject: [PATCH] Initial Windows systray support --- v2/internal/frontend/options/frontend.go | 9 + v2/internal/platform/systray.go | 25 ++ v2/internal/platform/systray/menu.go | 53 ++++ v2/internal/platform/systray/windows.go | 352 +++++++++++++++++++++++ v2/internal/platform/win32/consts.go | 155 ++++++++++ v2/internal/platform/win32/cursor.go | 9 + v2/internal/platform/win32/icon.go | 39 +++ v2/internal/platform/win32/menu.go | 29 ++ v2/internal/platform/win32/structs.go | 54 ++++ v2/internal/platform/win32/theme.go | 19 ++ v2/internal/platform/win32/window.go | 132 +++++++++ v2/internal/platform/win32/winres.go | 7 + v2/pkg/application/application.go | 39 ++- v2/pkg/application/events.go | 9 + v2/pkg/application/systray.go | 66 +++++ v2/pkg/menu/menuitem.go | 4 + v2/pkg/options/options.go | 9 + 17 files changed, 1009 insertions(+), 1 deletion(-) create mode 100644 v2/internal/frontend/options/frontend.go create mode 100644 v2/internal/platform/systray.go create mode 100644 v2/internal/platform/systray/menu.go create mode 100644 v2/internal/platform/systray/windows.go create mode 100644 v2/internal/platform/win32/consts.go create mode 100644 v2/internal/platform/win32/cursor.go create mode 100644 v2/internal/platform/win32/icon.go create mode 100644 v2/internal/platform/win32/menu.go create mode 100644 v2/internal/platform/win32/structs.go create mode 100644 v2/internal/platform/win32/theme.go create mode 100644 v2/internal/platform/win32/window.go create mode 100644 v2/internal/platform/win32/winres.go create mode 100644 v2/pkg/application/events.go create mode 100644 v2/pkg/application/systray.go diff --git a/v2/internal/frontend/options/frontend.go b/v2/internal/frontend/options/frontend.go new file mode 100644 index 000000000..aaab4459a --- /dev/null +++ b/v2/internal/frontend/options/frontend.go @@ -0,0 +1,9 @@ +package options + +import "github.com/wailsapp/wails/v2/pkg/options" + +// Frontend contains options for creating the Frontend +type Frontend struct { + options.App + HasMainWindow bool +} diff --git a/v2/internal/platform/systray.go b/v2/internal/platform/systray.go new file mode 100644 index 000000000..317ea7a7a --- /dev/null +++ b/v2/internal/platform/systray.go @@ -0,0 +1,25 @@ +package platform + +import ( + "github.com/wailsapp/wails/v2/internal/platform/systray" + "github.com/wailsapp/wails/v2/pkg/menu" +) +import "github.com/samber/lo" + +type SysTray interface { + // SetTitle sets the title of the tray menu + SetTitle(title string) + SetIcons(lightModeIcon []byte, darkModeIcon []byte) error + SetTooltip(tooltip string) error + Show() error + Hide() error + Run() error + Close() + AppendMenu(label string, callback menu.Callback) + AppendMenuItem(item *menu.MenuItem) + AppendSeparator() +} + +func NewSysTray() SysTray { + return lo.Must(systray.New()) +} diff --git a/v2/internal/platform/systray/menu.go b/v2/internal/platform/systray/menu.go new file mode 100644 index 000000000..5bfa00da0 --- /dev/null +++ b/v2/internal/platform/systray/menu.go @@ -0,0 +1,53 @@ +package systray + +import ( + "errors" + "github.com/wailsapp/wails/v2/internal/platform/win32" + "github.com/wailsapp/wails/v2/pkg/menu" +) + +func displayMenu(hwnd win32.HWND, menuItems []*menu.MenuItem) error { + popupMenu := win32.CreatePopupMenu() + + for index, item := range menuItems { + var ret bool + itemID := win32.MenuItemMsgID + index + flags := win32.MF_STRING + if item.Disabled { + flags = flags | win32.MF_GRAYED + } + if item.Checked { + flags = flags | win32.MF_CHECKED + } + //if item.BarBreak { + // flags = flags | win32.MF_MENUBARBREAK + //} + if item.IsSeparator() { + flags = flags | win32.MF_SEPARATOR + } + + ret = win32.AppendMenu(popupMenu, uintptr(flags), uintptr(itemID), item.Label) + if ret == false { + return errors.New("AppendMenu failed") + } + } + + x, y, ok := win32.GetCursorPos() + if ok == false { + return errors.New("GetCursorPos failed") + } + + if win32.SetForegroundWindow(hwnd) == false { + return errors.New("SetForegroundWindow failed") + } + + if win32.TrackPopupMenu(popupMenu, win32.TPM_LEFTALIGN, x, y-5, hwnd) == false { + return errors.New("TrackPopupMenu failed") + } + + if win32.PostMessage(hwnd, win32.WM_NULL, 0, 0) == 0 { + return errors.New("PostMessage failed") + } + + return nil +} diff --git a/v2/internal/platform/systray/windows.go b/v2/internal/platform/systray/windows.go new file mode 100644 index 000000000..fde7f3e3f --- /dev/null +++ b/v2/internal/platform/systray/windows.go @@ -0,0 +1,352 @@ +/* + * Based on code originally from https://github.com/tadvi/systray. Copyright (C) 2019 The Systray Authors. All Rights Reserved. + */ + +package systray + +import ( + "errors" + "github.com/wailsapp/wails/v2/internal/platform/win32" + "github.com/wailsapp/wails/v2/pkg/menu" + "golang.org/x/sys/windows" + "syscall" + "unsafe" +) + +var ( + user32 = syscall.MustLoadDLL("user32.dll") + + DefWindowProc = user32.MustFindProc("DefWindowProcW") + RegisterClassEx = user32.MustFindProc("RegisterClassExW") + CreateWindowEx = user32.MustFindProc("CreateWindowExW") +) + +type Systray struct { + id uint32 + hwnd win32.HWND + hinst win32.HINSTANCE + lclick func() + rclick func() + lightModeIcon win32.HICON + darkModeIcon win32.HICON + + Menu []*menu.MenuItem + + quit chan struct{} +} + +func (p *Systray) Close() { + err := p.Stop() + if err != nil { + println(err.Error()) + } +} + +// SetTitle is unused on Windows +func (p *Systray) SetTitle(_ string) {} + +func New() (*Systray, error) { + ni := &Systray{lclick: func() {}, rclick: func() {}} + + MainClassName := "WailsSystray" + ni.hinst, _ = RegisterWindow(MainClassName, ni.WinProc) + + mhwnd := win32.CreateWindowEx( + win32.WS_EX_CONTROLPARENT, + win32.MustStringToUTF16Ptr(MainClassName), + win32.MustStringToUTF16Ptr(""), + win32.WS_OVERLAPPEDWINDOW|win32.WS_CLIPSIBLINGS, + win32.CW_USEDEFAULT, + win32.CW_USEDEFAULT, + win32.CW_USEDEFAULT, + win32.CW_USEDEFAULT, + 0, + 0, + 0, + unsafe.Pointer(nil)) + + if mhwnd == 0 { + return nil, errors.New("create main win failed") + } + + NotifyIconClassName := "NotifyIconForm" + _, err := RegisterWindow(NotifyIconClassName, ni.WinProc) + if err != nil { + return nil, err + } + + hwnd, _, _ := CreateWindowEx.Call( + 0, + uintptr(unsafe.Pointer(win32.MustStringToUTF16Ptr(NotifyIconClassName))), + 0, + 0, + 0, + 0, + 0, + 0, + uintptr(win32.HWND_MESSAGE), + 0, + 0, + 0) + if hwnd == 0 { + return nil, errors.New("create notify win failed") + } + + ni.hwnd = win32.HWND(hwnd) // Important to keep this inside struct. + + nid := win32.NOTIFYICONDATA{ + HWnd: win32.HWND(hwnd), + UFlags: win32.NIF_MESSAGE | win32.NIF_STATE, + DwState: win32.NIS_HIDDEN, + DwStateMask: win32.NIS_HIDDEN, + UCallbackMessage: win32.NotifyIconMessageId, + } + nid.CbSize = uint32(unsafe.Sizeof(nid)) + + ret := win32.ShellNotifyIcon(win32.NIM_ADD, &nid) + if ret == 0 { + return nil, errors.New("shell notify create failed") + } + + nid.UVersion = win32.NOTIFYICON_VERSION + + ret = win32.ShellNotifyIcon(win32.NIM_SETVERSION, &nid) + if ret == 0 { + return nil, errors.New("shell notify version failed") + } + + ni.id = nid.UID + return ni, nil +} + +func (p *Systray) HWND() win32.HWND { + return p.hwnd +} + +// AppendMenu add menu item. +func (p *Systray) AppendMenu(label string, onclick menu.Callback) { + p.Menu = append(p.Menu, &menu.MenuItem{Type: menu.TextType, Label: label, Click: onclick}) +} + +// AppendMenuItem add menu item. +func (p *Systray) AppendMenuItem(item *menu.MenuItem) { + p.Menu = append(p.Menu, item) +} + +// AppendSeparator to the menu. +func (p *Systray) AppendSeparator() { + p.Menu = append(p.Menu, menu.Separator()) +} + +func (p *Systray) Stop() error { + nid := p.newNotifyIconData() + ret := win32.ShellNotifyIcon(win32.NIM_DELETE, &nid) + if ret == 0 { + return errors.New("shell notify delete failed") + } + return nil +} + +func (p *Systray) Click(fn func()) { + p.lclick = fn +} + +func (p *Systray) OnRightClick(fn func()) { + p.rclick = fn +} + +func (p *Systray) SetTooltip(tooltip string) error { + nid := p.newNotifyIconData() + nid.UFlags = win32.NIF_TIP + copy(nid.SzTip[:], win32.MustUTF16FromString(tooltip)) + + ret := win32.ShellNotifyIcon(win32.NIM_MODIFY, &nid) + if ret == 0 { + return errors.New("shell notify tooltip failed") + } + return nil +} + +func (p *Systray) ShowMessage(title, msg string, bigIcon bool) error { + nid := p.newNotifyIconData() + if bigIcon == true { + nid.DwInfoFlags = win32.NIIF_USER + } + + nid.CbSize = uint32(unsafe.Sizeof(nid)) + + nid.UFlags = win32.NIF_INFO + copy(nid.SzInfoTitle[:], win32.MustUTF16FromString(title)) + copy(nid.SzInfo[:], win32.MustUTF16FromString(msg)) + + ret := win32.ShellNotifyIcon(win32.NIM_MODIFY, &nid) + if ret == 0 { + return errors.New("shell notify tooltip failed") + } + return nil +} + +func (p *Systray) newNotifyIconData() win32.NOTIFYICONDATA { + nid := win32.NOTIFYICONDATA{ + UID: p.id, + HWnd: p.hwnd, + } + nid.CbSize = uint32(unsafe.Sizeof(nid)) + return nid +} + +func (p *Systray) Show() error { + return p.setVisible(true) +} + +func (p *Systray) Hide() error { + return p.setVisible(false) +} + +func (p *Systray) setVisible(visible bool) error { + nid := p.newNotifyIconData() + nid.UFlags = win32.NIF_STATE + nid.DwStateMask = win32.NIS_HIDDEN + if !visible { + nid.DwState = win32.NIS_HIDDEN + } + + ret := win32.ShellNotifyIcon(win32.NIM_MODIFY, &nid) + if ret == 0 { + return errors.New("shell notify tooltip failed") + } + return nil +} + +func (p *Systray) SetIcons(lightModeIcon []byte, darkModeIcon []byte) error { + p.lightModeIcon = p.getIcon(lightModeIcon) + p.darkModeIcon = p.getIcon(darkModeIcon) + return p.updateIcon() +} + +func (p *Systray) getIcon(icon []byte) win32.HICON { + result, err := win32.CreateHIconFromPNG(icon) + if err != nil { + result = win32.LoadIconWithResourceID(0, uintptr(win32.IDI_APPLICATION)) + } + return result +} + +func (p *Systray) setIcon(hicon win32.HICON) error { + nid := p.newNotifyIconData() + nid.UFlags = win32.NIF_ICON + if hicon == 0 { + nid.HIcon = 0 + } else { + nid.HIcon = hicon + } + + ret := win32.ShellNotifyIcon(win32.NIM_MODIFY, &nid) + if ret == 0 { + return errors.New("shell notify icon failed") + } + return nil +} + +func (p *Systray) WinProc(hwnd win32.HWND, msg uint32, wparam, lparam uintptr) uintptr { + switch msg { + case win32.NotifyIconMessageId: + if lparam == win32.WM_LBUTTONUP { + p.lclick() + if len(p.Menu) > 0 { + err := displayMenu(p.hwnd, p.Menu) + if err != nil { + return 0 + } + } + } else if lparam == win32.WM_RBUTTONUP { + p.rclick() + if len(p.Menu) > 0 { + err := displayMenu(p.hwnd, p.Menu) + if err != nil { + return 0 + } + } + } + case win32.WM_SETTINGCHANGE: + settingChanged := windows.UTF16PtrToString((*uint16)(unsafe.Pointer(lparam))) + if settingChanged == "ImmersiveColorSet" { + err := p.updateIcon() + if err != nil { + println("update icon failed", err.Error()) + } + } + return 0 + case win32.WM_COMMAND: + cmdMsgID := int(wparam & 0xffff) + switch cmdMsgID { + default: + if cmdMsgID >= win32.MenuItemMsgID && cmdMsgID < (win32.MenuItemMsgID+len(p.Menu)) { + itemIndex := cmdMsgID - win32.MenuItemMsgID + menuItem := p.Menu[itemIndex] + menuItem.Click(nil) + } + } + } + + result, _, _ := DefWindowProc.Call(uintptr(hwnd), uintptr(msg), wparam, lparam) + return result +} + +func (p *Systray) Run() error { + var msg win32.MSG + for { + rt := win32.GetMessage(&msg) + switch int(rt) { + case 0: + println("Quitting Run()") + return nil + case -1: + return errors.New("run failed") + } + + if win32.IsDialogMessage(p.hwnd, &msg) == 0 { + win32.TranslateMessage(&msg) + win32.DispatchMessage(&msg) + } + } +} + +func (p *Systray) updateIcon() error { + if win32.IsCurrentlyDarkMode() { + return p.setIcon(p.darkModeIcon) + } + return p.setIcon(p.lightModeIcon) +} + +func RegisterWindow(name string, proc win32.WindowProc) (win32.HINSTANCE, error) { + hinst := win32.GetModuleHandle(0) + if hinst == 0 { + return 0, errors.New("get module handle failed") + } + hicon := win32.LoadIconWithResourceID(0, uintptr(win32.IDI_APPLICATION)) + if hicon == 0 { + return 0, errors.New("load icon failed") + } + hcursor := win32.LoadCursorWithResourceID(0, uintptr(win32.IDC_ARROW)) + if hcursor == 0 { + return 0, errors.New("load cursor failed") + } + + hi := win32.HINSTANCE(hinst) + + var wc win32.WNDCLASSEX + wc.CbSize = uint32(unsafe.Sizeof(wc)) + wc.LpfnWndProc = syscall.NewCallback(proc) + wc.HInstance = win32.HINSTANCE(hinst) + wc.HIcon = hicon + wc.HCursor = hcursor + wc.HbrBackground = win32.COLOR_BTNFACE + 1 + wc.LpszClassName = win32.MustStringToUTF16Ptr(name) + + atom, _, _ := RegisterClassEx.Call(uintptr(unsafe.Pointer(&wc))) + if atom == 0 { + return 0, errors.New("register class failed") + } + return hi, nil +} diff --git a/v2/internal/platform/win32/consts.go b/v2/internal/platform/win32/consts.go new file mode 100644 index 000000000..dca347d98 --- /dev/null +++ b/v2/internal/platform/win32/consts.go @@ -0,0 +1,155 @@ +package win32 + +import ( + "syscall" + "unsafe" +) + +var ( + modKernel32 = syscall.NewLazyDLL("kernel32.dll") + procGetModuleHandle = modKernel32.NewProc("GetModuleHandleW") + + moduser32 = syscall.NewLazyDLL("user32.dll") + procRegisterClassEx = moduser32.NewProc("RegisterClassExW") + procLoadIcon = moduser32.NewProc("LoadIconW") + procLoadCursor = moduser32.NewProc("LoadCursorW") + procCreateWindowEx = moduser32.NewProc("CreateWindowExW") + procPostMessage = moduser32.NewProc("PostMessageW") + procGetCursorPos = moduser32.NewProc("GetCursorPos") + procSetForegroundWindow = moduser32.NewProc("SetForegroundWindow") + procCreatePopupMenu = moduser32.NewProc("CreatePopupMenu") + procTrackPopupMenu = moduser32.NewProc("TrackPopupMenu") + procAppendMenuW = moduser32.NewProc("AppendMenuW") + procCreateIconFromResourceEx = moduser32.NewProc("CreateIconFromResourceEx") + procGetMessageW = moduser32.NewProc("GetMessageW") + procIsDialogMessage = moduser32.NewProc("IsDialogMessageW") + procTranslateMessage = moduser32.NewProc("TranslateMessage") + procDispatchMessage = moduser32.NewProc("DispatchMessageW") + + modshell32 = syscall.NewLazyDLL("shell32.dll") + procShellNotifyIcon = modshell32.NewProc("Shell_NotifyIconW") +) + +type HANDLE uintptr +type HINSTANCE HANDLE +type HICON HANDLE +type HCURSOR HANDLE +type HBRUSH HANDLE +type HWND HANDLE +type HMENU HANDLE +type ATOM uint16 + +const ( + WM_LBUTTONUP = 0x0202 + WM_LBUTTONDBLCLK = 0x0203 + WM_RBUTTONUP = 0x0205 + WM_USER = 0x0400 + WM_TRAYICON = WM_USER + 69 + WM_SETTINGCHANGE = 0x001A + + WS_EX_APPWINDOW = 0x00040000 + WS_OVERLAPPEDWINDOW = 0x00000000 | 0x00C00000 | 0x00080000 | 0x00040000 | 0x00020000 | 0x00010000 + CW_USEDEFAULT = 0x80000000 + + NIM_ADD = 0x00000000 + NIM_MODIFY = 0x00000001 + NIM_DELETE = 0x00000002 + NIM_SETVERSION = 0x00000004 + + NIF_MESSAGE = 0x00000001 + NIF_ICON = 0x00000002 + NIF_TIP = 0x00000004 + NIF_STATE = 0x00000008 + NIF_INFO = 0x00000010 + + NIS_HIDDEN = 0x00000001 + + NIIF_NONE = 0x00000000 + NIIF_INFO = 0x00000001 + NIIF_WARNING = 0x00000002 + NIIF_ERROR = 0x00000003 + NIIF_USER = 0x00000004 + NIIF_NOSOUND = 0x00000010 + NIIF_LARGE_ICON = 0x00000020 + NIIF_RESPECT_QUIET_TIME = 0x00000080 + NIIF_ICON_MASK = 0x0000000F + + IMAGE_BITMAP = 0 + IMAGE_ICON = 1 + LR_LOADFROMFILE = 0x00000010 + LR_DEFAULTSIZE = 0x00000040 + + IDC_ARROW = 32512 + COLOR_WINDOW = 5 + COLOR_BTNFACE = 15 + + GWLP_USERDATA = -21 + WS_CLIPSIBLINGS = 0x04000000 + WS_EX_CONTROLPARENT = 0x00010000 + + HWND_MESSAGE = ^HWND(2) + NOTIFYICON_VERSION = 4 + + IDI_APPLICATION = 32512 + WM_APP = 32768 + WM_COMMAND = 273 + + MenuItemMsgID = WM_APP + 1024 + NotifyIconMessageId = WM_APP + iota + + MF_STRING = 0x00000000 + MF_ENABLED = 0x00000000 + MF_GRAYED = 0x00000001 + MF_DISABLED = 0x00000002 + MF_SEPARATOR = 0x00000800 + MF_CHECKED = 0x00000008 + MF_MENUBARBREAK = 0x00000020 + + TPM_LEFTALIGN = 0x0000 + WM_NULL = 0 + + CS_VREDRAW = 0x0001 + CS_HREDRAW = 0x0002 +) + +type WindowProc func(hwnd HWND, msg uint32, wparam, lparam uintptr) uintptr + +func GetModuleHandle(value uintptr) uintptr { + result, _, _ := procGetModuleHandle.Call(value) + return result +} + +func GetMessage(msg *MSG) uintptr { + rt, _, _ := procGetMessageW.Call(uintptr(unsafe.Pointer(&msg)), 0, 0, 0) + return rt +} + +func PostMessage(hwnd HWND, msg uint32, wParam, lParam uintptr) uintptr { + ret, _, _ := procPostMessage.Call( + uintptr(hwnd), + uintptr(msg), + wParam, + lParam) + + return ret +} + +func ShellNotifyIcon(cmd uintptr, nid *NOTIFYICONDATA) uintptr { + ret, _, _ := procShellNotifyIcon.Call(cmd, uintptr(unsafe.Pointer(nid))) + return ret +} + +func IsDialogMessage(hwnd HWND, msg *MSG) uintptr { + ret, _, _ := procIsDialogMessage.Call(uintptr(hwnd), uintptr(unsafe.Pointer(msg))) + return ret +} + +func TranslateMessage(msg *MSG) uintptr { + ret, _, _ := procTranslateMessage.Call(uintptr(unsafe.Pointer(msg))) + return ret +} + +func DispatchMessage(msg *MSG) uintptr { + ret, _, _ := procDispatchMessage.Call(uintptr(unsafe.Pointer(msg))) + return ret +} diff --git a/v2/internal/platform/win32/cursor.go b/v2/internal/platform/win32/cursor.go new file mode 100644 index 000000000..a9a32e733 --- /dev/null +++ b/v2/internal/platform/win32/cursor.go @@ -0,0 +1,9 @@ +package win32 + +import "unsafe" + +func GetCursorPos() (x, y int, ok bool) { + pt := POINT{} + ret, _, _ := procGetCursorPos.Call(uintptr(unsafe.Pointer(&pt))) + return int(pt.X), int(pt.Y), ret != 0 +} diff --git a/v2/internal/platform/win32/icon.go b/v2/internal/platform/win32/icon.go new file mode 100644 index 000000000..43eb9a4a1 --- /dev/null +++ b/v2/internal/platform/win32/icon.go @@ -0,0 +1,39 @@ +package win32 + +import ( + "unsafe" +) + +func CreateIconFromResourceEx(presbits uintptr, dwResSize uint32, isIcon bool, version uint32, cxDesired int, cyDesired int, flags uint) (uintptr, error) { + icon := 0 + if isIcon { + icon = 1 + } + r, _, err := procCreateIconFromResourceEx.Call( + presbits, + uintptr(dwResSize), + uintptr(icon), + uintptr(version), + uintptr(cxDesired), + uintptr(cyDesired), + uintptr(flags), + ) + + if r == 0 { + return 0, err + } + return r, nil +} + +// CreateHIconFromPNG creates a HICON from a PNG file +func CreateHIconFromPNG(pngData []byte) (HICON, error) { + icon, err := CreateIconFromResourceEx( + uintptr(unsafe.Pointer(&pngData[0])), + uint32(len(pngData)), + true, + 0x00030000, + 0, + 0, + LR_DEFAULTSIZE) + return HICON(icon), err +} diff --git a/v2/internal/platform/win32/menu.go b/v2/internal/platform/win32/menu.go new file mode 100644 index 000000000..332e859e7 --- /dev/null +++ b/v2/internal/platform/win32/menu.go @@ -0,0 +1,29 @@ +package win32 + +func CreatePopupMenu() HMENU { + ret, _, _ := procCreatePopupMenu.Call(0, 0, 0, 0) + return HMENU(ret) +} + +func TrackPopupMenu(menu HMENU, flags uint, x, y int, wnd HWND) bool { + ret, _, _ := procTrackPopupMenu.Call( + uintptr(menu), + uintptr(flags), + uintptr(x), + uintptr(y), + 0, + uintptr(wnd), + 0, + ) + return ret != 0 +} + +func AppendMenu(menu HMENU, flags uintptr, id uintptr, text string) bool { + ret, _, _ := procAppendMenuW.Call( + uintptr(menu), + flags, + id, + MustStringToUTF16uintptr(text), + ) + return ret != 0 +} diff --git a/v2/internal/platform/win32/structs.go b/v2/internal/platform/win32/structs.go new file mode 100644 index 000000000..a0772d004 --- /dev/null +++ b/v2/internal/platform/win32/structs.go @@ -0,0 +1,54 @@ +package win32 + +type NOTIFYICONDATA struct { + CbSize uint32 + HWnd HWND + UID uint32 + UFlags uint32 + UCallbackMessage uint32 + HIcon HICON + SzTip [128]uint16 + DwState uint32 + DwStateMask uint32 + SzInfo [256]uint16 + UVersion uint32 + SzInfoTitle [64]uint16 + DwInfoFlags uint32 + GuidItem GUID + HBalloonIcon HICON +} + +type GUID struct { + Data1 uint32 + Data2 uint16 + Data3 uint16 + Data4 [8]byte +} + +type WNDCLASSEX struct { + CbSize uint32 + Style uint32 + LpfnWndProc uintptr + CbClsExtra int32 + CbWndExtra int32 + HInstance HINSTANCE + HIcon HICON + HCursor HCURSOR + HbrBackground HBRUSH + LpszMenuName *uint16 + LpszClassName *uint16 + HIconSm HICON +} + +type MSG struct { + HWnd HWND + Message uint32 + WParam uintptr + LParam uintptr + Time uint32 + Pt POINT +} + +type POINT struct { + X, Y int32 +} diff --git a/v2/internal/platform/win32/theme.go b/v2/internal/platform/win32/theme.go new file mode 100644 index 000000000..d5a23eca4 --- /dev/null +++ b/v2/internal/platform/win32/theme.go @@ -0,0 +1,19 @@ +package win32 + +import ( + "golang.org/x/sys/windows/registry" +) + +func IsCurrentlyDarkMode() bool { + key, err := registry.OpenKey(registry.CURRENT_USER, `SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize`, registry.QUERY_VALUE) + if err != nil { + return false + } + defer key.Close() + + AppsUseLightTheme, _, err := key.GetIntegerValue("AppsUseLightTheme") + if err != nil { + return false + } + return AppsUseLightTheme == 0 +} diff --git a/v2/internal/platform/win32/window.go b/v2/internal/platform/win32/window.go new file mode 100644 index 000000000..62546ff23 --- /dev/null +++ b/v2/internal/platform/win32/window.go @@ -0,0 +1,132 @@ +package win32 + +import ( + "fmt" + "github.com/samber/lo" + "syscall" + "unsafe" +) + +func LoadIconWithResourceID(instance HINSTANCE, res uintptr) HICON { + ret, _, _ := procLoadIcon.Call( + uintptr(instance), + res) + + return HICON(ret) +} + +func LoadCursorWithResourceID(instance HINSTANCE, res uintptr) HCURSOR { + ret, _, _ := procLoadCursor.Call( + uintptr(instance), + res) + + return HCURSOR(ret) +} + +func RegisterClassEx(wndClassEx *WNDCLASSEX) ATOM { + ret, _, _ := procRegisterClassEx.Call(uintptr(unsafe.Pointer(wndClassEx))) + return ATOM(ret) +} + +func RegisterClass(className string, wndproc uintptr, instance HINSTANCE) error { + classNamePtr, err := syscall.UTF16PtrFromString(className) + if err != nil { + return err + } + icon := LoadIconWithResourceID(instance, IDI_APPLICATION) + + var wc WNDCLASSEX + wc.CbSize = uint32(unsafe.Sizeof(wc)) + wc.Style = CS_HREDRAW | CS_VREDRAW + wc.LpfnWndProc = wndproc + wc.HInstance = instance + wc.HbrBackground = COLOR_WINDOW + 1 + wc.HIcon = icon + wc.HCursor = LoadCursorWithResourceID(0, IDC_ARROW) + wc.LpszClassName = classNamePtr + wc.LpszMenuName = nil + wc.HIconSm = icon + + if ret := RegisterClassEx(&wc); ret == 0 { + return syscall.GetLastError() + } + + return nil +} + +func CreateWindow(className string, instance HINSTANCE, parent HWND, exStyle, style uint) HWND { + + classNamePtr := lo.Must(syscall.UTF16PtrFromString(className)) + + result := CreateWindowEx( + exStyle, + classNamePtr, + nil, + style, + CW_USEDEFAULT, + CW_USEDEFAULT, + CW_USEDEFAULT, + CW_USEDEFAULT, + parent, + 0, + instance, + nil) + + if result == 0 { + errStr := fmt.Sprintf("Error occurred in CreateWindow(%s, %v, %d, %d)", className, parent, exStyle, style) + panic(errStr) + } + + return result +} + +func CreateWindowEx(exStyle uint, className, windowName *uint16, + style uint, x, y, width, height int, parent HWND, menu HMENU, + instance HINSTANCE, param unsafe.Pointer) HWND { + ret, _, _ := procCreateWindowEx.Call( + uintptr(exStyle), + uintptr(unsafe.Pointer(className)), + uintptr(unsafe.Pointer(windowName)), + uintptr(style), + uintptr(x), + uintptr(y), + uintptr(width), + uintptr(height), + uintptr(parent), + uintptr(menu), + uintptr(instance), + uintptr(param)) + + return HWND(ret) +} + +func MustStringToUTF16Ptr(input string) *uint16 { + ret, err := syscall.UTF16PtrFromString(input) + if err != nil { + panic(err) + } + return ret +} + +func MustStringToUTF16uintptr(input string) uintptr { + ret, err := syscall.UTF16PtrFromString(input) + if err != nil { + panic(err) + } + return uintptr(unsafe.Pointer(ret)) +} + +func MustUTF16FromString(input string) []uint16 { + ret, err := syscall.UTF16FromString(input) + if err != nil { + panic(err) + } + return ret +} + +func SetForegroundWindow(wnd HWND) bool { + ret, _, _ := procSetForegroundWindow.Call( + uintptr(wnd), + ) + return ret != 0 +} diff --git a/v2/internal/platform/win32/winres.go b/v2/internal/platform/win32/winres.go new file mode 100644 index 000000000..3a51068a9 --- /dev/null +++ b/v2/internal/platform/win32/winres.go @@ -0,0 +1,7 @@ +package win32 + +import "unsafe" + +func MakeIntResource(id uint16) *uint16 { + return (*uint16)(unsafe.Pointer(uintptr(id))) +} diff --git a/v2/pkg/application/application.go b/v2/pkg/application/application.go index 205ef6bfb..2694ce09e 100644 --- a/v2/pkg/application/application.go +++ b/v2/pkg/application/application.go @@ -1,6 +1,7 @@ package application import ( + "context" "github.com/wailsapp/wails/v2/internal/app" "github.com/wailsapp/wails/v2/internal/signal" "github.com/wailsapp/wails/v2/pkg/menu" @@ -12,6 +13,9 @@ type Application struct { application *app.App options *options.App + // System Trays + systemTrays []*SystemTray + // running flag running bool } @@ -66,10 +70,43 @@ func (a *Application) Run() error { a.running = true - return a.application.Run() + err = a.application.Run() + a.Quit() + return err } // Quit will shut down the application func (a *Application) Quit() { + for _, systray := range a.systemTrays { + systray.Close() + } a.application.Shutdown() } + +// Bind the given struct to the application +func (a *Application) Bind(boundStruct any) { + a.options.Bind = append(a.options.Bind, boundStruct) +} + +func (a *Application) On(eventType EventType, callback func()) { + + c := func(ctx context.Context) { + callback() + } + + switch eventType { + case StartUp: + a.options.OnStartup = c + case ShutDown: + a.options.OnShutdown = c + case DomReady: + a.options.OnDomReady = c + } +} + +func (a *Application) NewSystemTray(options *options.SystemTray) *SystemTray { + systemTray := newSystemTray(options) + a.systemTrays = append(a.systemTrays, systemTray) + println("created systemTray") + return systemTray +} diff --git a/v2/pkg/application/events.go b/v2/pkg/application/events.go new file mode 100644 index 000000000..3896e9e75 --- /dev/null +++ b/v2/pkg/application/events.go @@ -0,0 +1,9 @@ +package application + +type EventType int + +const ( + StartUp EventType = iota + ShutDown + DomReady +) diff --git a/v2/pkg/application/systray.go b/v2/pkg/application/systray.go new file mode 100644 index 000000000..74535b676 --- /dev/null +++ b/v2/pkg/application/systray.go @@ -0,0 +1,66 @@ +package application + +import ( + "github.com/wailsapp/wails/v2/internal/platform" + "github.com/wailsapp/wails/v2/pkg/menu" + "github.com/wailsapp/wails/v2/pkg/options" +) + +// SystemTray defines a system tray! +type SystemTray struct { + title string + hidden bool + lightModeIcon []byte + darkModeIcon []byte + tooltip string + startHidden bool + + // The platform specific implementation + impl platform.SysTray +} + +func newSystemTray(options *options.SystemTray) *SystemTray { + return &SystemTray{ + impl: platform.NewSysTray(), + title: options.Title, + lightModeIcon: options.LightModeIcon, + darkModeIcon: options.DarkModeIcon, + tooltip: options.Tooltip, + startHidden: options.StartHidden, + } +} + +func (t *SystemTray) run() { + t.impl.SetTitle(t.title) + t.impl.SetIcons(t.lightModeIcon, t.darkModeIcon) + t.impl.SetTooltip(t.tooltip) + if !t.startHidden { + t.impl.Show() + } + t.impl.Run() +} + +func (t *SystemTray) SetTitle(title string) { + t.title = title + t.impl.SetTitle(title) +} +func (t *SystemTray) AppendMenu(label string, callback menu.Callback) { + t.impl.AppendMenu(label, callback) +} + +func (t *SystemTray) Run() error { + t.run() + return nil +} + +func (t *SystemTray) Close() { + t.impl.Close() +} + +func (t *SystemTray) AppendSeperator() { + t.impl.AppendSeparator() +} + +func (t *SystemTray) AppendMenuItem(item *menu.MenuItem) { + t.impl.AppendMenuItem(item) +} diff --git a/v2/pkg/menu/menuitem.go b/v2/pkg/menu/menuitem.go index f5b34dff2..ac5ebd7be 100644 --- a/v2/pkg/menu/menuitem.go +++ b/v2/pkg/menu/menuitem.go @@ -239,6 +239,10 @@ func (m *MenuItem) SetLabel(name string) { m.Impl.SetLabel(name) } +func (m *MenuItem) IsSeparator() bool { + return m.Type == SeparatorType +} + // Text is a helper to create basic Text menu items func Text(label string, accelerator *keys.Accelerator, click Callback) *MenuItem { return &MenuItem{ diff --git a/v2/pkg/options/options.go b/v2/pkg/options/options.go index 25f619b6b..98d36d2c8 100644 --- a/v2/pkg/options/options.go +++ b/v2/pkg/options/options.go @@ -170,3 +170,12 @@ func processDragOptions(appoptions *App) { appoptions.CSSDragProperty = html.EscapeString(appoptions.CSSDragProperty) appoptions.CSSDragValue = html.EscapeString(appoptions.CSSDragValue) } + +// SystemTray contains options for the system tray +type SystemTray struct { + LightModeIcon []byte + DarkModeIcon []byte + Title string + Tooltip string + StartHidden bool +}