real cursor shit

This commit is contained in:
KingKDot
2026-07-19 01:22:42 -05:00
parent 06e8a2921e
commit 1a68b5ddf1
17 changed files with 889 additions and 146 deletions
@@ -114,6 +114,7 @@ func CaptureAndSend(ctx context.Context, env *rt.Env) error {
log.Printf("capture: direct video capture failed: %v (sending black frame, consecutive=%d)", err, consecutiveCaptureFails.Load()) log.Printf("capture: direct video capture failed: %v (sending black frame, consecutive=%d)", err, consecutiveCaptureFails.Load())
return sendBlackFrame(ctx, env) return sendBlackFrame(ctx, env)
} }
emitDesktopCursor(ctx, env, display, frame.Header.Width, frame.Header.Height)
if len(frame.Data) == 0 { if len(frame.Data) == 0 {
consecutiveCaptureFails.Store(0) consecutiveCaptureFails.Store(0)
return nil return nil
@@ -168,6 +169,7 @@ func CaptureAndSend(ctx context.Context, env *rt.Env) error {
} }
frame.Header.Width = frameWidth frame.Header.Width = frameWidth
frame.Header.Height = frameHeight frame.Header.Height = frameHeight
emitDesktopCursor(ctx, env, display, frameWidth, frameHeight)
now := time.Now() now := time.Now()
fps := frameFPS(now) fps := frameFPS(now)
if fps <= 0 { if fps <= 0 {
@@ -8,6 +8,10 @@ import (
func SetCursorCapture(enabled bool) {} func SetCursorCapture(enabled bool) {}
func DesktopCursorState(display, frameWidth, frameHeight int) DesktopCursorMetadata {
return DesktopCursorMetadata{}
}
func DrawCursorOnImage(img *image.RGBA, captureBounds image.Rectangle) { func DrawCursorOnImage(img *image.RGBA, captureBounds image.Rectangle) {
// if you're not windows fuck you // if you're not windows fuck you
} }
@@ -0,0 +1,116 @@
package capture
import (
"context"
"image"
"sync"
rt "overlord-client/cmd/agent/runtime"
"overlord-client/cmd/agent/wire"
)
type DesktopCursorMetadata struct {
X int
Y int
Visible bool
Enabled bool
Shape uint64
Generation uint64
CursorWidth int
CursorHeight int
HotspotX int
HotspotY int
Image []byte
}
type desktopCursorStreamState struct {
x int
y int
width int
height int
visible bool
enabled bool
shape uint64
generation uint64
}
var desktopCursorStream struct {
sync.Mutex
last desktopCursorStreamState
}
func emitDesktopCursor(ctx context.Context, env *rt.Env, display, frameWidth, frameHeight int) {
desktopCursorStream.Lock()
previousSize := desktopCursorStream.last
desktopCursorStream.Unlock()
frameWidth, frameHeight = resolveCursorFrameSize(
frameWidth,
frameHeight,
previousSize,
DisplayBounds(display),
)
cursor := DesktopCursorState(display, frameWidth, frameHeight)
desktopCursorStream.Lock()
previous := desktopCursorStream.last
positionChanged := cursor.X != previous.x || cursor.Y != previous.y ||
frameWidth != previous.width || frameHeight != previous.height ||
cursor.Visible != previous.visible || cursor.Enabled != previous.enabled
shapeChanged := cursorShapeNeedsRefresh(cursor, previous, frameWidth, frameHeight)
includeShape := shapeChanged && len(cursor.Image) != 0
if !positionChanged && !includeShape {
desktopCursorStream.Unlock()
return
}
next := previous
next.x, next.y = cursor.X, cursor.Y
next.width, next.height = frameWidth, frameHeight
next.visible, next.enabled = cursor.Visible, cursor.Enabled
if includeShape {
next.shape, next.generation = cursor.Shape, cursor.Generation
}
desktopCursorStream.last = next
desktopCursorStream.Unlock()
if !cursor.Enabled && !previous.enabled {
return
}
message := wire.DesktopCursor{
Type: "desktop_cursor",
X: cursor.X,
Y: cursor.Y,
Width: frameWidth,
Height: frameHeight,
Visible: cursor.Enabled && cursor.Visible,
}
if includeShape {
message.CursorWidth = cursor.CursorWidth
message.CursorHeight = cursor.CursorHeight
message.HotspotX = cursor.HotspotX
message.HotspotY = cursor.HotspotY
message.Image = cursor.Image
}
_ = wire.WriteMsg(ctx, env.Conn, message)
}
func cursorShapeNeedsRefresh(cursor DesktopCursorMetadata, previous desktopCursorStreamState, frameWidth, frameHeight int) bool {
return cursor.Enabled &&
(cursor.Shape != previous.shape || cursor.Generation != previous.generation ||
frameWidth != previous.width || frameHeight != previous.height)
}
func resolveCursorFrameSize(frameWidth, frameHeight int, previous desktopCursorStreamState, captureBounds image.Rectangle) (int, int) {
if frameWidth <= 0 {
frameWidth = previous.width
if frameWidth <= 0 {
frameWidth = captureBounds.Dx()
}
}
if frameHeight <= 0 {
frameHeight = previous.height
if frameHeight <= 0 {
frameHeight = captureBounds.Dy()
}
}
return frameWidth, frameHeight
}
@@ -0,0 +1,34 @@
package capture
import (
"image"
"testing"
)
func TestCursorShapeRefreshesWhenEncodedResolutionChanges(t *testing.T) {
cursor := DesktopCursorMetadata{Enabled: true, Shape: 7, Generation: 3}
previous := desktopCursorStreamState{
enabled: true, shape: 7, generation: 3,
width: 3840, height: 2160,
}
if !cursorShapeNeedsRefresh(cursor, previous, 1920, 1080) {
t.Fatal("cursor shape metadata was not refreshed for 4K to 1080p change")
}
if cursorShapeNeedsRefresh(cursor, previous, 3840, 2160) {
t.Fatal("unchanged encoded resolution refreshed cursor shape metadata")
}
}
func TestEmptyDirectFrameKeepsEncodedCursorResolution(t *testing.T) {
previous := desktopCursorStreamState{width: 1920, height: 1080}
width, height := resolveCursorFrameSize(0, 0, previous, image.Rect(0, 0, 3840, 2160))
if width != 1920 || height != 1080 {
t.Fatalf("empty frame cursor size = %dx%d, want 1920x1080", width, height)
}
cursor := DesktopCursorMetadata{Enabled: true, Shape: 7, Generation: 3}
previous.enabled, previous.shape, previous.generation = true, 7, 3
if cursorShapeNeedsRefresh(cursor, previous, width, height) {
t.Fatal("empty direct frame caused a redundant cursor PNG refresh")
}
}
@@ -3,18 +3,30 @@
package capture package capture
import ( import (
"bytes"
"fmt"
"image" "image"
"image/color" "image/draw"
"image/png"
"sync"
"sync/atomic" "sync/atomic"
"unsafe" "unsafe"
) )
var ( var (
cursorCaptureEnabled atomic.Bool cursorCaptureEnabled atomic.Bool
cursorMetadataEnabled atomic.Bool
cursorGeneration atomic.Uint64
) )
func SetCursorCapture(enabled bool) { func SetCursorCapture(enabled bool) {
cursorCaptureEnabled.Store(enabled) // Cursor pixels are transported separately. Keeping frame compositing off
// preserves direct DXGI/video encoding and avoids a full-frame copy.
cursorMetadataEnabled.Store(enabled)
cursorCaptureEnabled.Store(false)
if enabled {
cursorGeneration.Add(1)
}
} }
var ( var (
@@ -22,6 +34,7 @@ var (
procGetCursorInfo = user32.NewProc("GetCursorInfo") procGetCursorInfo = user32.NewProc("GetCursorInfo")
procGetIconInfo = user32.NewProc("GetIconInfo") procGetIconInfo = user32.NewProc("GetIconInfo")
procDrawIconEx = user32.NewProc("DrawIconEx") procDrawIconEx = user32.NewProc("DrawIconEx")
procGetObjectW = gdi32.NewProc("GetObjectW")
) )
const ( const (
@@ -50,149 +63,330 @@ type iconInfo struct {
hbmColor uintptr hbmColor uintptr
} }
func getCursorPosition() (x, y int32, visible bool) { type gdiBitmap struct {
bmType int32
bmWidth int32
bmHeight int32
bmWidthBytes int32
bmPlanes uint16
bmBitsPixel uint16
bmBits unsafe.Pointer
}
type cursorShape struct {
width int
height int
hotspotX int
hotspotY int
image []byte
}
var cursorShapeCache struct {
sync.Mutex
hCursor uintptr
shape cursorShape
}
func queryCursor() (cursorInfo, bool) {
var ci cursorInfo var ci cursorInfo
ci.cbSize = uint32(unsafe.Sizeof(ci)) ci.cbSize = uint32(unsafe.Sizeof(ci))
ret, _, _ := procGetCursorInfo.Call(uintptr(unsafe.Pointer(&ci))) ret, _, _ := procGetCursorInfo.Call(uintptr(unsafe.Pointer(&ci)))
return ci, ret != 0
}
func getCursorPosition() (x, y int32, visible bool) {
ci, ok := queryCursor()
if !ok {
return 0, 0, false
}
return ci.ptScreenPos.x, ci.ptScreenPos.y, (ci.flags & CURSOR_SHOWING) != 0
}
func DesktopCursorState(display, frameWidth, frameHeight int) DesktopCursorMetadata {
if !cursorMetadataEnabled.Load() {
return DesktopCursorMetadata{}
}
state := DesktopCursorMetadata{
Enabled: true,
Generation: cursorGeneration.Load(),
}
ci, ok := queryCursor()
if !ok {
return state
}
state.Shape = uint64(ci.hCursor)
if ci.hCursor != 0 {
if shape, err := getCursorShape(ci.hCursor); err == nil {
state.CursorWidth = shape.width
state.CursorHeight = shape.height
state.HotspotX = shape.hotspotX
state.HotspotY = shape.hotspotY
state.Image = shape.image
}
}
bounds := DisplayBounds(display)
if bounds.Dx() <= 0 || bounds.Dy() <= 0 {
return state
}
if frameWidth <= 0 {
frameWidth = bounds.Dx()
}
if frameHeight <= 0 {
frameHeight = bounds.Dy()
}
if state.CursorWidth > 0 && state.CursorHeight > 0 {
state.CursorWidth = scaleCursorMetric(state.CursorWidth, frameWidth, bounds.Dx())
state.CursorHeight = scaleCursorMetric(state.CursorHeight, frameHeight, bounds.Dy())
state.HotspotX = scaleCursorMetric(state.HotspotX, frameWidth, bounds.Dx())
state.HotspotY = scaleCursorMetric(state.HotspotY, frameHeight, bounds.Dy())
if state.HotspotX >= state.CursorWidth {
state.HotspotX = state.CursorWidth - 1
}
if state.HotspotY >= state.CursorHeight {
state.HotspotY = state.CursorHeight - 1
}
}
showing := (ci.flags & CURSOR_SHOWING) != 0
if !showing ||
ci.ptScreenPos.x < int32(bounds.Min.X) || ci.ptScreenPos.x >= int32(bounds.Max.X) ||
ci.ptScreenPos.y < int32(bounds.Min.Y) || ci.ptScreenPos.y >= int32(bounds.Max.Y) {
return state
}
state.X = int(ci.ptScreenPos.x-int32(bounds.Min.X)) * frameWidth / bounds.Dx()
state.Y = int(ci.ptScreenPos.y-int32(bounds.Min.Y)) * frameHeight / bounds.Dy()
state.Visible = true
return state
}
func scaleCursorMetric(value, frameSize, captureSize int) int {
if value <= 0 || frameSize <= 0 || captureSize <= 0 {
return 0
}
scaled := (value*frameSize + captureSize/2) / captureSize
if scaled < 1 {
return 1
}
return scaled
}
func getCursorShape(hCursor uintptr) (cursorShape, error) {
cursorShapeCache.Lock()
defer cursorShapeCache.Unlock()
if cursorShapeCache.hCursor == hCursor && len(cursorShapeCache.shape.image) != 0 {
return cursorShapeCache.shape, nil
}
shape, err := extractCursorShape(hCursor)
if err != nil {
return cursorShape{}, err
}
cursorShapeCache.hCursor = hCursor
cursorShapeCache.shape = shape
return shape, nil
}
func extractCursorShape(hCursor uintptr) (cursorShape, error) {
if hCursor == 0 {
return cursorShape{}, fmt.Errorf("cursor handle is null")
}
var icon iconInfo
ret, _, callErr := procGetIconInfo.Call(hCursor, uintptr(unsafe.Pointer(&icon)))
if ret == 0 {
return cursorShape{}, fmt.Errorf("GetIconInfo: %w", callErr)
}
if icon.hbmMask != 0 {
defer deleteObject(icon.hbmMask)
}
if icon.hbmColor != 0 {
defer deleteObject(icon.hbmColor)
}
width, height, err := cursorBitmapDimensions(icon)
if err != nil {
return cursorShape{}, err
}
black, err := renderCursorOnBackground(hCursor, width, height, 0)
if err != nil {
return cursorShape{}, err
}
white, err := renderCursorOnBackground(hCursor, width, height, 255)
if err != nil {
return cursorShape{}, err
}
img := reconstructCursorRGBA(black, white, width, height)
var encoded bytes.Buffer
if err := png.Encode(&encoded, img); err != nil {
return cursorShape{}, fmt.Errorf("encode cursor PNG: %w", err)
}
return cursorShape{
width: width,
height: height,
hotspotX: int(icon.xHotspot),
hotspotY: int(icon.yHotspot),
image: encoded.Bytes(),
}, nil
}
func cursorBitmapDimensions(icon iconInfo) (int, int, error) {
if icon.hbmColor != 0 {
if width, height, ok := bitmapDimensions(icon.hbmColor); ok {
return width, height, validateCursorDimensions(width, height)
}
}
if icon.hbmMask != 0 {
if width, maskHeight, ok := bitmapDimensions(icon.hbmMask); ok {
height := maskHeight
if icon.hbmColor == 0 {
// A monochrome cursor stores its AND and XOR masks vertically.
height /= 2
}
return width, height, validateCursorDimensions(width, height)
}
}
return 0, 0, fmt.Errorf("cursor has no readable bitmap")
}
func bitmapDimensions(hBitmap uintptr) (int, int, bool) {
var bitmap gdiBitmap
ret, _, _ := procGetObjectW.Call(
hBitmap,
uintptr(unsafe.Sizeof(bitmap)),
uintptr(unsafe.Pointer(&bitmap)),
)
if ret == 0 { if ret == 0 {
return 0, 0, false return 0, 0, false
} }
width, height := int(bitmap.bmWidth), int(bitmap.bmHeight)
if width < 0 {
width = -width
}
if height < 0 {
height = -height
}
return width, height, true
}
visible = (ci.flags & CURSOR_SHOWING) != 0 func validateCursorDimensions(width, height int) error {
return ci.ptScreenPos.x, ci.ptScreenPos.y, visible if width <= 0 || height <= 0 || width > 1024 || height > 1024 {
return fmt.Errorf("invalid cursor dimensions %dx%d", width, height)
}
return nil
}
func renderCursorOnBackground(hCursor uintptr, width, height int, background byte) ([]byte, error) {
hdcScreen := getDC(0)
if hdcScreen == 0 {
return nil, fmt.Errorf("GetDC failed")
}
defer releaseDC(0, hdcScreen)
hdc := createCompatibleDC(hdcScreen)
if hdc == 0 {
return nil, fmt.Errorf("CreateCompatibleDC failed")
}
defer deleteDC(hdc)
bmi := bitmapInfo{bmiHeader: bitmapInfoHeader{
biSize: uint32(unsafe.Sizeof(bitmapInfoHeader{})),
biWidth: int32(width),
biHeight: -int32(height),
biPlanes: 1,
biBitCount: 32,
biCompression: BI_RGB,
}}
var bits unsafe.Pointer
dib := createDIBSection(hdcScreen, &bmi, DIB_RGB_COLORS, &bits)
if dib == 0 || bits == nil {
return nil, fmt.Errorf("CreateDIBSection failed")
}
defer deleteObject(dib)
previous := selectObject(hdc, dib)
if previous == 0 {
return nil, fmt.Errorf("SelectObject failed")
}
defer selectObject(hdc, previous)
pixels := unsafe.Slice((*byte)(bits), width*height*4)
for offset := 0; offset < len(pixels); offset += 4 {
pixels[offset] = background
pixels[offset+1] = background
pixels[offset+2] = background
pixels[offset+3] = 255
}
ret, _, callErr := procDrawIconEx.Call(
hdc,
0,
0,
hCursor,
uintptr(width),
uintptr(height),
0,
0,
uintptr(DI_NORMAL),
)
if ret == 0 {
return nil, fmt.Errorf("DrawIconEx: %w", callErr)
}
result := make([]byte, len(pixels))
copy(result, pixels)
return result, nil
}
func reconstructCursorRGBA(black, white []byte, width, height int) *image.NRGBA {
img := image.NewNRGBA(image.Rect(0, 0, width, height))
for src, dst := 0, 0; src+3 < len(black) && src+3 < len(white); src, dst = src+4, dst+4 {
blue := int(black[src])
green := int(black[src+1])
red := int(black[src+2])
transmission := max(
int(white[src])-blue,
int(white[src+1])-green,
int(white[src+2])-red,
)
if transmission < 0 {
transmission = 0
} else if transmission > 255 {
transmission = 255
}
alpha := 255 - transmission
img.Pix[dst+3] = byte(alpha)
if alpha == 0 {
continue
}
img.Pix[dst] = unpremultiplyCursorChannel(red, alpha)
img.Pix[dst+1] = unpremultiplyCursorChannel(green, alpha)
img.Pix[dst+2] = unpremultiplyCursorChannel(blue, alpha)
}
return img
}
func unpremultiplyCursorChannel(value, alpha int) byte {
value = (value*255 + alpha/2) / alpha
if value > 255 {
value = 255
}
return byte(value)
} }
func drawCursor(img *image.RGBA, cursorX, cursorY int32, bounds image.Rectangle) { func drawCursor(img *image.RGBA, cursorX, cursorY int32, bounds image.Rectangle) {
imgX := int(cursorX) - bounds.Min.X if img == nil {
imgY := int(cursorY) - bounds.Min.Y
if imgX < 0 || imgY < 0 || imgX >= img.Bounds().Dx() || imgY >= img.Bounds().Dy() {
return return
} }
ci, ok := queryCursor()
cursorPattern := []struct { if !ok || ci.hCursor == 0 {
x, y int return
c color.RGBA
}{
{0, 0, color.RGBA{0, 0, 0, 255}},
{0, 1, color.RGBA{0, 0, 0, 255}},
{0, 2, color.RGBA{0, 0, 0, 255}},
{0, 3, color.RGBA{0, 0, 0, 255}},
{0, 4, color.RGBA{0, 0, 0, 255}},
{0, 5, color.RGBA{0, 0, 0, 255}},
{0, 6, color.RGBA{0, 0, 0, 255}},
{0, 7, color.RGBA{0, 0, 0, 255}},
{0, 8, color.RGBA{0, 0, 0, 255}},
{0, 9, color.RGBA{0, 0, 0, 255}},
{0, 10, color.RGBA{0, 0, 0, 255}},
{0, 11, color.RGBA{0, 0, 0, 255}},
{1, 0, color.RGBA{0, 0, 0, 255}},
{1, 10, color.RGBA{0, 0, 0, 255}},
{2, 0, color.RGBA{0, 0, 0, 255}},
{2, 9, color.RGBA{0, 0, 0, 255}},
{3, 0, color.RGBA{0, 0, 0, 255}},
{3, 8, color.RGBA{0, 0, 0, 255}},
{3, 9, color.RGBA{0, 0, 0, 255}},
{4, 0, color.RGBA{0, 0, 0, 255}},
{4, 7, color.RGBA{0, 0, 0, 255}},
{4, 8, color.RGBA{0, 0, 0, 255}},
{5, 0, color.RGBA{0, 0, 0, 255}},
{5, 6, color.RGBA{0, 0, 0, 255}},
{5, 7, color.RGBA{0, 0, 0, 255}},
{5, 13, color.RGBA{0, 0, 0, 255}},
{6, 0, color.RGBA{0, 0, 0, 255}},
{6, 5, color.RGBA{0, 0, 0, 255}},
{6, 6, color.RGBA{0, 0, 0, 255}},
{6, 12, color.RGBA{0, 0, 0, 255}},
{6, 13, color.RGBA{0, 0, 0, 255}},
{7, 0, color.RGBA{0, 0, 0, 255}},
{7, 4, color.RGBA{0, 0, 0, 255}},
{7, 5, color.RGBA{0, 0, 0, 255}},
{7, 11, color.RGBA{0, 0, 0, 255}},
{7, 12, color.RGBA{0, 0, 0, 255}},
{8, 4, color.RGBA{0, 0, 0, 255}},
{8, 10, color.RGBA{0, 0, 0, 255}},
{8, 11, color.RGBA{0, 0, 0, 255}},
{9, 4, color.RGBA{0, 0, 0, 255}},
{9, 9, color.RGBA{0, 0, 0, 255}},
{9, 10, color.RGBA{0, 0, 0, 255}},
{10, 8, color.RGBA{0, 0, 0, 255}},
{10, 9, color.RGBA{0, 0, 0, 255}},
{11, 7, color.RGBA{0, 0, 0, 255}},
{11, 8, color.RGBA{0, 0, 0, 255}},
{12, 6, color.RGBA{0, 0, 0, 255}},
{12, 7, color.RGBA{0, 0, 0, 255}},
{1, 1, color.RGBA{255, 255, 255, 255}},
{1, 2, color.RGBA{255, 255, 255, 255}},
{1, 3, color.RGBA{255, 255, 255, 255}},
{1, 4, color.RGBA{255, 255, 255, 255}},
{1, 5, color.RGBA{255, 255, 255, 255}},
{1, 6, color.RGBA{255, 255, 255, 255}},
{1, 7, color.RGBA{255, 255, 255, 255}},
{1, 8, color.RGBA{255, 255, 255, 255}},
{1, 9, color.RGBA{255, 255, 255, 255}},
{2, 1, color.RGBA{255, 255, 255, 255}},
{2, 2, color.RGBA{255, 255, 255, 255}},
{2, 3, color.RGBA{255, 255, 255, 255}},
{2, 4, color.RGBA{255, 255, 255, 255}},
{2, 5, color.RGBA{255, 255, 255, 255}},
{2, 6, color.RGBA{255, 255, 255, 255}},
{2, 7, color.RGBA{255, 255, 255, 255}},
{2, 8, color.RGBA{255, 255, 255, 255}},
{3, 1, color.RGBA{255, 255, 255, 255}},
{3, 2, color.RGBA{255, 255, 255, 255}},
{3, 3, color.RGBA{255, 255, 255, 255}},
{3, 4, color.RGBA{255, 255, 255, 255}},
{3, 5, color.RGBA{255, 255, 255, 255}},
{3, 6, color.RGBA{255, 255, 255, 255}},
{3, 7, color.RGBA{255, 255, 255, 255}},
{4, 1, color.RGBA{255, 255, 255, 255}},
{4, 2, color.RGBA{255, 255, 255, 255}},
{4, 3, color.RGBA{255, 255, 255, 255}},
{4, 4, color.RGBA{255, 255, 255, 255}},
{4, 5, color.RGBA{255, 255, 255, 255}},
{4, 6, color.RGBA{255, 255, 255, 255}},
{5, 1, color.RGBA{255, 255, 255, 255}},
{5, 2, color.RGBA{255, 255, 255, 255}},
{5, 3, color.RGBA{255, 255, 255, 255}},
{5, 4, color.RGBA{255, 255, 255, 255}},
{5, 5, color.RGBA{255, 255, 255, 255}},
{5, 11, color.RGBA{255, 255, 255, 255}},
{5, 12, color.RGBA{255, 255, 255, 255}},
{6, 1, color.RGBA{255, 255, 255, 255}},
{6, 2, color.RGBA{255, 255, 255, 255}},
{6, 3, color.RGBA{255, 255, 255, 255}},
{6, 4, color.RGBA{255, 255, 255, 255}},
{6, 10, color.RGBA{255, 255, 255, 255}},
{6, 11, color.RGBA{255, 255, 255, 255}},
{7, 1, color.RGBA{255, 255, 255, 255}},
{7, 2, color.RGBA{255, 255, 255, 255}},
{7, 3, color.RGBA{255, 255, 255, 255}},
{7, 9, color.RGBA{255, 255, 255, 255}},
{7, 10, color.RGBA{255, 255, 255, 255}},
{8, 5, color.RGBA{255, 255, 255, 255}},
{8, 6, color.RGBA{255, 255, 255, 255}},
{8, 7, color.RGBA{255, 255, 255, 255}},
{8, 8, color.RGBA{255, 255, 255, 255}},
{8, 9, color.RGBA{255, 255, 255, 255}},
{9, 5, color.RGBA{255, 255, 255, 255}},
{9, 6, color.RGBA{255, 255, 255, 255}},
{9, 7, color.RGBA{255, 255, 255, 255}},
{9, 8, color.RGBA{255, 255, 255, 255}},
{10, 6, color.RGBA{255, 255, 255, 255}},
{10, 7, color.RGBA{255, 255, 255, 255}},
{11, 6, color.RGBA{255, 255, 255, 255}},
} }
shape, err := getCursorShape(ci.hCursor)
for _, p := range cursorPattern { if err != nil {
px := imgX + p.x return
py := imgY + p.y
if px >= 0 && px < img.Bounds().Dx() && py >= 0 && py < img.Bounds().Dy() {
img.SetRGBA(px, py, p.c)
} }
cursorImage, err := png.Decode(bytes.NewReader(shape.image))
if err != nil {
return
} }
x := int(cursorX) - bounds.Min.X - shape.hotspotX
y := int(cursorY) - bounds.Min.Y - shape.hotspotY
target := image.Rect(x, y, x+shape.width, y+shape.height)
draw.Draw(img, target, cursorImage, cursorImage.Bounds().Min, draw.Over)
} }
func DrawCursorOnDC(hdc uintptr, captureBounds image.Rectangle) bool { func DrawCursorOnDC(hdc uintptr, captureBounds image.Rectangle) bool {
@@ -203,14 +397,12 @@ func DrawCursorOnDCScaled(hdc uintptr, captureBounds image.Rectangle, scaleX, sc
if !cursorCaptureEnabled.Load() || hdc == 0 { if !cursorCaptureEnabled.Load() || hdc == 0 {
return false return false
} }
var ci cursorInfo ci, ok := queryCursor()
ci.cbSize = uint32(unsafe.Sizeof(ci)) if !ok || (ci.flags&CURSOR_SHOWING) == 0 || ci.hCursor == 0 {
ret, _, _ := procGetCursorInfo.Call(uintptr(unsafe.Pointer(&ci)))
if ret == 0 || (ci.flags&CURSOR_SHOWING) == 0 || ci.hCursor == 0 {
return false return false
} }
var icon iconInfo var icon iconInfo
ret, _, _ = procGetIconInfo.Call(ci.hCursor, uintptr(unsafe.Pointer(&icon))) ret, _, _ := procGetIconInfo.Call(ci.hCursor, uintptr(unsafe.Pointer(&icon)))
if ret == 0 { if ret == 0 {
return false return false
} }
@@ -221,7 +413,6 @@ func DrawCursorOnDCScaled(hdc uintptr, captureBounds image.Rectangle, scaleX, sc
defer deleteObject(icon.hbmColor) defer deleteObject(icon.hbmColor)
} }
// Map from screen space into the (possibly scaled) DC space.
x := int32(float64(ci.ptScreenPos.x-int32(captureBounds.Min.X))*scaleX) - int32(icon.xHotspot) x := int32(float64(ci.ptScreenPos.x-int32(captureBounds.Min.X))*scaleX) - int32(icon.xHotspot)
y := int32(float64(ci.ptScreenPos.y-int32(captureBounds.Min.Y))*scaleY) - int32(icon.yHotspot) y := int32(float64(ci.ptScreenPos.y-int32(captureBounds.Min.Y))*scaleY) - int32(icon.yHotspot)
ret, _, _ = procDrawIconEx.Call( ret, _, _ = procDrawIconEx.Call(
@@ -242,7 +433,6 @@ func DrawCursorOnImage(img *image.RGBA, captureBounds image.Rectangle) {
if !cursorCaptureEnabled.Load() { if !cursorCaptureEnabled.Load() {
return return
} }
x, y, visible := getCursorPosition() x, y, visible := getCursorPosition()
if visible { if visible {
drawCursor(img, x, y, captureBounds) drawCursor(img, x, y, captureBounds)
@@ -0,0 +1,100 @@
//go:build windows
package capture
import (
"bytes"
"image/png"
"testing"
)
func TestCursorMetadataDoesNotEnableFrameCompositing(t *testing.T) {
before := cursorGeneration.Load()
SetCursorCapture(true)
t.Cleanup(func() { SetCursorCapture(false) })
if !cursorMetadataEnabled.Load() {
t.Fatal("cursor metadata should be enabled")
}
if cursorCaptureEnabled.Load() {
t.Fatal("cursor frame compositing disables the direct capture path")
}
if got := cursorGeneration.Load(); got != before+1 {
t.Fatalf("cursor metadata generation = %d, want %d", got, before+1)
}
state := DesktopCursorState(0, 1, 1)
if !state.Enabled || state.Generation != before+1 {
t.Fatalf("cursor state enabled=%v generation=%d, want enabled generation %d", state.Enabled, state.Generation, before+1)
}
SetCursorCapture(true)
if got := cursorGeneration.Load(); got != before+2 {
t.Fatalf("refresh generation = %d, want %d", got, before+2)
}
if cursorCaptureEnabled.Load() {
t.Fatal("metadata refresh enabled frame compositing")
}
}
func TestScaleCursorMetricFrom4KTo1080p(t *testing.T) {
if got := scaleCursorMetric(64, 1920, 3840); got != 32 {
t.Fatalf("scaled cursor width = %d, want 32", got)
}
if got := scaleCursorMetric(64, 1080, 2160); got != 32 {
t.Fatalf("scaled cursor height = %d, want 32", got)
}
if got := scaleCursorMetric(1, 1920, 3840); got != 1 {
t.Fatalf("non-zero cursor metric collapsed to %d", got)
}
}
func TestReconstructCursorRGBA(t *testing.T) {
black := []byte{
0, 0, 0, 255,
0, 0, 255, 255,
255, 255, 255, 255,
}
white := []byte{
255, 255, 255, 255,
0, 0, 255, 255,
0, 0, 0, 255,
}
img := reconstructCursorRGBA(black, white, 3, 1)
if got := img.NRGBAAt(0, 0); got.A != 0 {
t.Fatalf("transparent pixel alpha = %d, want 0", got.A)
}
if got := img.NRGBAAt(1, 0); got.R != 255 || got.G != 0 || got.B != 0 || got.A != 255 {
t.Fatalf("opaque color pixel = %#v, want opaque red", got)
}
if got := img.NRGBAAt(2, 0); got.R != 255 || got.G != 255 || got.B != 255 || got.A != 255 {
t.Fatalf("inverting monochrome pixel fallback = %#v, want opaque white", got)
}
}
func TestExtractSystemCursorShape(t *testing.T) {
const idcArrow = 32512
loadCursorW := user32.NewProc("LoadCursorW")
hCursor, _, callErr := loadCursorW.Call(0, idcArrow)
if hCursor == 0 {
t.Fatalf("LoadCursorW(IDC_ARROW): %v", callErr)
}
shape, err := extractCursorShape(hCursor)
if err != nil {
t.Fatalf("extract system arrow: %v", err)
}
if shape.width <= 0 || shape.height <= 0 {
t.Fatalf("invalid extracted dimensions %dx%d", shape.width, shape.height)
}
if shape.hotspotX < 0 || shape.hotspotX >= shape.width || shape.hotspotY < 0 || shape.hotspotY >= shape.height {
t.Fatalf("hotspot (%d,%d) outside %dx%d cursor", shape.hotspotX, shape.hotspotY, shape.width, shape.height)
}
config, err := png.DecodeConfig(bytes.NewReader(shape.image))
if err != nil {
t.Fatalf("decode extracted PNG: %v", err)
}
if config.Width != shape.width || config.Height != shape.height {
t.Fatalf("PNG dimensions %dx%d, metadata %dx%d", config.Width, config.Height, shape.width, shape.height)
}
}
@@ -130,6 +130,20 @@ type Frame struct {
Data []byte `msgpack:"data"` Data []byte `msgpack:"data"`
} }
type DesktopCursor struct {
Type string `msgpack:"type"`
X int `msgpack:"x"`
Y int `msgpack:"y"`
Width int `msgpack:"width"`
Height int `msgpack:"height"`
Visible bool `msgpack:"visible"`
CursorWidth int `msgpack:"cursorWidth,omitempty"`
CursorHeight int `msgpack:"cursorHeight,omitempty"`
HotspotX int `msgpack:"hotspotX,omitempty"`
HotspotY int `msgpack:"hotspotY,omitempty"`
Image []byte `msgpack:"image,omitempty"`
}
type DesktopStreamStats struct { type DesktopStreamStats struct {
Type string `msgpack:"type"` Type string `msgpack:"type"`
FPS int `msgpack:"fps"` FPS int `msgpack:"fps"`
+98 -4
View File
@@ -63,6 +63,7 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s
const mouseCtrl = document.getElementById("mouseCtrl"); const mouseCtrl = document.getElementById("mouseCtrl");
const kbdCtrl = document.getElementById("kbdCtrl"); const kbdCtrl = document.getElementById("kbdCtrl");
const cursorCtrl = document.getElementById("cursorCtrl"); const cursorCtrl = document.getElementById("cursorCtrl");
const hideLocalCursorCtrl = document.getElementById("hideLocalCursorCtrl");
const duplicationCtrl = document.getElementById("duplicationCtrl"); const duplicationCtrl = document.getElementById("duplicationCtrl");
const streamProfileSelect = document.getElementById("streamProfileSelect"); const streamProfileSelect = document.getElementById("streamProfileSelect");
const streamProfileDetail = document.getElementById("streamProfileDetail"); const streamProfileDetail = document.getElementById("streamProfileDetail");
@@ -76,6 +77,9 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s
const codecMode = document.getElementById("codecMode"); const codecMode = document.getElementById("codecMode");
const canvas = document.getElementById("frameCanvas"); const canvas = document.getElementById("frameCanvas");
const canvasContainer = document.getElementById("canvasContainer"); const canvasContainer = document.getElementById("canvasContainer");
const renderSurface = document.getElementById("renderSurface");
const remoteCursor = document.getElementById("remoteCursor");
const remoteCursorImage = document.getElementById("remoteCursorImage");
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
const agentFps = document.getElementById("agentFps"); const agentFps = document.getElementById("agentFps");
const viewerFps = document.getElementById("viewerFps"); const viewerFps = document.getElementById("viewerFps");
@@ -97,6 +101,9 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s
let whepClient = null; let whepClient = null;
let p2pClient = null; let p2pClient = null;
let webrtcActive = false; let webrtcActive = false;
let lastRemoteCursor = null;
let remoteCursorShape = null;
let remoteCursorImageUrl = "";
function getWebrtcMode() { function getWebrtcMode() {
return webrtcMode ? String(webrtcMode.value || "off") : "off"; return webrtcMode ? String(webrtcMode.value || "off") : "off";
} }
@@ -462,6 +469,7 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s
if (mouseCtrl && typeof settings.mouse === "boolean") mouseCtrl.checked = settings.mouse; if (mouseCtrl && typeof settings.mouse === "boolean") mouseCtrl.checked = settings.mouse;
if (kbdCtrl && typeof settings.keyboard === "boolean") kbdCtrl.checked = settings.keyboard; if (kbdCtrl && typeof settings.keyboard === "boolean") kbdCtrl.checked = settings.keyboard;
if (cursorCtrl && typeof settings.cursor === "boolean") cursorCtrl.checked = settings.cursor; if (cursorCtrl && typeof settings.cursor === "boolean") cursorCtrl.checked = settings.cursor;
if (hideLocalCursorCtrl && typeof settings.hideLocalCursor === "boolean") hideLocalCursorCtrl.checked = settings.hideLocalCursor;
if (duplicationCtrl && typeof settings.duplication === "boolean") duplicationCtrl.checked = settings.duplication; if (duplicationCtrl && typeof settings.duplication === "boolean") duplicationCtrl.checked = settings.duplication;
if (softwareH264Ctrl && typeof settings.softwareH264 === "boolean") softwareH264Ctrl.checked = settings.softwareH264; if (softwareH264Ctrl && typeof settings.softwareH264 === "boolean") softwareH264Ctrl.checked = settings.softwareH264;
if (clipboardSyncCtrl && typeof settings.clipboardSync === "boolean") clipboardSyncCtrl.checked = settings.clipboardSync; if (clipboardSyncCtrl && typeof settings.clipboardSync === "boolean") clipboardSyncCtrl.checked = settings.clipboardSync;
@@ -488,6 +496,7 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s
mouse: !!mouseCtrl?.checked, mouse: !!mouseCtrl?.checked,
keyboard: !!kbdCtrl?.checked, keyboard: !!kbdCtrl?.checked,
cursor: !!cursorCtrl?.checked, cursor: !!cursorCtrl?.checked,
hideLocalCursor: !!hideLocalCursorCtrl?.checked,
duplication: !!duplicationCtrl?.checked, duplication: !!duplicationCtrl?.checked,
softwareH264: !!softwareH264Ctrl?.checked, softwareH264: !!softwareH264Ctrl?.checked,
clipboardSync: !!clipboardSyncCtrl?.checked, clipboardSync: !!clipboardSyncCtrl?.checked,
@@ -540,6 +549,10 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s
function setStreamState(state, text) { function setStreamState(state, text) {
streamState = state; streamState = state;
if (state !== "streaming" && state !== "stalled") {
lastRemoteCursor = null;
renderRemoteCursor();
}
if (statusEl) { if (statusEl) {
const icons = { const icons = {
connecting: '<i class="fa-solid fa-circle-notch fa-spin"></i>', connecting: '<i class="fa-solid fa-circle-notch fa-spin"></i>',
@@ -1259,6 +1272,7 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s
webrtcActive = !!active; webrtcActive = !!active;
if (canvas) canvas.style.display = active ? "none" : "block"; if (canvas) canvas.style.display = active ? "none" : "block";
if (webrtcVideo) webrtcVideo.style.display = active ? "block" : "none"; if (webrtcVideo) webrtcVideo.style.display = active ? "block" : "none";
renderRemoteCursor();
if (!active && networkStats) { if (!active && networkStats) {
networkStats.textContent = "--"; networkStats.textContent = "--";
networkStats.title = ""; networkStats.title = "";
@@ -1659,10 +1673,20 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s
} }
streamProfileSelect.appendChild(option); streamProfileSelect.appendChild(option);
} }
if (streamProfileSelect.options.length === 1) { const manualHighResolutionProfiles = [
{ maxHeight: 1440, fps: 30, label: "30 FPS - 1440p" },
{ maxHeight: 1440, fps: 60, label: "60 FPS - 1440p" },
{ maxHeight: 2160, fps: 30, label: "30 FPS - 2160p (4K)" },
{ maxHeight: 2160, fps: 60, label: "60 FPS - 2160p (4K)" },
];
const existingProfiles = new Set(Array.from(streamProfileSelect.options, (option) => option.value));
for (const profile of manualHighResolutionProfiles) {
const value = `${profile.maxHeight}:${profile.fps}`;
if (existingProfiles.has(value)) continue;
const option = document.createElement("option"); const option = document.createElement("option");
option.value = "1080:60"; option.value = value;
option.textContent = "60 FPS - 1080p"; option.textContent = profile.label;
option.title = "Manual high-resolution profile";
streamProfileSelect.appendChild(option); streamProfileSelect.appendChild(option);
} }
if (!setSelectValue(streamProfileSelect, savedStreamProfile || "auto") && if (!setSelectValue(streamProfileSelect, savedStreamProfile || "auto") &&
@@ -1817,14 +1841,69 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s
} }
} }
function renderRemoteCursor(message = lastRemoteCursor) {
lastRemoteCursor = message;
const image = message?.image;
if (remoteCursorImage && image instanceof Uint8Array && image.byteLength > 0) {
if (remoteCursorImageUrl) URL.revokeObjectURL(remoteCursorImageUrl);
remoteCursorImageUrl = URL.createObjectURL(new Blob([image], { type: "image/png" }));
remoteCursorImage.src = remoteCursorImageUrl;
remoteCursorShape = {
width: Math.max(1, Number(message.cursorWidth) || 1),
height: Math.max(1, Number(message.cursorHeight) || 1),
hotspotX: Math.max(0, Number(message.hotspotX) || 0),
hotspotY: Math.max(0, Number(message.hotspotY) || 0),
};
}
const hideLocalCursor = !!hideLocalCursorCtrl?.checked;
const showRemoteCursor = !!cursorCtrl?.checked && !!message?.visible && !!remoteCursorShape &&
(!mouseCtrl?.checked || hideLocalCursor);
const localCursor = hideLocalCursor ? "none" : "default";
canvas.style.cursor = localCursor;
if (webrtcVideo) webrtcVideo.style.cursor = localCursor;
if (!remoteCursor || !renderSurface || !showRemoteCursor) {
if (remoteCursor) remoteCursor.hidden = true;
return;
}
const surface = webrtcActive ? webrtcVideo : canvas;
const sourceWidth = Number(message.width) || (surface === webrtcVideo ? webrtcVideo.videoWidth : canvas.width);
const sourceHeight = Number(message.height) || (surface === webrtcVideo ? webrtcVideo.videoHeight : canvas.height);
if (!sourceWidth || !sourceHeight) {
remoteCursor.hidden = true;
return;
}
const surfaceRect = surface.getBoundingClientRect();
const containerRect = renderSurface.getBoundingClientRect();
const scaleX = surfaceRect.width / sourceWidth;
const scaleY = surfaceRect.height / sourceHeight;
const x = surfaceRect.left - containerRect.left + (Number(message.x) || 0) * scaleX -
remoteCursorShape.hotspotX * scaleX;
const y = surfaceRect.top - containerRect.top + (Number(message.y) || 0) * scaleY -
remoteCursorShape.hotspotY * scaleY;
remoteCursor.style.width = `${remoteCursorShape.width * scaleX}px`;
remoteCursor.style.height = `${remoteCursorShape.height * scaleY}px`;
remoteCursor.style.transform = `translate3d(${x}px, ${y}px, 0)`;
remoteCursor.hidden = false;
}
function updateCursorPresentation() {
renderRemoteCursor();
}
function pushCaptureToggles() { function pushCaptureToggles() {
if (cursorCtrl) { if (cursorCtrl) {
sendCmd("desktop_enable_cursor", { enabled: cursorCtrl.checked }); updateCursorPresentation();
sendCmd("desktop_enable_cursor", { enabled: !!cursorCtrl.checked });
} }
if (duplicationCtrl && !duplicationCtrl.disabled) { if (duplicationCtrl && !duplicationCtrl.disabled) {
sendCmd("desktop_set_duplication", { enabled: !!duplicationCtrl.checked }); sendCmd("desktop_set_duplication", { enabled: !!duplicationCtrl.checked });
} }
} }
updateCursorPresentation();
if (renderSurface && typeof ResizeObserver === "function") {
new ResizeObserver(() => renderRemoteCursor()).observe(renderSurface);
}
function pushPrivacyToggle() { function pushPrivacyToggle() {
if (!privacyCtrl) return; if (!privacyCtrl) return;
@@ -1844,6 +1923,7 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s
mouseCtrl.addEventListener("change", function () { mouseCtrl.addEventListener("change", function () {
pushInputToggles(); pushInputToggles();
updateCursorPresentation();
sharedSettingsSaver.scheduleSave(); sharedSettingsSaver.scheduleSave();
}); });
canvasContainer.setAttribute("tabindex", "0"); canvasContainer.setAttribute("tabindex", "0");
@@ -1876,6 +1956,12 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s
pushCaptureToggles(); pushCaptureToggles();
sharedSettingsSaver.scheduleSave(); sharedSettingsSaver.scheduleSave();
}); });
if (hideLocalCursorCtrl) {
hideLocalCursorCtrl.addEventListener("change", function () {
updateCursorPresentation();
sharedSettingsSaver.scheduleSave();
});
}
if (duplicationCtrl) { if (duplicationCtrl) {
duplicationCtrl.addEventListener("change", function () { duplicationCtrl.addEventListener("change", function () {
pushCaptureToggles(); pushCaptureToggles();
@@ -2483,6 +2569,10 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s
} }
const msg = decodeMsgpack(buf); const msg = decodeMsgpack(buf);
if (msg && msg.type === "desktop_cursor") {
renderRemoteCursor(msg);
return;
}
if (msg && msg.type === "desktop_encoder_capabilities") { if (msg && msg.type === "desktop_encoder_capabilities") {
applyEncoderCapabilities(msg); applyEncoderCapabilities(msg);
return; return;
@@ -2526,6 +2616,10 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s
} }
const msg = decodeMsgpack(ev.data); const msg = decodeMsgpack(ev.data);
if (msg && msg.type === "desktop_cursor") {
renderRemoteCursor(msg);
return;
}
if (msg && msg.type === "desktop_encoder_capabilities") { if (msg && msg.type === "desktop_encoder_capabilities") {
applyEncoderCapabilities(msg); applyEncoderCapabilities(msg);
return; return;
+12 -1
View File
@@ -187,6 +187,10 @@
<option value="720:60">60 FPS - 720p</option> <option value="720:60">60 FPS - 720p</option>
<option value="1080:30">30 FPS - 1080p</option> <option value="1080:30">30 FPS - 1080p</option>
<option value="1080:60">60 FPS - 1080p</option> <option value="1080:60">60 FPS - 1080p</option>
<option value="1440:30">30 FPS - 1440p</option>
<option value="1440:60">60 FPS - 1440p</option>
<option value="2160:30">30 FPS - 2160p (4K)</option>
<option value="2160:60">60 FPS - 2160p (4K)</option>
</select> </select>
</div> </div>
<div id="streamProfileDetail" class="mb-2 text-xs text-slate-500">Checking hardware encoder profiles…</div> <div id="streamProfileDetail" class="mb-2 text-xs text-slate-500">Checking hardware encoder profiles…</div>
@@ -244,9 +248,12 @@
<label class="flex items-center gap-2 text-sm text-slate-200"> <label class="flex items-center gap-2 text-sm text-slate-200">
<input type="checkbox" id="kbdCtrl" /> Keyboard <input type="checkbox" id="kbdCtrl" /> Keyboard
</label> </label>
<label class="flex items-center gap-2 text-sm text-slate-200" title="Show mouse cursor in captured frames"> <label class="flex items-center gap-2 text-sm text-slate-200" title="Show the remote cursor without compositing it into captured frames">
<input type="checkbox" id="cursorCtrl" /> Cursor <input type="checkbox" id="cursorCtrl" /> Cursor
</label> </label>
<label class="flex items-center gap-2 text-sm text-slate-200" title="Hide your browser cursor over the remote desktop">
<input type="checkbox" id="hideLocalCursorCtrl" /> Hide Local Cursor
</label>
<label class="flex items-center gap-2 text-sm text-slate-200 col-span-2" title="Sync clipboard between your browser and the remote machine"> <label class="flex items-center gap-2 text-sm text-slate-200 col-span-2" title="Sync clipboard between your browser and the remote machine">
<input type="checkbox" id="clipboardSyncCtrl" /> Clipboard Sync <input type="checkbox" id="clipboardSyncCtrl" /> Clipboard Sync
</label> </label>
@@ -274,6 +281,7 @@
Client: <span id="clientLabel" class="text-slate-100">-</span> Client: <span id="clientLabel" class="text-slate-100">-</span>
</div> </div>
<div <div
id="renderSurface"
class="relative flex-1 flex items-center justify-center p-4" class="relative flex-1 flex items-center justify-center p-4"
style="min-height: 0; overflow: hidden" style="min-height: 0; overflow: hidden"
> >
@@ -304,6 +312,9 @@
height: auto; height: auto;
" "
></video> ></video>
<div id="remoteCursor" hidden aria-hidden="true" class="absolute pointer-events-none" style="left: 0; top: 0; z-index: 30; will-change: transform">
<img id="remoteCursorImage" alt="" draggable="false" style="display: block; width: 100%; height: 100%" />
</div>
<section id="diagnosticsHud" class="rd-diagnostics-hud hidden" aria-label="Streaming diagnostics"> <section id="diagnosticsHud" class="rd-diagnostics-hud hidden" aria-label="Streaming diagnostics">
<div class="rd-diagnostics-header"> <div class="rd-diagnostics-header">
<div> <div>
+2
View File
@@ -93,6 +93,7 @@ import {
handleConsoleOutput, handleConsoleOutput,
handleDesktopEncoderCapabilities, handleDesktopEncoderCapabilities,
handleDesktopStreamStats, handleDesktopStreamStats,
handleDesktopCursor,
handleConsoleViewerMessage, handleConsoleViewerMessage,
handleConsoleViewerOpen, handleConsoleViewerOpen,
handlebackstageViewerMessage, handlebackstageViewerMessage,
@@ -658,6 +659,7 @@ async function startServer() {
handleConsoleOutput: (clientId: string, payload: any) => handleConsoleOutput(clientId, payload), handleConsoleOutput: (clientId: string, payload: any) => handleConsoleOutput(clientId, payload),
handleDesktopEncoderCapabilities: (clientId: string, payload: any) => handleDesktopEncoderCapabilities(clientId, payload), handleDesktopEncoderCapabilities: (clientId: string, payload: any) => handleDesktopEncoderCapabilities(clientId, payload),
handleDesktopStreamStats: (clientId: string, payload: any) => handleDesktopStreamStats(clientId, payload), handleDesktopStreamStats: (clientId: string, payload: any) => handleDesktopStreamStats(clientId, payload),
handleDesktopCursor: (clientId: string, payload: unknown) => handleDesktopCursor(clientId, payload),
handleFileBrowserMessage: (clientId: string, payload: any) => handleFileBrowserMessage: (clientId: string, payload: any) =>
forwardFileBrowserMessage(clientId, payload, { forwardFileBrowserMessage(clientId, payload, {
pendingHttpDownloads, pendingHttpDownloads,
+16
View File
@@ -9,6 +9,7 @@ export type MessageKind =
| "command_result" | "command_result"
| "desktop_encoder_capabilities" | "desktop_encoder_capabilities"
| "desktop_stream_stats" | "desktop_stream_stats"
| "desktop_cursor"
| "client_logs_result" | "client_logs_result"
| "screenshot_result" | "screenshot_result"
| "frame" | "frame"
@@ -224,6 +225,20 @@ export type FrameHeader = {
export type Frame = { type: "frame"; header: FrameHeader; data: Uint8Array }; export type Frame = { type: "frame"; header: FrameHeader; data: Uint8Array };
export type FrameAck = { type: "frame_ack" }; export type FrameAck = { type: "frame_ack" };
export type DesktopCursor = {
type: "desktop_cursor";
x: number;
y: number;
width: number;
height: number;
visible: boolean;
cursorWidth?: number;
cursorHeight?: number;
hotspotX?: number;
hotspotY?: number;
image?: Uint8Array;
};
export type DesktopStreamStats = { export type DesktopStreamStats = {
type: "desktop_stream_stats"; type: "desktop_stream_stats";
fps: number; fps: number;
@@ -547,6 +562,7 @@ export type WireMessage =
| Frame | Frame
| FrameAck | FrameAck
| DesktopStreamStats | DesktopStreamStats
| DesktopCursor
| Status | Status
| ConsoleOutput | ConsoleOutput
| FileListResult | FileListResult
@@ -229,6 +229,7 @@ type WsLifecycleDeps = {
handleConsoleOutput: (clientId: string, payload: any) => void; handleConsoleOutput: (clientId: string, payload: any) => void;
handleDesktopEncoderCapabilities: (clientId: string, payload: any) => void; handleDesktopEncoderCapabilities: (clientId: string, payload: any) => void;
handleDesktopStreamStats: (clientId: string, payload: any) => void; handleDesktopStreamStats: (clientId: string, payload: any) => void;
handleDesktopCursor: (clientId: string, payload: unknown) => void;
handleFileBrowserMessage: (clientId: string, payload: any) => void; handleFileBrowserMessage: (clientId: string, payload: any) => void;
handleProxyTunnelData: (clientId: string, connectionId: string, data: Uint8Array) => void; handleProxyTunnelData: (clientId: string, connectionId: string, data: Uint8Array) => void;
handleProxyTunnelClose: (clientId: string, connectionId: string) => void; handleProxyTunnelClose: (clientId: string, connectionId: string) => void;
@@ -808,6 +809,9 @@ export async function handleWebSocketMessage(
case "desktop_encoder_capabilities": case "desktop_encoder_capabilities":
deps.handleDesktopEncoderCapabilities(client.id, payload); deps.handleDesktopEncoderCapabilities(client.id, payload);
break; break;
case "desktop_cursor":
deps.handleDesktopCursor(client.id, payload);
break;
case "desktop_stream_stats": case "desktop_stream_stats":
deps.handleDesktopStreamStats(client.id, payload); deps.handleDesktopStreamStats(client.id, payload);
break; break;
@@ -10,6 +10,7 @@ import {
handleRemoteDesktopViewerMessage, handleRemoteDesktopViewerMessage,
handleRemoteDesktopViewerOpen, handleRemoteDesktopViewerOpen,
handleDesktopStreamStats, handleDesktopStreamStats,
handleDesktopCursor,
handleDesktopEncoderCapabilities, handleDesktopEncoderCapabilities,
backstageStreamingState, backstageStreamingState,
rdStreamingState, rdStreamingState,
@@ -218,6 +219,41 @@ describe("remote desktop viewer control", () => {
expect(message.transport).toBe("webrtc"); expect(message.transport).toBe("webrtc");
}); });
test("forwards cursor metadata without modifying video frames", () => {
const clientId = `rd-cursor-${Date.now().toString(36)}`;
createClient(clientId);
const viewer = createMockWs({ clientId });
handleRemoteDesktopViewerOpen(viewer as unknown as Parameters<typeof handleRemoteDesktopViewerOpen>[0]);
handleDesktopCursor(clientId, {
type: "desktop_cursor",
x: 640,
y: 360,
width: 1280,
height: 720,
visible: true,
cursorWidth: 32,
cursorHeight: 32,
hotspotX: 3,
hotspotY: 4,
image: new Uint8Array([137, 80, 78, 71]),
});
expect(decodeMessage(viewer.sent.at(-1) as Uint8Array)).toEqual({
type: "desktop_cursor",
x: 640,
y: 360,
width: 1280,
height: 720,
visible: true,
cursorWidth: 32,
cursorHeight: 32,
hotspotX: 3,
hotspotY: 4,
image: new Uint8Array([137, 80, 78, 71]),
});
});
test("selects one mutually compatible codec across all viewer transports", () => { test("selects one mutually compatible codec across all viewer transports", () => {
const clientId = `rd-codecs-${Date.now().toString(36)}`; const clientId = `rd-codecs-${Date.now().toString(36)}`;
createClient(clientId); createClient(clientId);
@@ -1568,6 +1568,33 @@ function releaseCanvasFrameAck(clientId: string, sessionId?: string): boolean {
} }
} }
export function handleDesktopCursor(clientId: string, payload: unknown) {
const data = payload && typeof payload === "object" ? payload as Record<string, unknown> : {};
const image = data.image instanceof Uint8Array && data.image.byteLength <= 256 * 1024
? data.image
: undefined;
const cursor = {
type: "desktop_cursor",
x: Math.max(0, Math.floor(Number(data.x) || 0)),
y: Math.max(0, Math.floor(Number(data.y) || 0)),
width: Math.max(0, Math.floor(Number(data.width) || 0)),
height: Math.max(0, Math.floor(Number(data.height) || 0)),
visible: data.visible === true,
...(image
? {
cursorWidth: Math.max(1, Math.floor(Number(data.cursorWidth) || 1)),
cursorHeight: Math.max(1, Math.floor(Number(data.cursorHeight) || 1)),
hotspotX: Math.max(0, Math.floor(Number(data.hotspotX) || 0)),
hotspotY: Math.max(0, Math.floor(Number(data.hotspotY) || 0)),
image,
}
: {}),
};
for (const session of sessionManager.getRdSessionsForClient(clientId)) {
safeSendViewer(session.viewer, cursor, "rd-cursor");
}
}
export function handleDesktopStreamStats(clientId: string, payload: any) { export function handleDesktopStreamStats(clientId: string, payload: any) {
const stats = { const stats = {
type: "desktop_stream_stats", type: "desktop_stream_stats",
+1
View File
@@ -11,6 +11,7 @@ describe("wsValidation", () => {
expect(ALLOWED_CLIENT_MESSAGE_TYPES.has("hello")).toBe(true); expect(ALLOWED_CLIENT_MESSAGE_TYPES.has("hello")).toBe(true);
expect(ALLOWED_CLIENT_MESSAGE_TYPES.has("ping")).toBe(true); expect(ALLOWED_CLIENT_MESSAGE_TYPES.has("ping")).toBe(true);
expect(ALLOWED_CLIENT_MESSAGE_TYPES.has("frame")).toBe(true); expect(ALLOWED_CLIENT_MESSAGE_TYPES.has("frame")).toBe(true);
expect(ALLOWED_CLIENT_MESSAGE_TYPES.has("desktop_cursor")).toBe(true);
}); });
test("unknown client message types are rejected", () => { test("unknown client message types are rejected", () => {
+1
View File
@@ -22,6 +22,7 @@ export const ALLOWED_CLIENT_MESSAGE_TYPES = new Set([
"command_result", "command_result",
"desktop_encoder_capabilities", "desktop_encoder_capabilities",
"desktop_stream_stats", "desktop_stream_stats",
"desktop_cursor",
"client_logs_result", "client_logs_result",
"screenshot_result", "screenshot_result",
"command_progress", "command_progress",
@@ -22,6 +22,7 @@ test("WebRTC input stays inactive until start and then forwards mouse and keyboa
constructor(_url: string | URL) { constructor(_url: string | URL) {
super(); super();
Object.defineProperty(window, "__rdSocket", { value: this, configurable: true });
queueMicrotask(() => this.dispatchEvent(new Event("open"))); queueMicrotask(() => this.dispatchEvent(new Event("open")));
} }
@@ -43,16 +44,39 @@ test("WebRTC input stays inactive until start and then forwards mouse and keyboa
await page.goto("/remotedesktop?clientId=webrtc-input-test"); await page.goto("/remotedesktop?clientId=webrtc-input-test");
await expect(page.locator("#canvasContainer")).toHaveAttribute("tabindex", "0"); await expect(page.locator("#canvasContainer")).toHaveAttribute("tabindex", "0");
await page.evaluate(async () => {
const { encodeMsgpack } = await import("/assets/msgpack-helpers.js");
const encoded = encodeMsgpack({
type: "desktop_encoder_capabilities",
display: 0,
profiles: [
{ maxHeight: 720, width: 1280, height: 720, fps: 60, label: "60 FPS - 720p" },
{ maxHeight: 1080, width: 1920, height: 1080, fps: 60, label: "60 FPS - 1080p" },
],
codecs: [{ codec: "h264", transports: ["websocket", "webrtc"] }],
selectedCodec: "h264",
fallbackCodecs: ["h264"],
transport: "websocket",
});
const data = encoded.buffer.slice(encoded.byteOffset, encoded.byteOffset + encoded.byteLength);
const socket = (window as typeof window & { __rdSocket: EventTarget }).__rdSocket;
socket.dispatchEvent(new MessageEvent("message", { data }));
});
await expect(page.locator("#streamProfileSelect option[value='1440:60']")).toHaveCount(1);
await expect(page.locator("#streamProfileSelect option[value='2160:60']")).toHaveCount(1);
await page.evaluate(() => { await page.evaluate(() => {
const sent = (window as typeof window & { __rdSent: ArrayBuffer[] }).__rdSent;
sent.length = 0;
const mouseCtrl = document.querySelector<HTMLInputElement>("#mouseCtrl")!; const mouseCtrl = document.querySelector<HTMLInputElement>("#mouseCtrl")!;
const kbdCtrl = document.querySelector<HTMLInputElement>("#kbdCtrl")!; const kbdCtrl = document.querySelector<HTMLInputElement>("#kbdCtrl")!;
const cursorCtrl = document.querySelector<HTMLInputElement>("#cursorCtrl")!;
mouseCtrl.checked = true; mouseCtrl.checked = true;
mouseCtrl.dispatchEvent(new Event("change")); mouseCtrl.dispatchEvent(new Event("change"));
kbdCtrl.checked = true; kbdCtrl.checked = true;
kbdCtrl.dispatchEvent(new Event("change")); kbdCtrl.dispatchEvent(new Event("change"));
const sent = (window as typeof window & { __rdSent: ArrayBuffer[] }).__rdSent; cursorCtrl.checked = true;
sent.length = 0; cursorCtrl.dispatchEvent(new Event("change"));
const canvas = document.querySelector<HTMLCanvasElement>("#frameCanvas")!; const canvas = document.querySelector<HTMLCanvasElement>("#frameCanvas")!;
const video = document.querySelector<HTMLVideoElement>("#webrtcVideo")!; const video = document.querySelector<HTMLVideoElement>("#webrtcVideo")!;
canvas.style.display = "none"; canvas.style.display = "none";
@@ -70,11 +94,78 @@ test("WebRTC input stays inactive until start and then forwards mouse and keyboa
height: 540, height: 540,
toJSON() { return this; }, toJSON() { return this; },
}); });
const renderSurface = document.querySelector<HTMLElement>("#renderSurface")!;
renderSurface.getBoundingClientRect = video.getBoundingClientRect;
canvas.getBoundingClientRect = video.getBoundingClientRect;
video.dispatchEvent(new MouseEvent("mousedown", { clientX: 480, clientY: 270, button: 0, bubbles: true })); video.dispatchEvent(new MouseEvent("mousedown", { clientX: 480, clientY: 270, button: 0, bubbles: true }));
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", code: "Enter", bubbles: true })); window.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", code: "Enter", bubbles: true }));
}); });
await page.evaluate(async () => {
const { encodeMsgpack } = await import("/assets/msgpack-helpers.js");
const encoded = encodeMsgpack({
type: "desktop_cursor",
x: 960,
y: 540,
width: 1920,
height: 1080,
visible: true,
cursorWidth: 32,
cursorHeight: 32,
hotspotX: 4,
hotspotY: 6,
image: Uint8Array.from(atob("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="), (char) => char.charCodeAt(0)),
});
const data = encoded.buffer.slice(encoded.byteOffset, encoded.byteOffset + encoded.byteLength);
const socket = (window as typeof window & { __rdSocket: EventTarget }).__rdSocket;
socket.dispatchEvent(new MessageEvent("message", { data }));
});
await expect(page.locator("#frameCanvas")).toHaveCSS("cursor", "default");
await expect(page.locator("#webrtcVideo")).toHaveCSS("cursor", "default");
await expect(page.locator("#remoteCursor")).toBeHidden();
await page.evaluate(() => {
const mouseCtrl = document.querySelector<HTMLInputElement>("#mouseCtrl")!;
mouseCtrl.checked = false;
mouseCtrl.dispatchEvent(new Event("change"));
});
await expect(page.locator("#remoteCursor")).toBeVisible();
await expect(page.locator("#remoteCursor")).toHaveCSS("transform", "matrix(1, 0, 0, 1, 478, 267)");
await expect(page.locator("#frameCanvas")).toHaveCSS("cursor", "default");
await expect(page.locator("#remoteCursor")).toHaveCSS("width", "16px");
await expect(page.locator("#remoteCursor")).toHaveCSS("height", "16px");
await page.evaluate(() => {
const mouseCtrl = document.querySelector<HTMLInputElement>("#mouseCtrl")!;
const hideLocalCursorCtrl = document.querySelector<HTMLInputElement>("#hideLocalCursorCtrl")!;
mouseCtrl.checked = true;
mouseCtrl.dispatchEvent(new Event("change"));
hideLocalCursorCtrl.checked = true;
hideLocalCursorCtrl.dispatchEvent(new Event("change"));
});
await expect(page.locator("#frameCanvas")).toHaveCSS("cursor", "none");
await expect(page.locator("#webrtcVideo")).toHaveCSS("cursor", "none");
await expect(page.locator("#remoteCursor")).toBeVisible();
await page.evaluate(() => {
const hideLocalCursorCtrl = document.querySelector<HTMLInputElement>("#hideLocalCursorCtrl")!;
hideLocalCursorCtrl.checked = false;
hideLocalCursorCtrl.dispatchEvent(new Event("change"));
});
await expect(page.locator("#frameCanvas")).toHaveCSS("cursor", "default");
await expect(page.locator("#remoteCursor")).toBeHidden();
await expect.poll(async () => page.evaluate(async () => {
const sent = (window as typeof window & { __rdSent: ArrayBuffer[] }).__rdSent;
const { decodeMsgpack } = await import("/assets/msgpack-helpers.js");
return sent.map((message) => decodeMsgpack(message))
.filter((message) => message.type === "desktop_enable_cursor");
})).toContainEqual(expect.objectContaining({
type: "desktop_enable_cursor",
enabled: true,
}));
await expect.poll(async () => page.evaluate(async () => { await expect.poll(async () => page.evaluate(async () => {
const sent = (window as typeof window & { __rdSent: ArrayBuffer[] }).__rdSent; const sent = (window as typeof window & { __rdSent: ArrayBuffer[] }).__rdSent;
const { decodeMsgpack } = await import("/assets/msgpack-helpers.js"); const { decodeMsgpack } = await import("/assets/msgpack-helpers.js");