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