feat: complete Windows client control and recovery paths

This commit is contained in:
2026-09-06 13:37:32 +00:00
parent 56b15c7f4f
commit 486894557d
38 changed files with 2188 additions and 106 deletions
+16 -1
View File
@@ -5,12 +5,25 @@ package windowstray
import (
"bytes"
"context"
"encoding/binary"
"errors"
"fmt"
"unicode/utf8"
)
// PipeName is the machine-local service endpoint used by every tray session.
// The native implementation creates it with PIPE_REJECT_REMOTE_CLIENTS and
// an explicit SYSTEM/Administrators/interactive-user ACL. Keeping the name
// here (rather than deriving it from user input) prevents cross-session and
// path-confusion bugs.
const PipeName = `\\.\pipe\RVBoxClientTrayV1`
// Handler is invoked by the service after the native adapter has verified the
// connecting process token, SID, and session. A response is always encoded
// as ActionStatus; requests other than status deliberately carry no payload.
type Handler func(context.Context, Peer, Frame) (Frame, error)
const (
protocolVersion uint16 = 1
maxFrameBytes = 64 << 10
@@ -33,6 +46,8 @@ const (
ActionStartService
ActionStopService
ActionRestartService
ActionSetAutomatic
ActionSetManual
ActionExitTray
)
@@ -115,7 +130,7 @@ func Authorize(peer Peer, action Action) error {
switch action {
case ActionStatus, ActionOpenConfig, ActionOpenLog, ActionExitTray:
return nil
case ActionStartService, ActionStopService, ActionRestartService:
case ActionStartService, ActionStopService, ActionRestartService, ActionSetAutomatic, ActionSetManual:
if peer.Administrator || peer.System {
return nil
}
@@ -0,0 +1,20 @@
//go:build !windows
package windowstray
import (
"context"
"errors"
"io"
)
var ErrUnsupported = errors.New("Windows tray IPC is unavailable on this platform")
// Serve exists on every platform so the service wiring stays build-tag
// neutral. Unix-like client support is deferred in v1; this endpoint must
// never silently become a second client daemon.
func Serve(context.Context, Handler) error { return ErrUnsupported }
func Request(context.Context, Frame) (Frame, error) { return Frame{}, ErrUnsupported }
func Run(context.Context, io.Writer) error { return ErrUnsupported }
@@ -0,0 +1,242 @@
//go:build windows
package windowstray
// This file is the small native service endpoint. It intentionally uses one
// request per pipe connection: the tray is a presentation client, not a
// long-lived command channel, and a bounded connection makes cancellation and
// peer verification straightforward. The service never hands the tray a
// store handle or command payload.
import (
"context"
"errors"
"fmt"
"io"
"os"
"time"
"unsafe"
winapi "golang.org/x/sys/windows"
)
const (
pipeBufferBytes = 64 << 10
pipeInstances = 8
)
var (
ErrUnsupported = errors.New("Windows tray IPC is unavailable on this platform")
pipeSDDL = "D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGW;;;IU)"
)
// Serve accepts bounded tray requests until ctx is cancelled. It is safe to
// run before any interactive user logs in; in that state no client can pass
// the interactive-peer authorization check.
func Serve(ctx context.Context, handler Handler) error {
if handler == nil {
return errors.New("tray handler is required")
}
for {
pipe, err := newTrayPipe()
if err != nil {
return fmt.Errorf("create tray pipe: %w", err)
}
connected := make(chan error, 1)
go func() { connected <- winapi.ConnectNamedPipe(winapi.Handle(pipe.Fd()), nil) }()
select {
case <-ctx.Done():
_ = pipe.Close()
return nil
case err := <-connected:
if err != nil && !errors.Is(err, winapi.ERROR_PIPE_CONNECTED) {
_ = pipe.Close()
if ctx.Err() != nil {
return nil
}
continue
}
go serveTrayPipe(ctx, pipe, handler)
}
}
}
func newTrayPipe() (*os.File, error) {
name, err := winapi.UTF16PtrFromString(PipeName)
if err != nil {
return nil, err
}
descriptor, err := winapi.SecurityDescriptorFromString(pipeSDDL)
if err != nil {
return nil, err
}
attributes := &winapi.SecurityAttributes{
Length: uint32(unsafe.Sizeof(winapi.SecurityAttributes{})),
SecurityDescriptor: descriptor,
}
mode := uint32(winapi.PIPE_ACCESS_DUPLEX | winapi.PIPE_TYPE_MESSAGE | winapi.PIPE_READMODE_MESSAGE | winapi.PIPE_WAIT | winapi.PIPE_REJECT_REMOTE_CLIENTS | winapi.SECURITY_IDENTIFICATION)
handle, err := winapi.CreateNamedPipe(name, mode, pipeInstances, pipeBufferBytes, pipeBufferBytes, 0, 0, attributes)
if err != nil {
return nil, err
}
return os.NewFile(uintptr(handle), "rvbox-tray-pipe"), nil
}
func serveTrayPipe(ctx context.Context, pipe *os.File, handler Handler) {
if pipe == nil {
return
}
defer pipe.Close()
// A blocked Read must be interrupted when service shutdown cancels ctx.
readDone := make(chan struct{})
go func() {
select {
case <-ctx.Done():
_ = pipe.Close()
case <-readDone:
}
}()
defer close(readDone)
peer, err := peerFromPipe(winapi.Handle(pipe.Fd()))
if err != nil {
writeTrayResponse(pipe, err)
return
}
request, err := readTrayFrame(pipe)
if err != nil {
writeTrayResponse(pipe, err)
return
}
if err := Authorize(peer, request.Action); err != nil {
writeTrayResponse(pipe, err)
return
}
response, err := handler(ctx, peer, request)
if err != nil {
writeTrayResponse(pipe, err)
return
}
if response.Action == 0 {
response.Action = ActionStatus
}
if response.Action != ActionStatus {
response = Frame{Action: ActionStatus}
}
writeTrayResponse(pipe, response)
}
func readTrayFrame(reader io.Reader) (Frame, error) {
buffer := make([]byte, maxFrameBytes)
count, err := reader.Read(buffer)
if err != nil {
return Frame{}, err
}
if count == len(buffer) {
return Frame{}, ErrFrameTooLarge
}
return Decode(buffer[:count])
}
func writeTrayResponse(writer *os.File, value any) {
frame := Frame{Action: ActionStatus}
switch response := value.(type) {
case Frame:
frame = response
case error:
message := response.Error()
if len(message) > maxPayloadBytes {
message = message[:maxPayloadBytes]
}
frame.Payload = []byte("error: " + message)
}
encoded, err := Encode(frame)
if err != nil {
return
}
_, _ = writer.Write(encoded)
_ = winapi.FlushFileBuffers(winapi.Handle(writer.Fd()))
}
func peerFromPipe(pipe winapi.Handle) (Peer, error) {
if pipe == 0 || pipe == winapi.InvalidHandle {
return Peer{}, ErrInvalidPeer
}
var pid uint32
if err := winapi.GetNamedPipeClientProcessId(pipe, &pid); err != nil || pid == 0 {
return Peer{}, ErrInvalidPeer
}
process, err := winapi.OpenProcess(winapi.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
if err != nil {
return Peer{}, ErrInvalidPeer
}
defer winapi.CloseHandle(process)
var token winapi.Token
if err := winapi.OpenProcessToken(process, winapi.TOKEN_QUERY, &token); err != nil {
return Peer{}, ErrInvalidPeer
}
defer token.Close()
user, err := token.GetTokenUser()
if err != nil || user.User.Sid == nil {
return Peer{}, ErrInvalidPeer
}
var sessionID uint32
var returned uint32
if err := winapi.GetTokenInformation(token, winapi.TokenSessionId, (*byte)(unsafe.Pointer(&sessionID)), uint32(unsafe.Sizeof(sessionID)), &returned); err != nil || returned != uint32(unsafe.Sizeof(sessionID)) {
return Peer{}, ErrInvalidPeer
}
adminSID, err := winapi.CreateWellKnownSid(winapi.WinBuiltinAdministratorsSid)
if err != nil {
return Peer{}, ErrInvalidPeer
}
admin, err := token.IsMember(adminSID)
if err != nil {
return Peer{}, ErrInvalidPeer
}
sid := user.User.Sid.String()
return Peer{PID: pid, SessionID: sessionID, SID: sid, TokenVerified: true, Interactive: sessionID != 0, Administrator: admin, System: sid == "S-1-5-18"}, nil
}
// Request opens exactly one local pipe connection and exchanges one frame.
// It retries only the transient ERROR_PIPE_BUSY state and never falls back to
// an arbitrary filesystem/socket path.
func Request(ctx context.Context, request Frame) (Frame, error) {
encoded, err := Encode(request)
if err != nil {
return Frame{}, err
}
var pipe *os.File
for {
if ctx.Err() != nil {
return Frame{}, ctx.Err()
}
name, nameErr := winapi.UTF16PtrFromString(PipeName)
if nameErr != nil {
return Frame{}, nameErr
}
handle, openErr := winapi.CreateFile(name, winapi.GENERIC_READ|winapi.GENERIC_WRITE, 0, nil, winapi.OPEN_EXISTING, 0, 0)
if openErr == nil {
pipe = os.NewFile(uintptr(handle), "rvbox-tray-client")
break
}
if !errors.Is(openErr, winapi.ERROR_PIPE_BUSY) {
return Frame{}, openErr
}
timer := time.NewTimer(100 * time.Millisecond)
select {
case <-ctx.Done():
timer.Stop()
return Frame{}, ctx.Err()
case <-timer.C:
}
}
defer pipe.Close()
state := uint32(winapi.PIPE_READMODE_MESSAGE)
_ = winapi.SetNamedPipeHandleState(winapi.Handle(pipe.Fd()), &state, nil, nil)
if _, err := pipe.Write(encoded); err != nil {
return Frame{}, err
}
if err := winapi.FlushFileBuffers(winapi.Handle(pipe.Fd())); err != nil {
return Frame{}, err
}
return readTrayFrame(pipe)
}
+419
View File
@@ -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()
}