feat: complete Windows client control and recovery paths
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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