443 lines
13 KiB
Go
443 lines
13 KiB
Go
//go:build windows
|
|
|
|
package windowstray
|
|
|
|
// A deliberately small Win32 notification-area host. The tray has no
|
|
// durable state and no command execution path; it only renders a tooltip,
|
|
// sends enum actions over the authenticated service pipe, and opens paths
|
|
// returned by the service with the exact Windows Explorer executable.
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"unicode/utf8"
|
|
"unsafe"
|
|
|
|
winapi "golang.org/x/sys/windows"
|
|
)
|
|
|
|
const (
|
|
wmDestroy = 0x0002
|
|
wmClose = 0x0010
|
|
wmCommand = 0x0111
|
|
wmRButtonUp = 0x0205
|
|
wmLButtonDblClick = 0x0203
|
|
wmApp = 0x8000
|
|
trayMessage = wmApp + 1
|
|
trayIconID = 1
|
|
trayNIMAdd = 0
|
|
trayNIMModify = 1
|
|
trayNIMDelete = 2
|
|
trayNIFMessage = 0x00000001
|
|
trayNIFIcon = 0x00000002
|
|
trayNIFTip = 0x00000004
|
|
trayTPMRightBtn = 0x0002
|
|
trayMFString = 0x00000000
|
|
trayMFSeparator = 0x00000800
|
|
traySWHide = 0
|
|
trayIDIApplication = 32512
|
|
trayIDCArrow = 32512
|
|
)
|
|
|
|
var (
|
|
ErrTrayAlreadyRunning = errors.New("RVBox tray is already running in this session")
|
|
user32Tray = syscall.NewLazyDLL("user32.dll")
|
|
shell32Tray = syscall.NewLazyDLL("shell32.dll")
|
|
procRegisterClassEx = user32Tray.NewProc("RegisterClassExW")
|
|
procCreateWindowEx = user32Tray.NewProc("CreateWindowExW")
|
|
procDefWindowProc = user32Tray.NewProc("DefWindowProcW")
|
|
procGetMessage = user32Tray.NewProc("GetMessageW")
|
|
procTranslate = user32Tray.NewProc("TranslateMessage")
|
|
procDispatch = user32Tray.NewProc("DispatchMessageW")
|
|
procPostMessage = user32Tray.NewProc("PostMessageW")
|
|
procDestroyWindow = user32Tray.NewProc("DestroyWindow")
|
|
procShowWindow = user32Tray.NewProc("ShowWindow")
|
|
procPostQuitMessage = user32Tray.NewProc("PostQuitMessage")
|
|
procLoadIcon = user32Tray.NewProc("LoadIconW")
|
|
procLoadCursor = user32Tray.NewProc("LoadCursorW")
|
|
procCreatePopup = user32Tray.NewProc("CreatePopupMenu")
|
|
procAppendMenu = user32Tray.NewProc("AppendMenuW")
|
|
procTrackPopup = user32Tray.NewProc("TrackPopupMenu")
|
|
procDestroyMenu = user32Tray.NewProc("DestroyMenu")
|
|
procGetCursorPos = user32Tray.NewProc("GetCursorPos")
|
|
procSetForeground = user32Tray.NewProc("SetForegroundWindow")
|
|
procGetModuleHandle = user32Tray.NewProc("GetModuleHandleW")
|
|
procRegisterMessage = user32Tray.NewProc("RegisterWindowMessageW")
|
|
procShellNotify = shell32Tray.NewProc("Shell_NotifyIconW")
|
|
trayCallback = syscall.NewCallback(trayWindowProc)
|
|
trayWindows sync.Map // hwnd -> *trayWindow
|
|
taskbarCreatedMessage uint32
|
|
)
|
|
|
|
type trayPoint struct{ X, Y int32 }
|
|
|
|
type trayMessageRecord struct {
|
|
HWnd uintptr
|
|
Message uint32
|
|
WParam uintptr
|
|
LParam uintptr
|
|
Time uint32
|
|
Point trayPoint
|
|
}
|
|
|
|
type trayClass struct {
|
|
CbSize uint32
|
|
Style uint32
|
|
WndProc uintptr
|
|
CbClsExtra int32
|
|
CbWndExtra int32
|
|
HInstance uintptr
|
|
HIcon uintptr
|
|
HCursor uintptr
|
|
HbrBackground uintptr
|
|
MenuName *uint16
|
|
ClassName *uint16
|
|
HIconSm uintptr
|
|
}
|
|
|
|
type trayIconData struct {
|
|
CbSize uint32
|
|
HWnd uintptr
|
|
UID uint32
|
|
UFlags uint32
|
|
UCallbackMessage uint32
|
|
HIcon uintptr
|
|
Tip [128]uint16
|
|
State uint32
|
|
StateMask uint32
|
|
InfoFlags uint32
|
|
InfoTitle [64]uint16
|
|
InfoData [256]uint16
|
|
Guid [16]byte
|
|
BalloonIcon uintptr
|
|
}
|
|
|
|
type trayWindow struct {
|
|
hwnd uintptr
|
|
icon trayIconData
|
|
class *uint16
|
|
tooltip string
|
|
output io.Writer
|
|
closeOnce sync.Once
|
|
}
|
|
|
|
// Run starts one notification icon for the current logged-in session. It
|
|
// returns when the icon is closed, the service becomes unavailable, or ctx is
|
|
// cancelled; none of those outcomes affect the machine-wide service.
|
|
func Run(ctx context.Context, output io.Writer) error {
|
|
mutex, err := acquireTrayMutex()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer winapi.CloseHandle(mutex)
|
|
response, err := Request(ctx, Frame{Action: ActionStatus})
|
|
if err != nil {
|
|
return fmt.Errorf("connect to RVBox service: %w", err)
|
|
}
|
|
tooltip := string(response.Payload)
|
|
if tooltip == "" {
|
|
tooltip = "RVBox service"
|
|
}
|
|
ready := make(chan *trayWindow, 1)
|
|
done := make(chan error, 1)
|
|
go runTrayMessageLoop(output, tooltip, ready, done)
|
|
var window *trayWindow
|
|
select {
|
|
case window = <-ready:
|
|
case err := <-done:
|
|
return err
|
|
case <-ctx.Done():
|
|
return nil
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
if window != nil {
|
|
_, _, _ = procPostMessage.Call(window.hwnd, wmClose, 0, 0)
|
|
}
|
|
return <-done
|
|
case err := <-done:
|
|
return err
|
|
}
|
|
}
|
|
|
|
func acquireTrayMutex() (winapi.Handle, error) {
|
|
var sessionID uint32
|
|
if err := winapi.ProcessIdToSessionId(winapi.GetCurrentProcessId(), &sessionID); err != nil {
|
|
return 0, err
|
|
}
|
|
name, err := winapi.UTF16PtrFromString(fmt.Sprintf("Local\\RVBoxTrayV1-%d", sessionID))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
mutex, err := winapi.CreateMutex(nil, true, name)
|
|
if errors.Is(err, winapi.ERROR_ALREADY_EXISTS) {
|
|
if mutex != 0 {
|
|
_ = winapi.CloseHandle(mutex)
|
|
}
|
|
return 0, ErrTrayAlreadyRunning
|
|
}
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return mutex, nil
|
|
}
|
|
|
|
func runTrayMessageLoop(output io.Writer, tooltip string, ready chan<- *trayWindow, done chan<- error) {
|
|
className, err := winapi.UTF16PtrFromString("RVBoxTrayWindowV1")
|
|
if err != nil {
|
|
done <- err
|
|
return
|
|
}
|
|
instance, _, _ := procGetModuleHandle.Call(0)
|
|
icon, _, _ := procLoadIcon.Call(0, uintptr(trayIDIApplication))
|
|
cursor, _, _ := procLoadCursor.Call(0, uintptr(trayIDCArrow))
|
|
class := trayClass{CbSize: uint32(unsafe.Sizeof(trayClass{})), WndProc: trayCallback, HInstance: instance, HIcon: icon, HCursor: cursor, ClassName: className, HIconSm: icon}
|
|
if result, _, callErr := procRegisterClassEx.Call(uintptr(unsafe.Pointer(&class))); result == 0 && !errors.Is(callErr, winapi.ERROR_CLASS_ALREADY_EXISTS) {
|
|
done <- callErr
|
|
return
|
|
}
|
|
title, _ := winapi.UTF16PtrFromString("RVBox")
|
|
hwnd, _, callErr := procCreateWindowEx.Call(0, uintptr(unsafe.Pointer(className)), uintptr(unsafe.Pointer(title)), 0, 0, 0, 0, 0, 0, 0, instance, 0)
|
|
if hwnd == 0 {
|
|
done <- callErr
|
|
return
|
|
}
|
|
window := &trayWindow{hwnd: hwnd, class: className, tooltip: tooltip, output: output}
|
|
window.icon = trayIconData{CbSize: uint32(unsafe.Sizeof(trayIconData{})), HWnd: hwnd, UID: trayIconID, UFlags: trayNIFMessage | trayNIFIcon | trayNIFTip, UCallbackMessage: trayMessage, HIcon: icon}
|
|
copy(window.icon.Tip[:], winapi.StringToUTF16(tooltip))
|
|
trayWindows.Store(hwnd, window)
|
|
messageName, _ := winapi.UTF16PtrFromString("TaskbarCreated")
|
|
registeredMessage, _, _ := procRegisterMessage.Call(uintptr(unsafe.Pointer(messageName)))
|
|
taskbarCreatedMessage = uint32(registeredMessage)
|
|
if err := window.addIcon(); err != nil {
|
|
trayWindows.Delete(hwnd)
|
|
_, _, _ = procDestroyWindow.Call(hwnd)
|
|
done <- err
|
|
return
|
|
}
|
|
_, _, _ = procShowWindow.Call(hwnd, traySWHide)
|
|
ready <- window
|
|
for {
|
|
var message trayMessageRecord
|
|
result, _, getErr := procGetMessage.Call(uintptr(unsafe.Pointer(&message)), 0, 0, 0)
|
|
if int32(result) == -1 {
|
|
window.removeIcon()
|
|
done <- getErr
|
|
return
|
|
}
|
|
if result == 0 {
|
|
window.removeIcon()
|
|
done <- nil
|
|
return
|
|
}
|
|
_, _, _ = procTranslate.Call(uintptr(unsafe.Pointer(&message)))
|
|
_, _, _ = procDispatch.Call(uintptr(unsafe.Pointer(&message)))
|
|
}
|
|
}
|
|
|
|
func trayWindowProc(hwnd uintptr, message uint32, wParam, lParam uintptr) uintptr {
|
|
value, _ := trayWindows.Load(hwnd)
|
|
window, _ := value.(*trayWindow)
|
|
switch message {
|
|
case wmClose:
|
|
_, _, _ = procDestroyWindow.Call(hwnd)
|
|
return 0
|
|
case wmDestroy:
|
|
if window != nil {
|
|
window.removeIcon()
|
|
}
|
|
trayWindows.Delete(hwnd)
|
|
procPostQuitMessage.Call(0)
|
|
return 0
|
|
case wmCommand:
|
|
if window != nil {
|
|
window.action(Action(uint16(wParam)))
|
|
}
|
|
return 0
|
|
case trayMessage:
|
|
if window != nil && (uint32(lParam) == wmRButtonUp || uint32(lParam) == wmLButtonDblClick) {
|
|
window.showMenu()
|
|
}
|
|
return 0
|
|
}
|
|
if window != nil && taskbarCreatedMessage != 0 && message == taskbarCreatedMessage {
|
|
// Explorer may restart without destroying the tray window. Re-add the
|
|
// icon with the current bounded tooltip so the frontend recovers without
|
|
// restarting the service or tray process.
|
|
_ = window.addIcon()
|
|
return 0
|
|
}
|
|
result, _, _ := procDefWindowProc.Call(hwnd, uintptr(message), wParam, lParam)
|
|
return result
|
|
}
|
|
|
|
func (window *trayWindow) addIcon() error {
|
|
window.icon.UFlags = trayNIFMessage | trayNIFIcon | trayNIFTip
|
|
if result, _, err := procShellNotify.Call(uintptr(trayNIMAdd), uintptr(unsafe.Pointer(&window.icon))); result == 0 {
|
|
if err == syscall.Errno(0) {
|
|
err = syscall.GetLastError()
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (window *trayWindow) removeIcon() {
|
|
window.closeOnce.Do(func() {
|
|
window.icon.UFlags = 0
|
|
_, _, _ = procShellNotify.Call(uintptr(trayNIMDelete), uintptr(unsafe.Pointer(&window.icon)))
|
|
})
|
|
}
|
|
|
|
func (window *trayWindow) showMenu() {
|
|
menu, _, _ := procCreatePopup.Call()
|
|
if menu == 0 {
|
|
return
|
|
}
|
|
defer procDestroyMenu.Call(menu)
|
|
add := func(action Action, label string) {
|
|
text, _ := winapi.UTF16PtrFromString(label)
|
|
procAppendMenu.Call(menu, trayMFString, uintptr(action), uintptr(unsafe.Pointer(text)))
|
|
}
|
|
add(ActionStatus, "Status")
|
|
add(ActionOpenConfig, "Open config")
|
|
add(ActionOpenLog, "Open log")
|
|
procAppendMenu.Call(menu, trayMFSeparator, 0, 0)
|
|
add(ActionStartService, "Start service")
|
|
add(ActionStopService, "Stop service")
|
|
add(ActionRestartService, "Restart service")
|
|
add(ActionSetAutomatic, "Start automatically")
|
|
add(ActionSetManual, "Start manually")
|
|
procAppendMenu.Call(menu, trayMFSeparator, 0, 0)
|
|
add(ActionExitTray, "Exit")
|
|
var point trayPoint
|
|
procGetCursorPos.Call(uintptr(unsafe.Pointer(&point)))
|
|
procSetForeground.Call(window.hwnd)
|
|
procTrackPopup.Call(menu, trayTPMRightBtn, uintptr(point.X), uintptr(point.Y), 0, window.hwnd, 0)
|
|
}
|
|
|
|
func (window *trayWindow) action(action Action) {
|
|
if action == ActionExitTray {
|
|
_, _, _ = procDestroyWindow.Call(window.hwnd)
|
|
return
|
|
}
|
|
response, err := Request(context.Background(), Frame{Action: action})
|
|
if err != nil {
|
|
window.setTooltip("RVBox service unavailable")
|
|
return
|
|
}
|
|
if isServiceMutation(action) && strings.HasPrefix(string(response.Payload), "error:") {
|
|
if err := runElevatedServiceAction(action); err == nil {
|
|
window.setTooltip("RVBox elevation requested")
|
|
return
|
|
}
|
|
}
|
|
if action == ActionOpenConfig || action == ActionOpenLog {
|
|
if err := openExactPath(string(response.Payload)); err != nil {
|
|
window.setTooltip("RVBox open failed")
|
|
}
|
|
return
|
|
}
|
|
if len(response.Payload) > 0 {
|
|
window.setTooltip(string(response.Payload))
|
|
}
|
|
}
|
|
|
|
func isServiceMutation(action Action) bool {
|
|
return action == ActionStartService || action == ActionStopService || action == ActionRestartService || action == ActionSetAutomatic || action == ActionSetManual
|
|
}
|
|
|
|
// runElevatedServiceAction is the tray's narrow UAC fallback. The service
|
|
// remains the authorization boundary; this helper only replays the enum as a
|
|
// canonical rvbox mode under an administrator token when the tray's filtered
|
|
// token cannot mutate SCM state directly.
|
|
func runElevatedServiceAction(action Action) error {
|
|
mode := ""
|
|
switch action {
|
|
case ActionStartService:
|
|
mode = "--start-service"
|
|
case ActionStopService:
|
|
mode = "--stop-service"
|
|
case ActionRestartService:
|
|
mode = "--restart-service"
|
|
case ActionSetAutomatic:
|
|
mode = "--configure-service --startup automatic"
|
|
case ActionSetManual:
|
|
mode = "--configure-service --startup manual"
|
|
default:
|
|
return errors.New("unsupported elevated tray action")
|
|
}
|
|
executable, err := os.Executable()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
configPath := ""
|
|
for index := 0; index+1 < len(os.Args); index++ {
|
|
if os.Args[index] == "--config" {
|
|
configPath = os.Args[index+1]
|
|
break
|
|
}
|
|
}
|
|
if configPath == "" {
|
|
return errors.New("tray config path is unavailable")
|
|
}
|
|
args, err := winapi.UTF16PtrFromString(mode + " --config " + syscall.EscapeArg(configPath))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
file, err := winapi.UTF16PtrFromString(executable)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := winapi.ShellExecute(0, mustUTF16("runas"), file, args, nil, winapi.SW_SHOWNORMAL); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func mustUTF16(value string) *uint16 {
|
|
encoded, _ := winapi.UTF16PtrFromString(value)
|
|
return encoded
|
|
}
|
|
|
|
func (window *trayWindow) setTooltip(value string) {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
value = "RVBox service"
|
|
}
|
|
window.tooltip = value
|
|
window.icon.UFlags = trayNIFTip
|
|
for index := range window.icon.Tip {
|
|
window.icon.Tip[index] = 0
|
|
}
|
|
copy(window.icon.Tip[:], winapi.StringToUTF16(value))
|
|
_, _, _ = procShellNotify.Call(uintptr(trayNIMModify), uintptr(unsafe.Pointer(&window.icon)))
|
|
}
|
|
|
|
func openExactPath(path string) error {
|
|
if path == "" || strings.TrimSpace(path) != path || !utf8.ValidString(path) || !filepath.IsAbs(path) {
|
|
return errors.New("invalid path returned by service")
|
|
}
|
|
info, err := os.Lstat(path)
|
|
if err != nil || !info.Mode().IsRegular() {
|
|
return errors.New("service path is not a regular file")
|
|
}
|
|
windowsDir, err := winapi.GetWindowsDirectory()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
explorer := filepath.Join(windowsDir, "explorer.exe")
|
|
if info, err := os.Lstat(explorer); err != nil || !info.Mode().IsRegular() {
|
|
return errors.New("Windows Explorer is unavailable")
|
|
}
|
|
return exec.Command(explorer, path).Start()
|
|
}
|