From 660a7e05d9a1311b0ed0745da5a33920665b21c5 Mon Sep 17 00:00:00 2001 From: OpenScreen User Date: Thu, 15 Oct 2026 19:20:29 +0800 Subject: [PATCH 1/2] fix(win): add a DXGI Desktop Duplication fallback for screen capture IGraphicsCaptureItemInterop::CreateForMonitor can be refused with E_ACCESSDENIED on a machine that reports GraphicsCaptureSession::IsSupported() == 1 and captures fine through every other route. When that happens the native helper cannot start a session at all and the app cannot record. CaptureSession tries WGC first and falls back to DXGI Desktop Duplication when initialize() fails. Both backends implement the same seven-method surface, so the encoder, MP4 muxer, audio, webcam, cursor sampler, JSON protocol and renderer are unchanged. main.cpp is +11/-6. Known limits, stated in the source: window capture has no fallback (Desktop Duplication duplicates an output, never a window), the system cursor is not composited, and setFrameCallback is inert on this backend. --- electron/native/wgc-capture/CMakeLists.txt | 3 + .../native/wgc-capture/src/capture_session.h | 166 +++++++++ .../src/dxgi_duplication_session.cpp | 330 ++++++++++++++++++ .../src/dxgi_duplication_session.h | 94 +++++ electron/native/wgc-capture/src/main.cpp | 17 +- 5 files changed, 604 insertions(+), 6 deletions(-) create mode 100644 electron/native/wgc-capture/src/capture_session.h create mode 100644 electron/native/wgc-capture/src/dxgi_duplication_session.cpp create mode 100644 electron/native/wgc-capture/src/dxgi_duplication_session.h diff --git a/electron/native/wgc-capture/CMakeLists.txt b/electron/native/wgc-capture/CMakeLists.txt index 7f0cbf02d5..3429cff3f8 100644 --- a/electron/native/wgc-capture/CMakeLists.txt +++ b/electron/native/wgc-capture/CMakeLists.txt @@ -38,6 +38,7 @@ set(CMAKE_CXX_EXTENSIONS OFF) add_executable(wgc-capture src/audio_sample_utils.cpp src/audio_sample_utils.h + src/capture_session.h src/desktop_icon_cover.cpp src/desktop_icon_cover.h src/dpi_awareness.h @@ -47,6 +48,8 @@ add_executable(wgc-capture src/frame_visibility.h src/dshow_webcam_capture.cpp src/dshow_webcam_capture.h + src/dxgi_duplication_session.cpp + src/dxgi_duplication_session.h src/main.cpp src/mf_encoder.cpp src/mf_encoder.h diff --git a/electron/native/wgc-capture/src/capture_session.h b/electron/native/wgc-capture/src/capture_session.h new file mode 100644 index 0000000000..467a3b3783 --- /dev/null +++ b/electron/native/wgc-capture/src/capture_session.h @@ -0,0 +1,166 @@ +#pragma once + +#include "dxgi_duplication_session.h" +#include "wgc_session.h" + +#include +#include +#include +#include +#include +#include + +// One frame source, two backends, chosen at initialize() time. +// +// main.cpp used to talk to WgcSession directly. It now talks to this instead, +// so the choice between WGC and DXGI Desktop Duplication lives in one place and +// every downstream stage -- the encoder, the MP4 muxer, audio, webcam, the +// cursor sampler, the JSON event protocol -- is unchanged and unaware. +// +// The fallback exists because CreateForMonitor can be refused with +// E_ACCESSDENIED on a machine that captures perfectly well otherwise; see +// dxgi_duplication_session.h for the measurements. WGC is still tried first +// everywhere, so a machine where it works keeps the backend it has always had. +class CaptureSession { +public: + using FrameCallback = WgcSession::FrameCallback; + + // OPENSCREEN_CAPTURE_BACKEND=dxgi force Desktop Duplication (diagnostics) + // =wgc force WGC, no fallback (diagnostics) + // unset try WGC, fall back to DXGI + static std::string requestedBackend() { + const char* raw = std::getenv("OPENSCREEN_CAPTURE_BACKEND"); + if (!raw) { + return {}; + } + std::string value(raw); + for (char& c : value) { + c = static_cast(std::tolower(static_cast(c))); + } + return value; + } + + bool initialize(HMONITOR monitor, int fps, bool captureCursor) { + const std::string requested = requestedBackend(); + if (requested == "dxgi") { + return startDxgi(monitor, fps, captureCursor, "requested"); + } + if (wgc_.initialize(monitor, fps, captureCursor)) { + return adoptWgc(); + } + wgcUnavailable_ = true; + if (requested == "wgc") { + return false; + } + wgc_.stop(); + return startDxgi(monitor, fps, captureCursor, "wgc-unavailable"); + } + + bool initialize(HWND window, int fps, bool captureCursor, bool includeSecondaryWindows = false) { + // Desktop Duplication duplicates an output, never a single window, so a + // window take has no fallback: it is WGC or it is a failure. + if (wgc_.initialize(window, fps, captureCursor, includeSecondaryWindows)) { + return adoptWgc(); + } + wgcUnavailable_ = true; + return false; + } + + bool start() { + switch (backend_) { + case Backend::Wgc: + return wgc_.start(); + case Backend::Dxgi: + return dxgi_.start(); + default: + return false; + } + } + + bool tryGetNextFrame(ID3D11Texture2D** outTexture, int64_t* outTimestampHns) { + switch (backend_) { + case Backend::Wgc: + return wgc_.tryGetNextFrame(outTexture, outTimestampHns); + case Backend::Dxgi: + return dxgi_.tryGetNextFrame(outTexture, outTimestampHns); + default: + return false; + } + } + + void setFrameCallback(FrameCallback callback) { + if (backend_ == Backend::Wgc) { + wgc_.setFrameCallback(std::move(callback)); + } + } + + bool quiesceLegacyCallback(int drainTimeoutMs = 5000) { + if (backend_ == Backend::Wgc) { + return wgc_.quiesceLegacyCallback(drainTimeoutMs); + } + return true; + } + + void stop() { + wgc_.stop(); + dxgi_.stop(); + backend_ = Backend::None; + } + + int captureWidth() const { + return backend_ == Backend::Dxgi ? dxgi_.captureWidth() : wgc_.captureWidth(); + } + + int captureHeight() const { + return backend_ == Backend::Dxgi ? dxgi_.captureHeight() : wgc_.captureHeight(); + } + + ID3D11Device* device() const { + return backend_ == Backend::Dxgi ? dxgi_.device() : wgc_.device(); + } + + ID3D11DeviceContext* context() const { + return backend_ == Backend::Dxgi ? dxgi_.context() : wgc_.context(); + } + + bool usingDxgi() const { return backend_ == Backend::Dxgi; } + bool wgcUnavailable() const { return wgcUnavailable_; } + + const char* backendName() const { + switch (backend_) { + case Backend::Wgc: + return "wgc"; + case Backend::Dxgi: + return "dxgi-duplication"; + default: + return "none"; + } + } + +private: + enum class Backend { None, Wgc, Dxgi }; + + bool adoptWgc() { + backend_ = Backend::Wgc; + std::cout << "{\"event\":\"capture-backend\",\"backend\":\"wgc\"}" << std::endl; + return true; + } + + bool startDxgi(HMONITOR monitor, int fps, bool captureCursor, const char* reason) { + if (!dxgi_.initialize(monitor, fps, captureCursor)) { + return false; + } + backend_ = Backend::Dxgi; + // Printed rather than only logged: which backend ran is a per-machine + // outcome the app reports next to encoder-selection, so a future report + // says which one produced the file without anyone having to guess. + std::cout << "{\"event\":\"capture-backend\",\"backend\":\"dxgi-duplication\",\"reason\":\"" + << reason << "\"}" << std::endl; + return true; + } + + Backend backend_ = Backend::None; + WgcSession wgc_; + DxgiDuplicationSession dxgi_; + bool wgcUnavailable_ = false; +}; diff --git a/electron/native/wgc-capture/src/dxgi_duplication_session.cpp b/electron/native/wgc-capture/src/dxgi_duplication_session.cpp new file mode 100644 index 0000000000..b93202fb95 --- /dev/null +++ b/electron/native/wgc-capture/src/dxgi_duplication_session.cpp @@ -0,0 +1,330 @@ +#include "dxgi_duplication_session.h" + +#include +#include + +namespace { + +// Same device shape WgcSession creates, so the encoder and the cursor sampler +// see the same adapter/feature level whichever backend produced the frames. +bool createDevice(Microsoft::WRL::ComPtr& device, + Microsoft::WRL::ComPtr& context) { + const D3D_FEATURE_LEVEL levels[] = { + D3D_FEATURE_LEVEL_11_1, + D3D_FEATURE_LEVEL_11_0, + D3D_FEATURE_LEVEL_10_1, + D3D_FEATURE_LEVEL_10_0, + }; + UINT flags = D3D11_CREATE_DEVICE_BGRA_SUPPORT; + D3D_FEATURE_LEVEL obtained = D3D_FEATURE_LEVEL_11_0; + HRESULT hr = D3D11CreateDevice(nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, flags, levels, + ARRAYSIZE(levels), D3D11_SDK_VERSION, device.GetAddressOf(), + &obtained, context.GetAddressOf()); + if (FAILED(hr)) { + hr = D3D11CreateDevice(nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, + flags & ~D3D11_CREATE_DEVICE_BGRA_SUPPORT, levels, + ARRAYSIZE(levels), D3D11_SDK_VERSION, device.GetAddressOf(), + &obtained, context.GetAddressOf()); + } + if (FAILED(hr)) { + std::cerr << "ERROR: DXGI capture could not create a D3D11 device (hr=0x" << std::hex << hr + << std::dec << ")" << std::endl; + return false; + } + return true; +} + +} // namespace + +DxgiDuplicationSession::~DxgiDuplicationSession() { + stop(); +} + +bool DxgiDuplicationSession::createD3DDevice() { + if (d3dDevice_) { + return true; + } + if (!createDevice(d3dDevice_, d3dContext_)) { + return false; + } + QueryPerformanceFrequency(&qpcFrequency_); + return true; +} + +bool DxgiDuplicationSession::createDuplication(HMONITOR monitor) { + Microsoft::WRL::ComPtr dxgiDevice; + if (FAILED(d3dDevice_.As(&dxgiDevice))) { + return false; + } + if (FAILED(dxgiDevice->GetAdapter(adapter_.ReleaseAndGetAddressOf()))) { + return false; + } + + // The monitor handle the caller matched with EnumDisplayMonitors is the only + // thing tying the app's chosen source to an output, so match on it rather + // than on index or position: a display can be rearranged between the picker + // and the take. + Microsoft::WRL::ComPtr candidate; + for (UINT i = 0; adapter_->EnumOutputs(i, candidate.ReleaseAndGetAddressOf()) == S_OK; ++i) { + DXGI_OUTPUT_DESC desc{}; + if (SUCCEEDED(candidate->GetDesc(&desc)) && desc.Monitor == monitor) { + if (FAILED(candidate.As(&output_))) { + std::cerr << "ERROR: DXGI capture could not query IDXGIOutput1 for the chosen monitor" + << std::endl; + return false; + } + break; + } + } + if (!output_) { + std::cerr << "ERROR: DXGI capture could not find an output for the chosen monitor" + << std::endl; + return false; + } + + Microsoft::WRL::ComPtr duplication; + HRESULT hr = output_->DuplicateOutput(d3dDevice_.Get(), duplication.GetAddressOf()); + if (FAILED(hr)) { + // DXGI_ERROR_NOT_CURRENTLY_AVAILABLE is what a machine reports when the + // maximum number of duplications is already in use; anything else here + // is a driver refusal. + std::cerr << "ERROR: DXGI capture DuplicateOutput failed (hr=0x" << std::hex << hr << std::dec + << ")" << std::endl; + return false; + } + duplication_ = duplication; + + DXGI_OUTDUPL_DESC desc{}; + duplication_->GetDesc(&desc); + width_ = static_cast(desc.ModeDesc.Width); + height_ = static_cast(desc.ModeDesc.Height); + if (width_ <= 0 || height_ <= 0) { + std::cerr << "ERROR: DXGI capture reported an empty desktop (" << width_ << "x" << height_ + << ")" << std::endl; + return false; + } + return true; +} + +bool DxgiDuplicationSession::ensureCopyTexture() { + if (copyTexture_) { + D3D11_TEXTURE2D_DESC existing{}; + copyTexture_->GetDesc(&existing); + if (static_cast(existing.Width) == width_ && + static_cast(existing.Height) == height_) { + return true; + } + copyTexture_.Reset(); + } + + // A private destination for the acquired frame, so AcquireNextFrame can be + // released immediately and the pointer handed to the caller stays valid. + // Flags match what main.cpp already does when it copies a WGC frame out: + // no bind flags, no CPU access -- it is only ever a CopyResource source. + D3D11_TEXTURE2D_DESC desc{}; + desc.Width = static_cast(width_); + desc.Height = static_cast(height_); + desc.MipLevels = 1; + desc.ArraySize = 1; + desc.Format = DXGI_FORMAT_B8G8R8A8_UNORM; + desc.SampleDesc.Count = 1; + desc.Usage = D3D11_USAGE_DEFAULT; + desc.BindFlags = 0; + desc.CPUAccessFlags = 0; + desc.MiscFlags = 0; + const HRESULT hr = d3dDevice_->CreateTexture2D(&desc, nullptr, copyTexture_.GetAddressOf()); + if (FAILED(hr)) { + std::cerr << "ERROR: DXGI capture could not create its copy texture (hr=0x" << std::hex << hr + << std::dec << ")" << std::endl; + return false; + } + return true; +} + +bool DxgiDuplicationSession::initialize(HMONITOR monitor, int fps, bool captureCursor) { + if (!monitor) { + return false; + } + if (captureCursor_) { + // Nothing to do yet; the warning is emitted once on the first frame. + } + captureCursor_ = captureCursor; + fps_ = std::clamp(fps, 1, 120); + monitor_ = monitor; + + if (!createD3DDevice()) { + return false; + } + if (!createDuplication(monitor)) { + return false; + } + if (!ensureCopyTexture()) { + return false; + } + return true; +} + +bool DxgiDuplicationSession::initialize(HWND, int, bool, bool) { + // Desktop Duplication duplicates an output, never a single window. There is + // no DXGI equivalent of CreateForWindow, so a window take has to stay on + // WGC -- say so rather than pretending to be ready. + std::cerr << "WARNING: DXGI capture cannot capture a single window; only displays" << std::endl; + return false; +} + +bool DxgiDuplicationSession::start() { + if (!duplication_) { + return false; + } + started_ = true; + return true; +} + +int64_t DxgiDuplicationSession::ticksToHns(LONGLONG ticks) const { + if (qpcFrequency_.QuadPart <= 0) { + return ticks; + } + // 10'000'000 == one second in 100 ns units. + return static_cast((ticks * 10'000'000LL) / qpcFrequency_.QuadPart); +} + +bool DxgiDuplicationSession::reacquire() { + // DXGI_ERROR_ACCESS_LOST is normal: a UAC prompt, a desktop switch, a + // resolution or refresh-rate change, or another process taking exclusive + // fullscreen all invalidate the duplication. Rebuild it and let the caller + // keep polling -- a frame is simply skipped, never a recording ended. + ++reacquireCount_; + duplication_.Reset(); + output_.Reset(); + adapter_.Reset(); + copyTexture_.Reset(); + if (!d3dDevice_) { + return false; + } + if (!createDuplication(monitor_)) { + if (!warnedAccessLost_) { + warnedAccessLost_ = true; + std::cerr << "WARNING: DXGI capture lost the desktop and could not reacquire it; " + "recording continues without new frames" + << std::endl; + } + return false; + } + if (!ensureCopyTexture()) { + return false; + } + if (!warnedAccessLost_) { + warnedAccessLost_ = true; + std::cout << "{\"event\":\"capture-reacquired\",\"backend\":\"dxgi\"}" << std::endl; + } + return true; +} + +bool DxgiDuplicationSession::tryGetNextFrame(ID3D11Texture2D** outTexture, int64_t* outTimestampHns) { + if (!outTexture || !outTimestampHns || !started_ || !duplication_ || exhausted_) { + return false; + } + *outTexture = nullptr; + + // Wait no longer than one frame interval. main.cpp polls this on its own + // cadence while it holds no lock, and a longer block here would simply make + // the writer answer `stop` late. + const UINT timeoutMs = static_cast(std::max(1, 1000 / std::max(1, fps_))); + DXGI_OUTDUPL_FRAME_INFO info{}; + Microsoft::WRL::ComPtr resource; + const HRESULT hr = duplication_->AcquireNextFrame(timeoutMs, &info, &resource); + + if (hr == DXGI_ERROR_WAIT_TIMEOUT) { + // A static desktop presents nothing. Not an error, and not a dropped frame. + return false; + } + if (hr == DXGI_ERROR_ACCESS_LOST) { + if (!reacquire()) { + exhausted_ = true; + } + return false; + } + if (FAILED(hr)) { + if (hr == DXGI_ERROR_INVALID_CALL) { + // Only reachable if a previous frame was not released; recover rather + // than wedge the writer on a permanently failing acquire. + duplication_->ReleaseFrame(); + return false; + } + std::cerr << "WARNING: DXGI capture AcquireNextFrame failed (hr=0x" << std::hex << hr + << std::dec << ")" << std::endl; + return false; + } + + Microsoft::WRL::ComPtr acquired; + bool copied = false; + if (SUCCEEDED(resource.As(&acquired)) && acquired) { + if (ensureCopyTexture()) { + d3dContext_->CopyResource(copyTexture_.Get(), acquired.Get()); + copied = true; + } + } + + // The frame must be released before the next acquisition, whatever happened + // above -- holding it would make every later AcquireNextFrame fail. + duplication_->ReleaseFrame(); + + if (!copied) { + return false; + } + + if (captureCursor_ && !warnedCursorIgnored_) { + warnedCursorIgnored_ = true; + // Desktop Duplication hands over the desktop image without the pointer; + // drawing it is the caller's job (DXGI_OUTDUPL_FRAME_INFO carries the + // shape and position). The app's default editable-overlay mode does not + // ask for it -- it samples the cursor separately and composites its own + // -- so this only affects a take explicitly set to the system cursor. + std::cerr << "WARNING: DXGI capture does not composite the system cursor; use the " + "editable cursor mode for this machine" + << std::endl; + } + + // LastPresentTime shares the QPC clock. A zero means the frame carried no + // timestamp, which falls back to now -- the value is only ever used as a + // delta from the first frame, so monotonic is what matters. + LONGLONG ticks = info.LastPresentTime.QuadPart; + if (ticks == 0) { + LARGE_INTEGER now{}; + QueryPerformanceCounter(&now); + ticks = now.QuadPart; + } + *outTimestampHns = ticksToHns(ticks); + *outTexture = copyTexture_.Get(); + return true; +} + +void DxgiDuplicationSession::setFrameCallback(FrameCallback callback) { + (void)callback; + // Deliberately inert: Desktop Duplication is pull-only, and rebuilding the + // WGC callback-thread model here would reintroduce the frame-lock hazard + // that pull-based delivery exists to avoid (see wgc_session.h). main.cpp + // keeps the pull path when this backend is active. +} + +bool DxgiDuplicationSession::quiesceLegacyCallback(int) { + // Nothing was ever registered -- report drained so the caller never waits. + return true; +} + +void DxgiDuplicationSession::stop() { + started_ = false; + if (duplication_) { + // An acquired-but-unreleased frame would keep the output duplicated for + // the rest of the process, so release unconditionally before dropping it. + duplication_->ReleaseFrame(); + } + copyTexture_.Reset(); + duplication_.Reset(); + output_.Reset(); + adapter_.Reset(); + d3dContext_.Reset(); + d3dDevice_.Reset(); + width_ = 0; + height_ = 0; +} diff --git a/electron/native/wgc-capture/src/dxgi_duplication_session.h b/electron/native/wgc-capture/src/dxgi_duplication_session.h new file mode 100644 index 0000000000..5c4ed74c2f --- /dev/null +++ b/electron/native/wgc-capture/src/dxgi_duplication_session.h @@ -0,0 +1,94 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include + +// DXGI Desktop Duplication capture, as an alternative frame source to +// WgcSession for the same pipeline. +// +// Why it exists: IGraphicsCaptureItemInterop::CreateForMonitor can be refused +// with E_ACCESSDENIED on a machine where the OS reports WGC as supported +// (GraphicsCaptureSession::IsSupported() == 1) and where every other capture +// route works -- measured on Windows 10 22H2 (19045.6466), where the same +// process that gets 0x80070005 from CreateForMonitor acquires 2560x1440 BGRA +// frames from IDXGIOutputDuplication for the same monitor, in the same run. +// Window capture has no equivalent here: Desktop Duplication only ever +// duplicates an output, so initialize(HWND, ...) refuses. +// +// The public surface mirrors WgcSession deliberately. main.cpp drives both +// through the CaptureSession wrapper, so nothing downstream -- encoder, MP4 +// muxer, audio, webcam, cursor sampler, the JSON event protocol -- knows which +// backend produced a frame. +class DxgiDuplicationSession { +public: + using FrameCallback = std::function; + + DxgiDuplicationSession() = default; + ~DxgiDuplicationSession(); + + DxgiDuplicationSession(const DxgiDuplicationSession&) = delete; + DxgiDuplicationSession& operator=(const DxgiDuplicationSession&) = delete; + + bool initialize(HMONITOR monitor, int fps, bool captureCursor); + // Unsupported by design -- see the class comment. Returns false and says so + // once, so a window take falls back to the WGC failure path rather than + // silently recording nothing. + bool initialize(HWND window, int fps, bool captureCursor, bool includeSecondaryWindows = false); + + bool start(); + // Returns the most recent frame's texture, or false when no new frame has + // been presented since the last call. Unlike WgcSession, the texture is a + // private copy owned by this session rather than pool-owned: AcquireNextFrame + // requires the frame to be released before the next acquisition, so the + // desktop image is copied out immediately and the returned pointer stays + // valid until the next call or stop(). + bool tryGetNextFrame(ID3D11Texture2D** outTexture, int64_t* outTimestampHns); + + // Pull-only backend. WgcSession's FrameArrived path exists as a rollback + // lever for a driver bug specific to WGC's callback thread (see wgc_session.h); + // Desktop Duplication has no equivalent push mode, so these are inert and + // main.cpp keeps the pull path when this backend is the active one. + void setFrameCallback(FrameCallback callback); + bool quiesceLegacyCallback(int drainTimeoutMs = 5000); + void stop(); + + int captureWidth() const { return width_; } + int captureHeight() const { return height_; } + ID3D11Device* device() const { return d3dDevice_.Get(); } + ID3D11DeviceContext* context() const { return d3dContext_.Get(); } + +private: + bool createD3DDevice(); + bool createDuplication(HMONITOR monitor); + bool ensureCopyTexture(); + bool reacquire(); + // LastPresentTime and QueryPerformanceCounter share the QPC clock, which is + // the same clock WGC's SystemRelativeTime is derived from -- and main.cpp + // only ever uses the value as a delta from the first frame, so a monotonic + // 100 ns count is all this has to be. + int64_t ticksToHns(LONGLONG ticks) const; + + Microsoft::WRL::ComPtr d3dDevice_; + Microsoft::WRL::ComPtr d3dContext_; + Microsoft::WRL::ComPtr adapter_; + Microsoft::WRL::ComPtr output_; + Microsoft::WRL::ComPtr duplication_; + Microsoft::WRL::ComPtr copyTexture_; + + HMONITOR monitor_ = nullptr; + LARGE_INTEGER qpcFrequency_{}; + int width_ = 0; + int height_ = 0; + int fps_ = 60; + bool captureCursor_ = false; + bool started_ = false; + bool warnedCursorIgnored_ = false; + bool warnedAccessLost_ = false; + bool exhausted_ = false; + uint64_t reacquireCount_ = 0; +}; diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp index bd28c5eb1b..d4bd09d4b5 100644 --- a/electron/native/wgc-capture/src/main.cpp +++ b/electron/native/wgc-capture/src/main.cpp @@ -10,7 +10,7 @@ #include "wasapi_render_keepalive.h" #include "frame_visibility.h" #include "webcam_capture.h" -#include "wgc_session.h" +#include "capture_session.h" #include @@ -704,7 +704,7 @@ int wmain(int argc, wchar_t* argv[]) { // Declared before the session so it outlives it: the icons come back only // once nothing is recording any more. DesktopIconCover desktopIconCover; - WgcSession session; + CaptureSession session; HMONITOR capturedMonitor = nullptr; if (config.sourceType == "display") { HMONITOR monitor = findMonitorForCapture( @@ -716,7 +716,9 @@ int wmain(int argc, wchar_t* argv[]) { } capturedMonitor = monitor; if (!session.initialize(monitor, config.fps, config.captureCursor)) { - std::cerr << "ERROR: Failed to initialize WGC display session" << std::endl; + std::cerr << "ERROR: Failed to initialize display capture: Windows Graphics Capture " + "was refused and DXGI Desktop Duplication could not start either" + << std::endl; return 1; } // Before `recording-started`, so the first frame is already clean. A cover @@ -741,7 +743,7 @@ int wmain(int argc, wchar_t* argv[]) { const bool includeSecondaryWindows = readEnvInt("OPENSCREEN_WGC_DISABLE_SECONDARY_WINDOWS", 0) != 1; if (!session.initialize(window, config.fps, config.captureCursor, includeSecondaryWindows)) { - std::cerr << "ERROR: Failed to initialize WGC window session" << std::endl; + std::cerr << "ERROR: Failed to initialize window capture (Windows Graphics Capture)" << std::endl; return 1; } } else { @@ -1011,7 +1013,9 @@ int wmain(int argc, wchar_t* argv[]) { // OPENSCREEN_WGC_LEGACY_FRAME_CALLBACK=1 reverts to the previously // shipped push-based design (frameMutex/frameCv guard the handoff from // WGC's own callback thread) as a rollback lever -- see wgc_session.h. - const bool legacyFrameCallback = useLegacyFrameCallback(); + // The DXGI fallback is pull-only (see capture_session.h), so the legacy + // FrameArrived rollback lever only means anything on the WGC backend. + const bool legacyFrameCallback = useLegacyFrameCallback() && !session.usingDxgi(); CaptureControl control; std::atomic firstFrameWritten = false; std::atomic encodeFailed = false; @@ -1477,7 +1481,8 @@ int wmain(int argc, wchar_t* argv[]) { if (audioMixer) { audioMixer->stop(); } - std::cerr << "ERROR: Failed to start WGC session" << std::endl; + std::cerr << "ERROR: Failed to start native capture session (" << session.backendName() << ")" + << std::endl; return 1; } From e30d8a1c1c6db35bb20c06814276dda361a46300 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Tue, 27 Oct 2026 09:00:20 +0100 Subject: [PATCH 2/2] fix(win): harden the DXGI fallback (reacquire, adapter, cursor, rotation, resize) - Retry a failed duplication rebuild about once a second instead of freezing the take after a UAC prompt or a secure-desktop switch; log every recovery. - Search the output across all adapters and create the D3D11 device on the one that owns it, so hybrid and multi-GPU machines can use the fallback. - Draw the system cursor over the copied frame through GDI when the take asks for it, since Desktop Duplication never includes the pointer. - Refuse rotated displays with a clear error rather than record sideways. - End the take with an error when the capture size changes mid-recording, instead of copying between mismatched textures. - Keep ticksToHns from overflowing after ~25 h of QPC uptime. --- electron/native/wgc-capture/CMakeLists.txt | 1 + .../src/dxgi_duplication_session.cpp | 182 +++++++++++------- .../src/dxgi_duplication_session.h | 15 +- electron/native/wgc-capture/src/main.cpp | 17 ++ 4 files changed, 137 insertions(+), 78 deletions(-) diff --git a/electron/native/wgc-capture/CMakeLists.txt b/electron/native/wgc-capture/CMakeLists.txt index 3429cff3f8..87eaf2af97 100644 --- a/electron/native/wgc-capture/CMakeLists.txt +++ b/electron/native/wgc-capture/CMakeLists.txt @@ -81,6 +81,7 @@ target_link_libraries(wgc-capture PRIVATE avrt d3d11 dxgi + gdi32 mf mfplat mfreadwrite diff --git a/electron/native/wgc-capture/src/dxgi_duplication_session.cpp b/electron/native/wgc-capture/src/dxgi_duplication_session.cpp index b93202fb95..e4a4b8c07a 100644 --- a/electron/native/wgc-capture/src/dxgi_duplication_session.cpp +++ b/electron/native/wgc-capture/src/dxgi_duplication_session.cpp @@ -5,9 +5,10 @@ namespace { -// Same device shape WgcSession creates, so the encoder and the cursor sampler -// see the same adapter/feature level whichever backend produced the frames. -bool createDevice(Microsoft::WRL::ComPtr& device, +// Same device shape WgcSession creates, but on the adapter that owns the output: +// DuplicateOutput refuses a device from any other adapter, which is the normal +// case for a display wired to the discrete GPU of a hybrid laptop. +bool createDevice(IDXGIAdapter1* adapter, Microsoft::WRL::ComPtr& device, Microsoft::WRL::ComPtr& context) { const D3D_FEATURE_LEVEL levels[] = { D3D_FEATURE_LEVEL_11_1, @@ -17,11 +18,11 @@ bool createDevice(Microsoft::WRL::ComPtr& device, }; UINT flags = D3D11_CREATE_DEVICE_BGRA_SUPPORT; D3D_FEATURE_LEVEL obtained = D3D_FEATURE_LEVEL_11_0; - HRESULT hr = D3D11CreateDevice(nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, flags, levels, + HRESULT hr = D3D11CreateDevice(adapter, D3D_DRIVER_TYPE_UNKNOWN, nullptr, flags, levels, ARRAYSIZE(levels), D3D11_SDK_VERSION, device.GetAddressOf(), &obtained, context.GetAddressOf()); if (FAILED(hr)) { - hr = D3D11CreateDevice(nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, + hr = D3D11CreateDevice(adapter, D3D_DRIVER_TYPE_UNKNOWN, nullptr, flags & ~D3D11_CREATE_DEVICE_BGRA_SUPPORT, levels, ARRAYSIZE(levels), D3D11_SDK_VERSION, device.GetAddressOf(), &obtained, context.GetAddressOf()); @@ -40,48 +41,62 @@ DxgiDuplicationSession::~DxgiDuplicationSession() { stop(); } -bool DxgiDuplicationSession::createD3DDevice() { - if (d3dDevice_) { - return true; - } - if (!createDevice(d3dDevice_, d3dContext_)) { - return false; - } - QueryPerformanceFrequency(&qpcFrequency_); - return true; -} - -bool DxgiDuplicationSession::createDuplication(HMONITOR monitor) { - Microsoft::WRL::ComPtr dxgiDevice; - if (FAILED(d3dDevice_.As(&dxgiDevice))) { - return false; - } - if (FAILED(dxgiDevice->GetAdapter(adapter_.ReleaseAndGetAddressOf()))) { +bool DxgiDuplicationSession::findOutput(HMONITOR monitor) { + output_.Reset(); + adapter_.Reset(); + Microsoft::WRL::ComPtr factory; + if (FAILED(CreateDXGIFactory1(IID_PPV_ARGS(&factory)))) { + std::cerr << "ERROR: DXGI capture could not create a DXGI factory" << std::endl; return false; } // The monitor handle the caller matched with EnumDisplayMonitors is the only // thing tying the app's chosen source to an output, so match on it rather // than on index or position: a display can be rearranged between the picker - // and the take. - Microsoft::WRL::ComPtr candidate; - for (UINT i = 0; adapter_->EnumOutputs(i, candidate.ReleaseAndGetAddressOf()) == S_OK; ++i) { - DXGI_OUTPUT_DESC desc{}; - if (SUCCEEDED(candidate->GetDesc(&desc)) && desc.Monitor == monitor) { + // and the take. Every adapter is searched, not just the default one. + Microsoft::WRL::ComPtr adapter; + for (UINT a = 0; factory->EnumAdapters1(a, adapter.ReleaseAndGetAddressOf()) == S_OK; ++a) { + Microsoft::WRL::ComPtr candidate; + for (UINT i = 0; adapter->EnumOutputs(i, candidate.ReleaseAndGetAddressOf()) == S_OK; ++i) { + DXGI_OUTPUT_DESC desc{}; + if (FAILED(candidate->GetDesc(&desc)) || desc.Monitor != monitor) { + continue; + } + if (desc.Rotation != DXGI_MODE_ROTATION_IDENTITY && + desc.Rotation != DXGI_MODE_ROTATION_UNSPECIFIED) { + // Desktop Duplication hands over the unrotated scan-out image, so a + // portrait display would record sideways. Refuse rather than do that. + std::cerr << "ERROR: DXGI capture does not support rotated displays (rotation=" + << desc.Rotation << ")" << std::endl; + return false; + } if (FAILED(candidate.As(&output_))) { std::cerr << "ERROR: DXGI capture could not query IDXGIOutput1 for the chosen monitor" << std::endl; return false; } - break; + adapter_ = adapter; + desktopRect_ = desc.DesktopCoordinates; + return true; } } - if (!output_) { - std::cerr << "ERROR: DXGI capture could not find an output for the chosen monitor" - << std::endl; + std::cerr << "ERROR: DXGI capture could not find an output for the chosen monitor" + << std::endl; + return false; +} + +bool DxgiDuplicationSession::createD3DDevice() { + if (d3dDevice_) { + return true; + } + if (!createDevice(adapter_.Get(), d3dDevice_, d3dContext_)) { return false; } + QueryPerformanceFrequency(&qpcFrequency_); + return true; +} +bool DxgiDuplicationSession::createDuplication() { Microsoft::WRL::ComPtr duplication; HRESULT hr = output_->DuplicateOutput(d3dDevice_.Get(), duplication.GetAddressOf()); if (FAILED(hr)) { @@ -121,6 +136,8 @@ bool DxgiDuplicationSession::ensureCopyTexture() { // released immediately and the pointer handed to the caller stays valid. // Flags match what main.cpp already does when it copies a WGC frame out: // no bind flags, no CPU access -- it is only ever a CopyResource source. + // With the system cursor it must also take a GDI DC, which needs a render + // target that is GDI-compatible (drawCursor). D3D11_TEXTURE2D_DESC desc{}; desc.Width = static_cast(width_); desc.Height = static_cast(height_); @@ -129,9 +146,9 @@ bool DxgiDuplicationSession::ensureCopyTexture() { desc.Format = DXGI_FORMAT_B8G8R8A8_UNORM; desc.SampleDesc.Count = 1; desc.Usage = D3D11_USAGE_DEFAULT; - desc.BindFlags = 0; + desc.BindFlags = captureCursor_ ? D3D11_BIND_RENDER_TARGET : 0; desc.CPUAccessFlags = 0; - desc.MiscFlags = 0; + desc.MiscFlags = captureCursor_ ? D3D11_RESOURCE_MISC_GDI_COMPATIBLE : 0; const HRESULT hr = d3dDevice_->CreateTexture2D(&desc, nullptr, copyTexture_.GetAddressOf()); if (FAILED(hr)) { std::cerr << "ERROR: DXGI capture could not create its copy texture (hr=0x" << std::hex << hr @@ -145,17 +162,14 @@ bool DxgiDuplicationSession::initialize(HMONITOR monitor, int fps, bool captureC if (!monitor) { return false; } - if (captureCursor_) { - // Nothing to do yet; the warning is emitted once on the first frame. - } captureCursor_ = captureCursor; fps_ = std::clamp(fps, 1, 120); monitor_ = monitor; - if (!createD3DDevice()) { + if (!findOutput(monitor) || !createD3DDevice()) { return false; } - if (!createDuplication(monitor)) { + if (!createDuplication()) { return false; } if (!ensureCopyTexture()) { @@ -185,45 +199,46 @@ int64_t DxgiDuplicationSession::ticksToHns(LONGLONG ticks) const { return ticks; } // 10'000'000 == one second in 100 ns units. - return static_cast((ticks * 10'000'000LL) / qpcFrequency_.QuadPart); + // Split so ticks * 10^7 never overflows (it would after ~25 h of uptime). + const LONGLONG f = qpcFrequency_.QuadPart; + return static_cast((ticks / f) * 10'000'000LL + ((ticks % f) * 10'000'000LL) / f); } bool DxgiDuplicationSession::reacquire() { // DXGI_ERROR_ACCESS_LOST is normal: a UAC prompt, a desktop switch, a // resolution or refresh-rate change, or another process taking exclusive // fullscreen all invalidate the duplication. Rebuild it and let the caller - // keep polling -- a frame is simply skipped, never a recording ended. - ++reacquireCount_; + // keep polling -- a frame is simply skipped, never a recording ended. While + // the secure desktop is up, DuplicateOutput keeps failing, so the caller + // retries about once a second until it comes back. + lastReacquireAttemptMs_ = GetTickCount64(); duplication_.Reset(); - output_.Reset(); - adapter_.Reset(); copyTexture_.Reset(); - if (!d3dDevice_) { - return false; - } - if (!createDuplication(monitor_)) { - if (!warnedAccessLost_) { - warnedAccessLost_ = true; - std::cerr << "WARNING: DXGI capture lost the desktop and could not reacquire it; " - "recording continues without new frames" + if (!d3dDevice_ || !findOutput(monitor_) || !createDuplication() || !ensureCopyTexture()) { + duplication_.Reset(); + if (!warnedReacquireFailed_) { + warnedReacquireFailed_ = true; + std::cerr << "WARNING: DXGI capture lost the desktop and could not reacquire it yet; " + "retrying every second" << std::endl; } return false; } - if (!ensureCopyTexture()) { - return false; - } - if (!warnedAccessLost_) { - warnedAccessLost_ = true; - std::cout << "{\"event\":\"capture-reacquired\",\"backend\":\"dxgi\"}" << std::endl; - } + warnedReacquireFailed_ = false; + std::cout << "{\"event\":\"capture-reacquired\",\"backend\":\"dxgi\"}" << std::endl; return true; } bool DxgiDuplicationSession::tryGetNextFrame(ID3D11Texture2D** outTexture, int64_t* outTimestampHns) { - if (!outTexture || !outTimestampHns || !started_ || !duplication_ || exhausted_) { + if (!outTexture || !outTimestampHns || !started_) { return false; } + if (!duplication_) { + // A previous rebuild failed: try again, throttled, rather than give up. + if (GetTickCount64() - lastReacquireAttemptMs_ < 1000 || !reacquire()) { + return false; + } + } *outTexture = nullptr; // Wait no longer than one frame interval. main.cpp polls this on its own @@ -239,9 +254,7 @@ bool DxgiDuplicationSession::tryGetNextFrame(ID3D11Texture2D** outTexture, int64 return false; } if (hr == DXGI_ERROR_ACCESS_LOST) { - if (!reacquire()) { - exhausted_ = true; - } + reacquire(); return false; } if (FAILED(hr)) { @@ -273,16 +286,8 @@ bool DxgiDuplicationSession::tryGetNextFrame(ID3D11Texture2D** outTexture, int64 return false; } - if (captureCursor_ && !warnedCursorIgnored_) { - warnedCursorIgnored_ = true; - // Desktop Duplication hands over the desktop image without the pointer; - // drawing it is the caller's job (DXGI_OUTDUPL_FRAME_INFO carries the - // shape and position). The app's default editable-overlay mode does not - // ask for it -- it samples the cursor separately and composites its own - // -- so this only affects a take explicitly set to the system cursor. - std::cerr << "WARNING: DXGI capture does not composite the system cursor; use the " - "editable cursor mode for this machine" - << std::endl; + if (captureCursor_) { + drawCursor(); } // LastPresentTime shares the QPC clock. A zero means the frame carried no @@ -299,6 +304,39 @@ bool DxgiDuplicationSession::tryGetNextFrame(ID3D11Texture2D** outTexture, int64 return true; } +void DxgiDuplicationSession::drawCursor() { + CURSORINFO ci{}; + ci.cbSize = sizeof(ci); + if (!GetCursorInfo(&ci) || !(ci.flags & CURSOR_SHOWING) || !ci.hCursor) { + return; + } + ICONINFO icon{}; + if (!GetIconInfo(ci.hCursor, &icon)) { + return; + } + // GetIconInfo hands over copies of the bitmaps, which the caller owns. + if (icon.hbmMask) { + DeleteObject(icon.hbmMask); + } + if (icon.hbmColor) { + DeleteObject(icon.hbmColor); + } + + Microsoft::WRL::ComPtr surface; + HDC dc = nullptr; + if (FAILED(copyTexture_.As(&surface)) || FAILED(surface->GetDC(FALSE, &dc))) { + return; + } + // The helper is per-monitor DPI aware, so the cursor position and the + // output's desktop rectangle are both in physical pixels. DrawIconEx with + // DI_NORMAL applies the AND/XOR masks, so monochrome and inverting cursors + // look the way they do on screen. + DrawIconEx(dc, ci.ptScreenPos.x - desktopRect_.left - static_cast(icon.xHotspot), + ci.ptScreenPos.y - desktopRect_.top - static_cast(icon.yHotspot), ci.hCursor, 0, + 0, 0, nullptr, DI_NORMAL | DI_DEFAULTSIZE); + surface->ReleaseDC(nullptr); +} + void DxgiDuplicationSession::setFrameCallback(FrameCallback callback) { (void)callback; // Deliberately inert: Desktop Duplication is pull-only, and rebuilding the diff --git a/electron/native/wgc-capture/src/dxgi_duplication_session.h b/electron/native/wgc-capture/src/dxgi_duplication_session.h index 5c4ed74c2f..911a0b6f60 100644 --- a/electron/native/wgc-capture/src/dxgi_duplication_session.h +++ b/electron/native/wgc-capture/src/dxgi_duplication_session.h @@ -63,10 +63,14 @@ class DxgiDuplicationSession { ID3D11DeviceContext* context() const { return d3dContext_.Get(); } private: + bool findOutput(HMONITOR monitor); bool createD3DDevice(); - bool createDuplication(HMONITOR monitor); + bool createDuplication(); bool ensureCopyTexture(); bool reacquire(); + // WGC draws the pointer itself; Desktop Duplication never does, so with the + // system cursor mode it is drawn here, over the private copy, through GDI. + void drawCursor(); // LastPresentTime and QueryPerformanceCounter share the QPC clock, which is // the same clock WGC's SystemRelativeTime is derived from -- and main.cpp // only ever uses the value as a delta from the first frame, so a monotonic @@ -75,7 +79,7 @@ class DxgiDuplicationSession { Microsoft::WRL::ComPtr d3dDevice_; Microsoft::WRL::ComPtr d3dContext_; - Microsoft::WRL::ComPtr adapter_; + Microsoft::WRL::ComPtr adapter_; Microsoft::WRL::ComPtr output_; Microsoft::WRL::ComPtr duplication_; Microsoft::WRL::ComPtr copyTexture_; @@ -87,8 +91,7 @@ class DxgiDuplicationSession { int fps_ = 60; bool captureCursor_ = false; bool started_ = false; - bool warnedCursorIgnored_ = false; - bool warnedAccessLost_ = false; - bool exhausted_ = false; - uint64_t reacquireCount_ = 0; + RECT desktopRect_{}; + bool warnedReacquireFailed_ = false; + ULONGLONG lastReacquireAttemptMs_ = 0; }; diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp index d4bd09d4b5..ff369d9cd7 100644 --- a/electron/native/wgc-capture/src/main.cpp +++ b/electron/native/wgc-capture/src/main.cpp @@ -1166,6 +1166,23 @@ int wmain(int argc, wchar_t* argv[]) { break; } } + // A display mode change mid-take (resolution, scale) + // hands over frames of a new size. The encoder was set + // up for the first one, and CopyResource between + // mismatched textures is undefined, so end the take + // with a clear error instead of recording garbage. + D3D11_TEXTURE2D_DESC frameDesc{}; + D3D11_TEXTURE2D_DESC latestDesc{}; + wgcTexture->GetDesc(&frameDesc); + latestFrameTexture->GetDesc(&latestDesc); + if (frameDesc.Width != latestDesc.Width || frameDesc.Height != latestDesc.Height) { + std::cerr << "ERROR: Capture size changed mid-recording (" << latestDesc.Width + << "x" << latestDesc.Height << " -> " << frameDesc.Width << "x" + << frameDesc.Height << "); ending the take" << std::endl; + encodeFailed = true; + control.requestStop(); + break; + } // The wedge risk this class exists to avoid: this call // can block inside the display driver and never return // (#252, still true of PR #305's DXGI path on some