Files
rvbox/internal/client/windowstray/tray_windows.go
T

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()
}