feat: complete Windows client control and recovery paths
This commit is contained in:
@@ -0,0 +1,419 @@
|
||||
//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")
|
||||
procShellNotify = shell32Tray.NewProc("Shell_NotifyIconW")
|
||||
trayCallback = syscall.NewCallback(trayWindowProc)
|
||||
trayWindows sync.Map // hwnd -> *trayWindow
|
||||
)
|
||||
|
||||
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)
|
||||
if result, _, err := procShellNotify.Call(uintptr(trayNIMAdd), uintptr(unsafe.Pointer(&window.icon))); result == 0 {
|
||||
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
|
||||
}
|
||||
result, _, _ := procDefWindowProc.Call(hwnd, uintptr(message), wParam, lParam)
|
||||
return result
|
||||
}
|
||||
|
||||
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) {
|
||||
return errors.New("invalid path returned by service")
|
||||
}
|
||||
info, err := os.Stat(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.Stat(explorer); err != nil || !info.Mode().IsRegular() {
|
||||
return errors.New("Windows Explorer is unavailable")
|
||||
}
|
||||
return exec.Command(explorer, path).Start()
|
||||
}
|
||||
Reference in New Issue
Block a user