diff --git a/apps/desktop/src/platform/windows/screen_capturer/display_presence.h b/apps/desktop/src/platform/windows/screen_capturer/display_presence.h new file mode 100644 index 00000000..6a84bdad --- /dev/null +++ b/apps/desktop/src/platform/windows/screen_capturer/display_presence.h @@ -0,0 +1,85 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-09-20 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef CROSSDESK_DISPLAY_PRESENCE_H_ +#define CROSSDESK_DISPLAY_PRESENCE_H_ + +#include + +#include +#include +#include + +namespace crossdesk { + +// Without any attached monitor, Windows keeps the desktop on a phantom display +// target with an OS-chosen resolution. DXGI has no output to duplicate there, +// but GDI still reads the composed desktop and input injection keeps working. +enum class DisplayPresence { present, headless, unknown }; + +struct DisplayPresenceProbe { + bool query_succeeded = false; // QueryDisplayConfig returned a topology + size_t active_paths = 0; // active display paths, phantom included + size_t available_targets = 0; // active paths whose monitor is present + size_t desktop_devices = 0; // adapters attached to the desktop + size_t attached_monitors = 0; // monitor devices reported active +}; + +// Pure policy, kept separate from Win32 so it can be tested with fixtures. +inline DisplayPresence ClassifyDisplayPresence( + const DisplayPresenceProbe& probe) { + if (probe.query_succeeded) { + if (probe.available_targets > 0) return DisplayPresence::present; + if (probe.active_paths > 0 || probe.desktop_devices > 0) + return DisplayPresence::headless; + return DisplayPresence::unknown; + } + if (probe.attached_monitors > 0) return DisplayPresence::present; + if (probe.desktop_devices > 0) return DisplayPresence::headless; + return DisplayPresence::unknown; +} + +inline DisplayPresenceProbe ProbeDisplayPresence() { + DisplayPresenceProbe probe; + // The buffer size can change between the two calls during a topology + // change; a few retries cover that window. + for (int attempt = 0; attempt < 4 && !probe.query_succeeded; ++attempt) { + UINT32 path_count = 0; + UINT32 mode_count = 0; + if (GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &path_count, + &mode_count) != ERROR_SUCCESS) break; + std::vector paths((std::max)(path_count, 1u)); + std::vector modes((std::max)(mode_count, 1u)); + const LONG status = QueryDisplayConfig(QDC_ONLY_ACTIVE_PATHS, &path_count, + paths.data(), &mode_count, + modes.data(), nullptr); + if (status == ERROR_INSUFFICIENT_BUFFER) continue; + if (status != ERROR_SUCCESS) break; + probe.query_succeeded = true; + for (UINT32 index = 0; index < path_count; ++index) { + if (!(paths[index].flags & DISPLAYCONFIG_PATH_ACTIVE)) continue; + ++probe.active_paths; + // A removed monitor can leave its path active while the target is gone. + if (paths[index].targetInfo.targetAvailable) ++probe.available_targets; + } + } + for (DWORD index = 0;; ++index) { + DISPLAY_DEVICEW adapter{sizeof(adapter)}; + if (!EnumDisplayDevicesW(nullptr, index, &adapter, 0)) break; + if (!(adapter.StateFlags & DISPLAY_DEVICE_ATTACHED_TO_DESKTOP)) continue; + ++probe.desktop_devices; + for (DWORD child = 0;; ++child) { + DISPLAY_DEVICEW monitor{sizeof(monitor)}; + if (!EnumDisplayDevicesW(adapter.DeviceName, child, &monitor, 0)) break; + if (monitor.StateFlags & DISPLAY_DEVICE_ACTIVE) ++probe.attached_monitors; + } + } + return probe; +} + +} // namespace crossdesk + +#endif // CROSSDESK_DISPLAY_PRESENCE_H_ diff --git a/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_gdi.cpp b/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_gdi.cpp index 4f3525bb..69d21d87 100644 --- a/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_gdi.cpp +++ b/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_gdi.cpp @@ -122,15 +122,25 @@ int ScreenCapturerGdi::ResetToInitialMonitor() { void ScreenCapturerGdi::CaptureLoop() { int interval_ms = fps_ > 0 ? (1000 / fps_) : 16; HDC screen_dc = GetDC(nullptr); + bool dc_error_logged = false; while (running_) { if (paused_) { std::this_thread::sleep_for(std::chrono::milliseconds(10)); continue; } if (!screen_dc) { - std::this_thread::sleep_for(std::chrono::milliseconds(10)); + // The screen DC can be unavailable during a desktop switch; retry so + // capture resumes once the desktop is reachable again. + if (!dc_error_logged) { + LOG_WARN("GDI: GetDC(nullptr) failed, error={}; retrying", + GetLastError()); + dc_error_logged = true; + } + std::this_thread::sleep_for(std::chrono::milliseconds(interval_ms)); + screen_dc = GetDC(nullptr); continue; } + dc_error_logged = false; int idx = monitor_index_.load(); if (idx < 0 || idx >= static_cast(display_info_list_.size())) { @@ -157,11 +167,22 @@ void ScreenCapturerGdi::CaptureLoop() { bmi.bmiHeader.biBitCount = 32; bmi.bmiHeader.biCompression = BI_RGB; + // GDI is the only backend on a headless desktop, where odd DIB sizes and + // exhausted GDI handles are plausible; never dereference a failed alloc. void* bits = nullptr; HDC mem_dc = CreateCompatibleDC(screen_dc); - HBITMAP dib = - CreateDIBSection(mem_dc, &bmi, DIB_RGB_COLORS, &bits, nullptr, 0); - HGDIOBJ old = SelectObject(mem_dc, dib); + HBITMAP dib = mem_dc ? CreateDIBSection(mem_dc, &bmi, DIB_RGB_COLORS, + &bits, nullptr, 0) + : nullptr; + HGDIOBJ old = (dib && bits) ? SelectObject(mem_dc, dib) : nullptr; + if (!old) { + LOG_WARN("GDI: frame buffer allocation failed ({}x{}), error={}", width, + height, GetLastError()); + if (dib) DeleteObject(dib); + if (mem_dc) DeleteDC(mem_dc); + std::this_thread::sleep_for(std::chrono::milliseconds(interval_ms)); + continue; + } BitBlt(mem_dc, 0, 0, width, height, screen_dc, left, top, SRCCOPY | CAPTUREBLT); @@ -202,7 +223,7 @@ void ScreenCapturerGdi::CaptureLoop() { std::this_thread::sleep_for(std::chrono::milliseconds(interval_ms)); } - ReleaseDC(nullptr, screen_dc); + if (screen_dc) ReleaseDC(nullptr, screen_dc); } } // namespace crossdesk diff --git a/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_win.cpp b/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_win.cpp index e5bf058a..965ad009 100644 --- a/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_win.cpp +++ b/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_win.cpp @@ -18,6 +18,7 @@ #include #include "captured_nv12_frame.h" #include "captured_cursor_state.h" +#include "display_presence.h" #include "dxgi_cursor_state.h" #include "interactive_desktop.h" #include "interactive_state.h" @@ -30,6 +31,7 @@ #include "secure_desktop_status_poller.h" #include "service_host.h" #include "session_helper_shared.h" +#include "../virtual_display/usbmmidd_virtual_display.h" #include "wgc_plugin_api.h" namespace crossdesk { @@ -180,6 +182,63 @@ const char* CaptureBackendName(const ScreenCapturer* capturer) { return "unavailable"; } +// Debug aid: CROSSDESK_FORCE_HEADLESS=1 makes a host that has monitors behave +// as if it had none until a usbmmidd monitor is plugged, so the virtual +// display path can be exercised without unplugging cables. Once the virtual +// monitor is attached the real probe takes over again. +bool ForcedHeadlessForTesting() { + static const bool forced = [] { + wchar_t value[8] = {0}; + const DWORD length = + GetEnvironmentVariableW(L"CROSSDESK_FORCE_HEADLESS", value, 8); + return length > 0 && length < 8 && value[0] == L'1'; + }(); + return forced && CountUsbmmiddMonitors() == 0; +} + +// Headless compatibility mode: no monitor is attached, so the desktop lives on +// a phantom target at an OS-chosen resolution. DXGI cannot duplicate it and +// WGC has no real monitor item; GDI reads the composed desktop. This is a +// best-effort baseline that needs no driver. Only an explicit Auto selection +// takes this shortcut; a pinned method keeps its own failure semantics. +bool DetectHeadlessDesktop(ScreenCaptureMethod method) { + if (method != ScreenCaptureMethod::Auto) return false; + if (ForcedHeadlessForTesting()) { + static std::atomic logged{false}; + if (!logged.exchange(true)) { + LOG_WARN("Windows capturer: CROSSDESK_FORCE_HEADLESS=1 set; treating the " + "desktop as headless until a usbmmidd monitor is plugged"); + } + return true; + } + const auto probe = ProbeDisplayPresence(); + const auto presence = ClassifyDisplayPresence(probe); + // Sessions probe repeatedly; report transitions rather than every probe. + static std::atomic last_logged{-1}; + if (last_logged.exchange(static_cast(presence)) != + static_cast(presence)) { + if (presence == DisplayPresence::headless) { + LOG_WARN( + "Windows capturer: no attached display (active_paths={}, " + "available_targets={}, desktop_devices={}, monitors={}); entering " + "headless compatibility mode. Resolution is fixed by Windows and " + "capture uses GDI. Attach a monitor or an HDMI dummy plug to " + "restore GPU capture.", + probe.active_paths, probe.available_targets, probe.desktop_devices, + probe.attached_monitors); + } else if (presence == DisplayPresence::unknown) { + LOG_WARN("Windows capturer: display presence unknown (query_ok={}, " + "desktop_devices={}); using the normal backend order", + probe.query_succeeded, probe.desktop_devices); + } else { + LOG_INFO("Windows capturer: attached display detected " + "(available_targets={}, monitors={})", + probe.available_targets, probe.attached_monitors); + } + } + return presence == DisplayPresence::headless; +} + std::string BuildSecureCaptureCommand(int left, int top, int width, int height, bool show_cursor, const std::string& stage, @@ -502,25 +561,22 @@ int ScreenCapturerWin::Init(const int fps, cb_desktop_data cb) { }; impl_.reset(); + const bool headless = DetectHeadlessDesktop(capture_method_); + headless_compat_.store(headless, std::memory_order_relaxed); int ret = -1; - auto try_init = [&](std::unique_ptr candidate) { + for (const auto& create : PreferredBackends(headless)) { + std::unique_ptr candidate = create(); ret = candidate ? candidate->Init(fps_, cb_) : -1; - if (ret != 0) { - LOG_WARN("Windows capturer: {} init failed (ret={})", - CaptureBackendName(candidate.get()), ret); - return false; + if (ret == 0) { + impl_ = std::move(candidate); + break; } - impl_ = std::move(candidate); - return true; - }; - // Short-circuiting also keeps the unused WGC plugin unloaded. - if ((IsBackendEnabled(ScreenCaptureMethod::Dxgi) && - try_init(std::make_unique())) || - (IsBackendEnabled(ScreenCaptureMethod::Wgc) && - try_init(WgcPluginCapturer::Create())) || - (IsBackendEnabled(ScreenCaptureMethod::Gdi) && - try_init(std::make_unique()))) { - LOG_INFO("Windows capturer: using {}", CaptureBackendName(impl_.get())); + LOG_WARN("Windows capturer: {} init failed (ret={})", + CaptureBackendName(candidate.get()), ret); + } + if (impl_) { + LOG_INFO("Windows capturer: using {}{}", CaptureBackendName(impl_.get()), + headless ? " (headless compatibility mode)" : ""); BuildCanonicalFromImpl(); monitor_index_.store(0, std::memory_order_relaxed); return 0; @@ -533,6 +589,9 @@ int ScreenCapturerWin::Init(const int fps, cb_desktop_data cb) { int ScreenCapturerWin::Destroy() { Stop(); + // Stop() releases the virtual display on a worker; finish it here so the + // provisioner is idle before its owner goes away. + JoinVirtualDisplayThread(); paused_.store(false, std::memory_order_relaxed); if (impl_) { impl_->Destroy(); @@ -651,6 +710,51 @@ bool ScreenCapturerWin::TryStartBackend( return true; } +std::vector +ScreenCapturerWin::PreferredBackends(bool headless) const { + // A headless desktop goes straight to GDI: DXGI has no output to duplicate + // and WGC has no monitor item. + std::vector factories; + if (!headless && IsBackendEnabled(ScreenCaptureMethod::Dxgi)) { + factories.push_back([] { return std::make_unique(); }); + } + if (!headless && IsBackendEnabled(ScreenCaptureMethod::Wgc)) { + factories.push_back([] { return WgcPluginCapturer::Create(); }); + } + if (IsBackendEnabled(ScreenCaptureMethod::Gdi)) { + factories.push_back([] { return std::make_unique(); }); + } + return factories; +} + +bool ScreenCapturerWin::StartPreferredBackend(bool headless, + int monitor_index) { + for (const auto& create : PreferredBackends(headless)) { + if (TryStartBackend(create(), monitor_index)) return true; + } + return false; +} + +void ScreenCapturerWin::ResetPostSecureDesktopState() { + secure_desktop_capture_active_.store(false, std::memory_order_relaxed); + post_secure_desktop_waiting_for_frame_.store(false, + std::memory_order_relaxed); + post_secure_desktop_drop_logged_.store(false, std::memory_order_relaxed); + post_secure_desktop_started_tick_.store(0, std::memory_order_relaxed); + SharedSecureDesktopCursorState().SetActive(false); +} + +int ScreenCapturerWin::MarkStarted() { + running_.store(true, std::memory_order_relaxed); + NotifyPrivacyCapture(true); + ResetPostSecureDesktopState(); + if (!secure_capture_thread_.joinable()) { + secure_capture_thread_ = + std::thread([this]() { SecureDesktopCaptureLoop(); }); + } + return 0; +} + int ScreenCapturerWin::Start(bool show_cursor) { if (!impl_) return -1; if (running_.load(std::memory_order_relaxed)) { @@ -670,8 +774,40 @@ int ScreenCapturerWin::Start(bool show_cursor) { // open; logical stream IDs must remain stable. const int requested_monitor = monitor_index_.load(std::memory_order_relaxed); + // A monitor may have been attached or removed since the last session. Pick + // the backend for the desktop as it is now, not as it was at Init. + const bool headless = DetectHeadlessDesktop(capture_method_); + // The virtual display is provisioned off this thread (Start() runs on the + // UI thread and a driver install can take minutes). Begin in compatibility + // mode; MaybeAdoptVirtualDisplay() switches to a GPU backend once ready. + JoinVirtualDisplayThread(); + virtual_display_ready_.store(false, std::memory_order_relaxed); + const bool was_headless = + headless_compat_.exchange(headless, std::memory_order_relaxed); + const bool backend_is_gdi = dynamic_cast(impl_.get()) != nullptr; + int ret = 0; + if (headless != was_headless || (headless && !backend_is_gdi)) { + LOG_INFO("Windows capturer: desktop presence changed (headless={}), " + "reselecting the capture backend", headless); + impl_->Destroy(); + // Keep an instance around on failure so the next Start can retry. + std::unique_ptr previous; + { + std::lock_guard lock(impl_mutex_); + previous = std::move(impl_); + } + if (!StartPreferredBackend(headless, requested_monitor)) { + std::lock_guard lock(impl_mutex_); + impl_ = std::move(previous); + LOG_ERROR("Windows capturer: no capture backend could start"); + return -1; + } + ret = MarkStarted(); + if (ret == 0 && headless) BeginVirtualDisplayProvisioning(); + return ret; + } impl_->Destroy(); - int ret = impl_->Init(fps_, cb_); + ret = impl_->Init(fps_, cb_); if (ret == 0) { RebuildAliasesFromImpl(); // Canonical wire/input slots may differ from this backend's refreshed @@ -706,42 +842,100 @@ int ScreenCapturerWin::Start(bool show_cursor) { } } - running_.store(true, std::memory_order_relaxed); - NotifyPrivacyCapture(true); - SharedSecureDesktopCursorState().SetActive(false); - secure_desktop_capture_active_.store(false, std::memory_order_relaxed); - post_secure_desktop_waiting_for_frame_.store(false, - std::memory_order_relaxed); - post_secure_desktop_drop_logged_.store(false, std::memory_order_relaxed); - post_secure_desktop_started_tick_.store(0, std::memory_order_relaxed); - if (!secure_capture_thread_.joinable()) { - secure_capture_thread_ = - std::thread([this]() { SecureDesktopCaptureLoop(); }); - } - return 0; + ret = MarkStarted(); + if (ret == 0 && headless) BeginVirtualDisplayProvisioning(); + return ret; } int ScreenCapturerWin::Stop() { NotifyPrivacyCapture(false); running_.store(false, std::memory_order_relaxed); last_capture_progress_tick_.store(0, std::memory_order_relaxed); - secure_desktop_capture_active_.store(false, std::memory_order_relaxed); - post_secure_desktop_waiting_for_frame_.store(false, - std::memory_order_relaxed); - post_secure_desktop_drop_logged_.store(false, std::memory_order_relaxed); - post_secure_desktop_started_tick_.store(0, std::memory_order_relaxed); int ret = 0; // Drain management operations before stopping/replacing their backend. + virtual_display_.RequestCancel(); StopSecureCaptureThread(); + JoinVirtualDisplayThread(); + ResetPostSecureDesktopState(); if (impl_) { ret = impl_->Stop(); } SharedCapturedCursorState().Reset(); - SharedSecureDesktopCursorState().SetActive(false); StopSecureDesktopSharedCapture(secure_shared_session_id_); + // After the backend released its duplication of the virtual output. The + // unplug itself can take seconds; do not hold the UI thread for it. + BeginVirtualDisplayRelease(); return ret; } +void ScreenCapturerWin::JoinVirtualDisplayThread() { + if (virtual_display_thread_.joinable()) virtual_display_thread_.join(); +} + +void ScreenCapturerWin::BeginVirtualDisplayProvisioning() { + JoinVirtualDisplayThread(); + virtual_display_thread_ = std::thread([this] { + // A usbmmidd virtual monitor turns the phantom desktop into a real + // display target, so the GPU backends and a chosen resolution apply. + std::string error; + if (virtual_display_.Acquire(kDefaultVirtualDisplayMode, &error)) { + virtual_display_ready_.store(true, std::memory_order_release); + } else if (error != "cancelled") { + LOG_WARN("Windows capturer: no usbmmidd virtual display ({}); staying " + "in headless compatibility mode", + error); + } + }); +} + +void ScreenCapturerWin::BeginVirtualDisplayRelease() { + JoinVirtualDisplayThread(); + virtual_display_ready_.store(false, std::memory_order_relaxed); + if (!virtual_display_.NeedsRelease()) return; + virtual_display_thread_ = std::thread([this] { virtual_display_.Release(); }); +} + +void ScreenCapturerWin::MaybeAdoptVirtualDisplay() { + if (!virtual_display_ready_.exchange(false, std::memory_order_acq_rel)) + return; + if (!running_.load(std::memory_order_relaxed)) return; + if (DetectHeadlessDesktop(capture_method_)) { + LOG_WARN("Windows capturer: virtual display plugged but the desktop " + "still reports no display; staying in compatibility mode"); + return; + } + const int current_monitor = monitor_index_.load(std::memory_order_relaxed); + LOG_INFO("Windows capturer: virtual display ready, switching from " + "compatibility mode to a GPU backend"); + NotifyPrivacyCapture(false); + // Public entry points dereference impl_ under impl_mutex_; take the old + // backend out under the lock, then tear it down without holding it. + std::unique_ptr previous; + { + std::lock_guard lock(impl_mutex_); + previous = std::move(impl_); + } + if (previous) previous->Destroy(); + if (StartPreferredBackend(false, current_monitor)) { + headless_compat_.store(false, std::memory_order_relaxed); + // The owner refreshes its display list and input mapping every tick from + // GetDisplayInfoList(), so the new geometry is picked up without a hint. + } else { + LOG_ERROR("Windows capturer: no GPU backend started on the virtual " + "display; restoring compatibility capture"); + { + std::lock_guard lock(impl_mutex_); + impl_ = std::move(previous); + } + // RestoreMonitor takes impl_mutex_ itself (via SwitchTo). + if (impl_ && impl_->Init(fps_, cb_) == 0) { + RestoreMonitor(current_monitor); + impl_->Start(show_cursor_.load(std::memory_order_relaxed)); + } + } + NotifyPrivacyCapture(true); +} + int ScreenCapturerWin::Pause(int monitor_index) { paused_.store(true, std::memory_order_relaxed); std::lock_guard lock(impl_mutex_); @@ -949,21 +1143,15 @@ bool ScreenCapturerWin::RestartCaptureBackendAfterSecureDesktop() { return true; } - if ((IsBackendEnabled(ScreenCaptureMethod::Dxgi) && - TryStartBackend(std::make_unique(), current_monitor)) || - (IsBackendEnabled(ScreenCaptureMethod::Wgc) && - TryStartBackend(WgcPluginCapturer::Create(), current_monitor)) || - (IsBackendEnabled(ScreenCaptureMethod::Gdi) && - TryStartBackend(std::make_unique(), current_monitor))) { + if (StartPreferredBackend(headless_compat_.load(std::memory_order_relaxed), + current_monitor)) { return true; } - if (impl_) { - LOG_WARN( - "Windows capturer: all backend restart attempts after secure desktop " - "failed (last_ret={})", - ret); - } + LOG_WARN( + "Windows capturer: all backend restart attempts after secure desktop " + "failed (last_ret={})", + ret); return false; } @@ -1405,6 +1593,7 @@ void ScreenCapturerWin::SecureDesktopCaptureLoop() { if (!status.capture_active || status.active_session_id == 0xFFFFFFFF) { StopSecureDesktopSharedCapture(secure_shared_session_id_); + MaybeAdoptVirtualDisplay(); CheckCaptureProgress(now); if (post_secure_restart_pending) { if (now >= post_secure_restart_deadline_tick) { diff --git a/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_win.h b/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_win.h index 1c432a48..38b37c3c 100644 --- a/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_win.h +++ b/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_win.h @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -22,6 +23,8 @@ #include "screen_capturer.h" #include "screen_capture_method.h" #include "privacy_controller.h" +// Relative: platform include directories stay private to the capture target. +#include "../virtual_display/virtual_display_provisioner.h" namespace crossdesk { @@ -50,6 +53,8 @@ class ScreenCapturerWin : public ScreenCapturer { std::vector GetDisplayInfoList() override; int GetCurrentMonitorIndex() const override; + // True while this session captures a usbmmidd monitor it plugged itself. + bool IsVirtualDisplayActive() const { return virtual_display_.active(); } void SetPrivacyController(PrivacyController* privacy) override { privacy_ = privacy; } // Set before Init(), while capture is stopped. void SetCaptureMethod(ScreenCaptureMethod method) { capture_method_ = method; } @@ -58,6 +63,7 @@ class ScreenCapturerWin : public ScreenCapturer { static bool CurrentFrameIsFromSecureDesktop(); private: + using BackendFactory = std::function()>; std::unique_ptr impl_; // Serializes backend replacement (secure-desktop recovery runs on the // management thread) against the public entry points that dereference it. @@ -87,6 +93,16 @@ class ScreenCapturerWin : public ScreenCapturer { std::atomic paused_{false}; std::atomic show_cursor_{true}; bool applied_show_cursor_ = true; + std::atomic headless_compat_{false}; + // Session-scoped: plugged on a headless desktop after Start() returns and + // released after Stop(), so an attached physical monitor is never shadowed + // between sessions. Both run on virtual_display_thread_ because the helper + // may need up to two minutes to install the driver; Start() itself begins + // in GDI compatibility mode and the management thread adopts the virtual + // display (switching to a GPU backend) once virtual_display_ready_ is set. + VirtualDisplayProvisioner virtual_display_; + std::thread virtual_display_thread_; + std::atomic virtual_display_ready_{false}; std::atomic monitor_index_{0}; std::atomic secure_desktop_capture_active_{false}; std::atomic post_secure_desktop_waiting_for_frame_{false}; @@ -112,8 +128,16 @@ class ScreenCapturerWin : public ScreenCapturer { void BuildCanonicalFromImpl(); void RebuildAliasesFromImpl(bool preserve_backend_slots = false); void RestoreMonitor(int monitor_index); + // Backends in preference order for the current desktop; each factory is + // invoked lazily so an unused WGC plugin is never loaded. + std::vector PreferredBackends(bool headless) const; bool TryStartBackend(std::unique_ptr candidate, int monitor_index); + bool StartPreferredBackend(bool headless, int monitor_index); + void ResetPostSecureDesktopState(); + // Common tail of every successful Start(): publish running state and + // launch the management thread. + int MarkStarted(); void EmitCapturedFrame(unsigned char* data, int size, int width, int height, const char* stream_id, const MiniRtcNativeVideoFrame* native_frame = nullptr, @@ -121,6 +145,13 @@ class ScreenCapturerWin : public ScreenCapturer { void StopSecureCaptureThread(); bool RestartCaptureBackendAfterSecureDesktop(); void CheckCaptureProgress(ULONGLONG now); + // Virtual display provisioning runs off the caller's thread. + void JoinVirtualDisplayThread(); + void BeginVirtualDisplayProvisioning(); + void BeginVirtualDisplayRelease(); + // Management thread: once the worker plugged the virtual display, replace + // the GDI compatibility backend with a GPU backend on that display. + void MaybeAdoptVirtualDisplay(); void ApplyCursorCaptureSetting(); void SecureDesktopCaptureLoop(); bool GetCurrentCaptureRegion(int* left, int* top, int* width, int* height, diff --git a/apps/desktop/src/platform/windows/service/session_helper_main.cpp b/apps/desktop/src/platform/windows/service/session_helper_main.cpp index 535021e9..c9c9751e 100644 --- a/apps/desktop/src/platform/windows/service/session_helper_main.cpp +++ b/apps/desktop/src/platform/windows/service/session_helper_main.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -25,6 +26,8 @@ #include "path_manager.h" #include "rd_log.h" #include "session_helper_shared.h" +#include "usbmmidd_policy.h" +#include "usbmmidd_virtual_display.h" #include "windows_cursor_state.h" #include "cursor_draw.h" #include "windows_input_marker.h" @@ -453,8 +456,193 @@ std::string BuildHelperStatusResponse(HelperState* helper_state) { return json.dump(); } +// Driver install and monitor plug/unplug take seconds. They run on a worker +// so the status pipe (polled by the service every second) stays responsive; +// the GUI polls the status command until |busy| clears. +struct VirtualDisplayJobState { + std::mutex mutex; + std::thread worker; + bool busy = false; + // Monitors this helper plugged and has not yet unplugged. Unplug releases + // only these, so a monitor the user plugged for other software survives. + int plugged_by_helper = 0; + // An unplug that arrived while a plug was running; the plug worker honours + // it on completion so a cancelled session never leaves a monitor behind. + bool unplug_queued = false; + std::string last_action; + std::string last_error; + + ~VirtualDisplayJobState() { + if (worker.joinable()) worker.join(); + } +}; + +// The helper pipe grants read/write to every authenticated user so that the +// GUI can poll status without elevation. Plug/unplug install a driver and +// change the interactive desktop's topology as SYSTEM, so those commands are +// accepted only from a CrossDesk executable in this helper's own directory. +bool IsTrustedVirtualDisplayClient(HANDLE pipe) { + ULONG client_pid = 0; + if (!GetNamedPipeClientProcessId(pipe, &client_pid)) return false; + HANDLE process = + OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, client_pid); + if (process == nullptr) return false; + wchar_t image[MAX_PATH] = {0}; + DWORD length = MAX_PATH; + const bool queried = + QueryFullProcessImageNameW(process, 0, image, &length) && length > 0; + CloseHandle(process); + if (!queried) return false; + + wchar_t own[MAX_PATH] = {0}; + const DWORD own_length = GetModuleFileNameW(nullptr, own, MAX_PATH); + if (own_length == 0 || own_length >= MAX_PATH) return false; + std::error_code ignored; + const std::filesystem::path client(image); + if (_wcsicmp(client.filename().c_str(), L"CrossDesk.exe") != 0) return false; + const std::filesystem::path client_directory = + std::filesystem::weakly_canonical(client.parent_path(), ignored); + const std::filesystem::path own_directory = std::filesystem::weakly_canonical( + std::filesystem::path(own).parent_path(), ignored); + return _wcsicmp(client_directory.c_str(), own_directory.c_str()) == 0; +} + +std::string BuildVirtualDisplayStatusResponse(VirtualDisplayJobState* job) { + const crossdesk::UsbmmiddStatus status = crossdesk::QueryUsbmmiddStatus(); + Json json; + json["ok"] = true; + json["package_available"] = status.package_available; + json["package_directory"] = status.package_directory; + json["driver_installed"] = status.driver_installed; + json["plugged_monitors"] = status.plugged_monitors; + std::lock_guard lock(job->mutex); + json["busy"] = job->busy; + json["last_action"] = job->last_action; + json["last_error"] = job->last_error; + return json.dump(); +} + +std::string StartVirtualDisplayJob( + VirtualDisplayJobState* job, const char* action, + std::function work) { + std::lock_guard lock(job->mutex); + if (job->busy) return BuildErrorJson("virtual_display_busy"); + // A finished worker no longer touches the mutex; joining under it is safe. + if (job->worker.joinable()) job->worker.join(); + job->busy = true; + job->last_action = action; + job->last_error.clear(); + LOG_INFO("Session helper virtual display job started: {}", action); + job->worker = std::thread([job, action, work = std::move(work)]() { + std::string error; + const bool ok = work(&error); + if (ok) { + LOG_INFO("Session helper virtual display job finished: {}", action); + } else { + LOG_WARN("Session helper virtual display job failed: {}, error={}", + action, error); + } + std::lock_guard lock(job->mutex); + job->busy = false; + job->last_error = ok ? std::string() : (error.empty() ? "unknown" : error); + }); + Json json; + json["ok"] = true; + json["pending"] = true; + return json.dump(); +} + +std::string HandleVirtualDisplayCommand(const std::string& command, + HANDLE pipe, + VirtualDisplayJobState* job, + bool* handled) { + *handled = true; + if (command == crossdesk::kCrossDeskVirtualDisplayStatusCommand) { + return BuildVirtualDisplayStatusResponse(job); + } + const bool mutating = + command == crossdesk::kCrossDeskVirtualDisplayUnplugCommand || + command.rfind(crossdesk::kCrossDeskVirtualDisplayPlugCommandPrefix, 0) == + 0; + if (mutating && !IsTrustedVirtualDisplayClient(pipe)) { + LOG_WARN("Session helper rejected virtual display command '{}' from an " + "untrusted client", + command); + return BuildErrorJson("virtual_display_client_not_allowed", + ERROR_ACCESS_DENIED); + } + if (command == crossdesk::kCrossDeskVirtualDisplayUnplugCommand) { + { + std::lock_guard lock(job->mutex); + if (job->busy && job->last_action == "plug") { + job->unplug_queued = true; + Json json; + json["ok"] = true; + json["pending"] = true; + json["queued"] = true; + return json.dump(); + } + } + return StartVirtualDisplayJob(job, "unplug", [job](std::string* error) { + int count = 0; + { + std::lock_guard lock(job->mutex); + count = job->plugged_by_helper; + } + if (!crossdesk::ReleaseUsbmmiddDisplays(count, error)) return false; + std::lock_guard lock(job->mutex); + job->plugged_by_helper = 0; + return true; + }); + } + crossdesk::VirtualDisplayMode mode; + if (crossdesk::ParseVirtualDisplayPlugCommand(command, &mode)) { + return StartVirtualDisplayJob(job, "plug", [job, mode](std::string* error) { + { + // This job only starts when no other job runs, so any unplug queued + // behind an earlier plug has already been honoured. + std::lock_guard lock(job->mutex); + job->unplug_queued = false; + } + int plugged = 0; + const bool ok = crossdesk::ProvisionUsbmmiddDisplay(mode, &plugged, error); + int to_release = 0; + { + std::lock_guard lock(job->mutex); + job->plugged_by_helper += plugged; + if (job->unplug_queued) { + job->unplug_queued = false; + to_release = job->plugged_by_helper; + } + } + if (to_release > 0) { + LOG_INFO("Session helper virtual display: releasing {} monitor(s) " + "for an unplug queued during the plug", + to_release); + std::string release_error; + if (crossdesk::ReleaseUsbmmiddDisplays(to_release, &release_error)) { + std::lock_guard lock(job->mutex); + job->plugged_by_helper = 0; + } else { + LOG_WARN("Session helper virtual display: queued unplug failed, " + "error={}", + release_error); + } + } + return ok; + }); + } + if (command.rfind(crossdesk::kCrossDeskVirtualDisplayPlugCommandPrefix, 0) == + 0) { + return BuildErrorJson("invalid_virtual_display_mode"); + } + *handled = false; + return {}; +} + void HelperIpcServerLoop(HANDLE stop_event, DWORD session_id, - HelperState* helper_state) { + HelperState* helper_state, + VirtualDisplayJobState* virtual_display_job) { IpcSecurityAttributes security_attributes; SECURITY_ATTRIBUTES* pipe_attributes = nullptr; if (security_attributes.Initialize()) { @@ -523,11 +711,14 @@ void HelperIpcServerLoop(HANDLE stop_event, DWORD session_id, bytes_read > 0) { std::string command(buffer, buffer + bytes_read); std::string response; + bool handled = false; if (command == crossdesk::kCrossDeskSessionHelperStatusCommand) { response = BuildHelperStatusResponse(helper_state); } else if (command == "ping") { response = "{\"ok\":true,\"reply\":\"pong\"}"; - } else { + } else if (response = HandleVirtualDisplayCommand( + command, pipe, virtual_display_job, &handled); + !handled) { response = BuildErrorJson("unknown_command"); } @@ -1865,8 +2056,9 @@ int main(int argc, char* argv[]) { GetLastError()); } + VirtualDisplayJobState virtual_display_job; std::thread ipc_thread(HelperIpcServerLoop, stop_event, current_session_id, - &helper_state); + &helper_state, &virtual_display_job); std::string last_desktop_name; bool last_lock_app = false; diff --git a/apps/desktop/src/platform/windows/virtual_display/usbmmidd_policy.h b/apps/desktop/src/platform/windows/virtual_display/usbmmidd_policy.h new file mode 100644 index 00000000..d29932e8 --- /dev/null +++ b/apps/desktop/src/platform/windows/virtual_display/usbmmidd_policy.h @@ -0,0 +1,148 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-09-20 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef CROSSDESK_USBMMIDD_POLICY_H_ +#define CROSSDESK_USBMMIDD_POLICY_H_ + +#include +#include +#include +#include +#include +#include + +namespace crossdesk { + +// Amyuni "USB Mobile Monitor" indirect display driver (usbmmidd_v2). The +// package ships deviceinstaller64.exe, usbmmIdd.inf/.cat and usbmmIdd.dll. It +// is bundled from apps/desktop/resources/windows/usbmmidd_v2 by the installer +// and the portable archive; a user may also drop it next to the executables. +inline constexpr wchar_t kUsbmmiddHardwareId[] = L"usbmmidd"; +// Set when CrossDesk itself installed the driver. The NSIS uninstaller reads +// it (64-bit registry view) and removes the driver only in that case. +inline constexpr wchar_t kUsbmmiddInstallMarkerKey[] = L"SOFTWARE\\CrossDesk"; +inline constexpr wchar_t kUsbmmiddInstallMarkerValue[] = + L"UsbmmiddDriverInstalled"; +inline constexpr wchar_t kUsbmmiddAdapterDescription[] = + L"USB Mobile Monitor Virtual Display"; +#if defined(_WIN64) +inline constexpr wchar_t kUsbmmiddInstallerName[] = L"deviceinstaller64.exe"; +#else +inline constexpr wchar_t kUsbmmiddInstallerName[] = L"deviceinstaller.exe"; +#endif +inline constexpr wchar_t kUsbmmiddInfName[] = L"usbmmidd.inf"; +inline constexpr const wchar_t* kUsbmmiddPackageDirectories[] = { + L"usbmmidd_v2", L"usbmmidd", L"drivers\\usbmmidd_v2"}; +// The driver accepts up to four monitors; remote capture needs one. +inline constexpr int kUsbmmiddMaxMonitors = 4; + +// Session helper IPC sub-protocol (see session_helper_shared.h for the pipe). +// Plug/unplug run asynchronously in the helper; poll the status command. +inline constexpr char kCrossDeskVirtualDisplayStatusCommand[] = + "virtual-display-status"; +inline constexpr char kCrossDeskVirtualDisplayPlugCommandPrefix[] = + "virtual-display-plug:"; +inline constexpr char kCrossDeskVirtualDisplayUnplugCommand[] = + "virtual-display-unplug"; + +struct VirtualDisplayMode { + int width = 0; + int height = 0; + bool operator==(const VirtualDisplayMode& other) const { + return width == other.width && height == other.height; + } + bool operator!=(const VirtualDisplayMode& other) const { + return !(*this == other); + } +}; + +inline constexpr VirtualDisplayMode kDefaultVirtualDisplayMode{1920, 1080}; + +inline bool ContainsCaseInsensitive(const std::wstring& haystack, + const std::wstring& needle) { + if (needle.empty()) return true; + if (haystack.size() < needle.size()) return false; + const auto it = std::search( + haystack.begin(), haystack.end(), needle.begin(), needle.end(), + [](wchar_t a, wchar_t b) { return std::towlower(a) == std::towlower(b); }); + return it != haystack.end(); +} + +// EnumDisplayDevices reports the adapter description plus a PnP id such as +// "PCI\VEN_..." or "ROOT\DISPLAY\0000". The IDD adapter carries the hardware +// id in its device id; the description is a second signal for older packages. +inline bool IsUsbmmiddAdapter(const std::wstring& device_string, + const std::wstring& device_id) { + return ContainsCaseInsensitive(device_id, kUsbmmiddHardwareId) || + ContainsCaseInsensitive(device_string, kUsbmmiddAdapterDescription); +} + +// Exact match first; otherwise the largest mode that fits inside the request, +// so a 4K request on a 1080p-only package still yields the best available +// picture; otherwise the smallest mode the driver offers. +inline bool ChooseVirtualDisplayMode( + const std::vector& supported, + VirtualDisplayMode requested, VirtualDisplayMode* chosen) { + if (chosen == nullptr || supported.empty()) return false; + const VirtualDisplayMode* best = nullptr; + const VirtualDisplayMode* smallest = nullptr; + auto area = [](const VirtualDisplayMode& mode) { + return static_cast(mode.width) * mode.height; + }; + for (const auto& mode : supported) { + if (mode.width <= 0 || mode.height <= 0) continue; + if (mode == requested) { + *chosen = mode; + return true; + } + if (mode.width <= requested.width && mode.height <= requested.height && + (best == nullptr || area(mode) > area(*best))) { + best = &mode; + } + if (smallest == nullptr || area(mode) < area(*smallest)) smallest = &mode; + } + if (best != nullptr) { + *chosen = *best; + return true; + } + if (smallest != nullptr) { + *chosen = *smallest; + return true; + } + return false; +} + +// "virtual-display-plug:1920x1080" +inline std::string BuildVirtualDisplayPlugCommand(VirtualDisplayMode mode) { + return std::string(kCrossDeskVirtualDisplayPlugCommandPrefix) + + std::to_string(mode.width) + "x" + std::to_string(mode.height); +} + +inline bool ParseVirtualDisplayPlugCommand(const std::string& command, + VirtualDisplayMode* mode) { + if (mode == nullptr) return false; + const std::string prefix(kCrossDeskVirtualDisplayPlugCommandPrefix); + if (command.rfind(prefix, 0) != 0) return false; + const std::string payload = command.substr(prefix.size()); + const size_t separator = payload.find('x'); + if (separator == std::string::npos || separator == 0 || + separator + 1 >= payload.size()) { + return false; + } + char* end = nullptr; + const long width = std::strtol(payload.c_str(), &end, 10); + if (end != payload.c_str() + separator) return false; + const long height = std::strtol(payload.c_str() + separator + 1, &end, 10); + if (end == nullptr || *end != '\0') return false; + if (width <= 0 || height <= 0 || width > 16384 || height > 16384) return false; + mode->width = static_cast(width); + mode->height = static_cast(height); + return true; +} + +} // namespace crossdesk + +#endif // CROSSDESK_USBMMIDD_POLICY_H_ diff --git a/apps/desktop/src/platform/windows/virtual_display/usbmmidd_virtual_display.cpp b/apps/desktop/src/platform/windows/virtual_display/usbmmidd_virtual_display.cpp new file mode 100644 index 00000000..6b4895a5 --- /dev/null +++ b/apps/desktop/src/platform/windows/virtual_display/usbmmidd_virtual_display.cpp @@ -0,0 +1,384 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-09-20 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#include "usbmmidd_virtual_display.h" + +// clang-format off +#include +#include // defines GUID_DEVCLASS_DISPLAY in this unit +#include +#include +// clang-format on + +#include +#include +#include + +#include "rd_log.h" + +namespace crossdesk { + +namespace { + +constexpr DWORD kInstallerPollIntervalMs = 250; +constexpr DWORD kDriverInstallTimeoutMs = 90 * 1000; +constexpr DWORD kInstallerCommandTimeoutMs = 20 * 1000; + +std::string Narrow(const std::wstring& value) { + if (value.empty()) return {}; + const int size = WideCharToMultiByte(CP_UTF8, 0, value.data(), + static_cast(value.size()), nullptr, + 0, nullptr, nullptr); + std::string result(static_cast((std::max)(size, 0)), '\0'); + if (size > 0) { + WideCharToMultiByte(CP_UTF8, 0, value.data(), + static_cast(value.size()), result.data(), size, + nullptr, nullptr); + } + return result; +} + +void SetError(std::string* error, const std::string& message) { + if (error != nullptr) *error = message; +} + +std::filesystem::path CurrentExecutableDirectory() { + wchar_t module_path[MAX_PATH] = {0}; + const DWORD length = GetModuleFileNameW(nullptr, module_path, MAX_PATH); + if (length == 0 || length >= MAX_PATH) return {}; + return std::filesystem::path(module_path).parent_path(); +} + +struct UsbmmiddAdapter { + std::wstring device_name; // \\.\DISPLAYn + bool attached_to_desktop = false; +}; + +std::vector EnumerateUsbmmiddAdapters() { + std::vector adapters; + for (DWORD index = 0;; ++index) { + DISPLAY_DEVICEW device{sizeof(device)}; + if (!EnumDisplayDevicesW(nullptr, index, &device, 0)) break; + if (!IsUsbmmiddAdapter(device.DeviceString, device.DeviceID)) continue; + UsbmmiddAdapter adapter; + adapter.device_name = device.DeviceName; + adapter.attached_to_desktop = + (device.StateFlags & DISPLAY_DEVICE_ATTACHED_TO_DESKTOP) != 0; + adapters.push_back(std::move(adapter)); + } + return adapters; +} + +// Runs " " hidden with the package folder as the +// working directory (the installer resolves usbmmIdd.dll relative to it). +bool RunUsbmmiddInstaller(const UsbmmiddPackage& package, + const std::wstring& arguments, DWORD timeout_ms, + std::string* error) { + if (!package.Available()) { + SetError(error, "usbmmidd_package_missing"); + return false; + } + std::wstring command_line = + L"\"" + package.installer.wstring() + L"\" " + arguments; + STARTUPINFOW startup{}; + startup.cb = sizeof(startup); + PROCESS_INFORMATION process{}; + const std::wstring working_directory = package.directory.wstring(); + if (!CreateProcessW(package.installer.c_str(), command_line.data(), nullptr, + nullptr, FALSE, CREATE_NO_WINDOW, nullptr, + working_directory.c_str(), &startup, &process)) { + const DWORD code = GetLastError(); + // 740 (ERROR_ELEVATION_REQUIRED) is the non-elevated GUI fallback path. + SetError(error, "usbmmidd_installer_launch_failed:" + std::to_string(code)); + return false; + } + CloseHandle(process.hThread); + const DWORD wait = WaitForSingleObject(process.hProcess, timeout_ms); + DWORD exit_code = 0; + if (wait != WAIT_OBJECT_0) { + TerminateProcess(process.hProcess, 1); + CloseHandle(process.hProcess); + SetError(error, wait == WAIT_TIMEOUT ? "usbmmidd_installer_timeout" + : "usbmmidd_installer_wait_failed"); + return false; + } + GetExitCodeProcess(process.hProcess, &exit_code); + CloseHandle(process.hProcess); + if (exit_code != 0) { + SetError(error, "usbmmidd_installer_exit:" + std::to_string(exit_code)); + return false; + } + return true; +} + +// Waits until |predicate| holds or the deadline passes. +template +bool WaitUntil(DWORD timeout_ms, Predicate predicate) { + const ULONGLONG deadline = GetTickCount64() + timeout_ms; + while (true) { + if (predicate()) return true; + const ULONGLONG now = GetTickCount64(); + if (now >= deadline) return false; + Sleep(static_cast( + (std::min)(static_cast(kInstallerPollIntervalMs), + deadline - now))); + } +} + +// The uninstaller removes the driver only when CrossDesk installed it, so a +// driver the user set up for other software is left alone. The helper runs as +// SYSTEM and the in-process path is elevated, so HKLM is writable here; a +// failure only costs a stale device node after uninstall. +void MarkUsbmmiddDriverInstalledByCrossDesk() { + HKEY key = nullptr; + const LONG open = RegCreateKeyExW(HKEY_LOCAL_MACHINE, kUsbmmiddInstallMarkerKey, + 0, nullptr, 0, KEY_SET_VALUE, nullptr, &key, + nullptr); + if (open != ERROR_SUCCESS) { + LOG_WARN("usbmmidd: failed to record the driver install marker, error={}", + open); + return; + } + const DWORD value = 1; + const LONG set = RegSetValueExW(key, kUsbmmiddInstallMarkerValue, 0, REG_DWORD, + reinterpret_cast(&value), + sizeof(value)); + RegCloseKey(key); + if (set != ERROR_SUCCESS) { + LOG_WARN("usbmmidd: failed to record the driver install marker, error={}", + set); + } +} + +bool DeviceHasUsbmmiddHardwareId(HDEVINFO device_info, + SP_DEVINFO_DATA* device_data) { + DWORD required = 0; + SetupDiGetDeviceRegistryPropertyW(device_info, device_data, SPDRP_HARDWAREID, + nullptr, nullptr, 0, &required); + if (required == 0) return false; + std::vector buffer(required / sizeof(wchar_t) + 2, L'\0'); + if (!SetupDiGetDeviceRegistryPropertyW( + device_info, device_data, SPDRP_HARDWAREID, nullptr, + reinterpret_cast(buffer.data()), + static_cast(buffer.size() * sizeof(wchar_t)), nullptr)) { + return false; + } + // REG_MULTI_SZ: hardware ids separated by NUL, terminated by a double NUL. + for (const wchar_t* id = buffer.data(); *id != L'\0'; id += wcslen(id) + 1) { + if (ContainsCaseInsensitive(id, kUsbmmiddHardwareId)) return true; + } + return false; +} + +std::vector EnumerateModes(const std::wstring& device) { + std::vector modes; + for (DWORD index = 0;; ++index) { + DEVMODEW mode{}; + mode.dmSize = sizeof(mode); + if (!EnumDisplaySettingsExW(device.c_str(), index, &mode, 0)) break; + if (mode.dmBitsPerPel != 32) continue; + const VirtualDisplayMode candidate{static_cast(mode.dmPelsWidth), + static_cast(mode.dmPelsHeight)}; + if (std::find(modes.begin(), modes.end(), candidate) == modes.end()) { + modes.push_back(candidate); + } + } + return modes; +} + +} // namespace + +UsbmmiddPackage LocateUsbmmiddPackage() { + UsbmmiddPackage package; + const std::filesystem::path base = CurrentExecutableDirectory(); + if (base.empty()) return package; + std::error_code ignored; + for (const wchar_t* relative : kUsbmmiddPackageDirectories) { + const std::filesystem::path directory = base / relative; + const std::filesystem::path installer = directory / kUsbmmiddInstallerName; + if (!std::filesystem::is_regular_file(installer, ignored)) continue; + package.directory = directory; + package.installer = installer; + const std::filesystem::path inf = directory / kUsbmmiddInfName; + if (std::filesystem::is_regular_file(inf, ignored)) package.inf = inf; + break; + } + return package; +} + +int CountUsbmmiddMonitors() { + int count = 0; + for (const auto& adapter : EnumerateUsbmmiddAdapters()) { + if (adapter.attached_to_desktop) ++count; + } + return count; +} + +bool IsUsbmmiddDriverInstalled() { + const HDEVINFO device_info = SetupDiGetClassDevsW( + &GUID_DEVCLASS_DISPLAY, nullptr, nullptr, DIGCF_PRESENT); + if (device_info == INVALID_HANDLE_VALUE) { + // Fall back to the adapter list; it misses an installed-but-idle driver. + return !EnumerateUsbmmiddAdapters().empty(); + } + bool installed = false; + SP_DEVINFO_DATA device_data{}; + device_data.cbSize = sizeof(device_data); + for (DWORD index = 0; + !installed && SetupDiEnumDeviceInfo(device_info, index, &device_data); + ++index) { + installed = DeviceHasUsbmmiddHardwareId(device_info, &device_data); + } + SetupDiDestroyDeviceInfoList(device_info); + return installed || !EnumerateUsbmmiddAdapters().empty(); +} + +UsbmmiddStatus QueryUsbmmiddStatus() { + UsbmmiddStatus status; + const UsbmmiddPackage package = LocateUsbmmiddPackage(); + status.package_available = package.Available(); + status.package_directory = Narrow(package.directory.wstring()); + status.driver_installed = IsUsbmmiddDriverInstalled(); + status.plugged_monitors = CountUsbmmiddMonitors(); + return status; +} + +bool InstallUsbmmiddDriver(const UsbmmiddPackage& package, std::string* error) { + if (IsUsbmmiddDriverInstalled()) return true; + if (package.inf.empty()) { + SetError(error, "usbmmidd_inf_missing"); + return false; + } + LOG_INFO("usbmmidd: installing driver from '{}'", + Narrow(package.directory.wstring())); + const std::wstring arguments = L"install \"" + package.inf.wstring() + + L"\" " + kUsbmmiddHardwareId; + if (!RunUsbmmiddInstaller(package, arguments, kDriverInstallTimeoutMs, + error)) { + return false; + } + if (!WaitUntil(5000, IsUsbmmiddDriverInstalled)) { + SetError(error, "usbmmidd_driver_not_present_after_install"); + return false; + } + MarkUsbmmiddDriverInstalledByCrossDesk(); + return true; +} + +bool PlugUsbmmiddMonitor(const UsbmmiddPackage& package, DWORD timeout_ms, + std::string* error) { + const int before = CountUsbmmiddMonitors(); + if (before >= kUsbmmiddMaxMonitors) { + SetError(error, "usbmmidd_monitor_limit"); + return false; + } + if (!RunUsbmmiddInstaller(package, L"enableidd 1", + kInstallerCommandTimeoutMs, error)) { + return false; + } + if (!WaitUntil(timeout_ms, + [before] { return CountUsbmmiddMonitors() > before; })) { + SetError(error, "usbmmidd_monitor_not_attached"); + return false; + } + return true; +} + +bool UnplugUsbmmiddMonitor(const UsbmmiddPackage& package, DWORD timeout_ms, + std::string* error) { + const int before = CountUsbmmiddMonitors(); + if (before == 0) return true; + if (!RunUsbmmiddInstaller(package, L"enableidd 0", + kInstallerCommandTimeoutMs, error)) { + return false; + } + if (!WaitUntil(timeout_ms, + [before] { return CountUsbmmiddMonitors() < before; })) { + SetError(error, "usbmmidd_monitor_still_attached"); + return false; + } + return true; +} + +bool ApplyUsbmmiddDisplayMode(VirtualDisplayMode requested, + std::string* error) { + std::wstring device; + for (const auto& adapter : EnumerateUsbmmiddAdapters()) { + if (adapter.attached_to_desktop) { + device = adapter.device_name; + break; + } + } + if (device.empty()) { + SetError(error, "usbmmidd_monitor_not_attached"); + return false; + } + VirtualDisplayMode chosen; + if (!ChooseVirtualDisplayMode(EnumerateModes(device), requested, &chosen)) { + SetError(error, "usbmmidd_no_display_modes"); + return false; + } + DEVMODEW current{}; + current.dmSize = sizeof(current); + if (EnumDisplaySettingsExW(device.c_str(), ENUM_CURRENT_SETTINGS, ¤t, + 0) && + static_cast(current.dmPelsWidth) == chosen.width && + static_cast(current.dmPelsHeight) == chosen.height) { + return true; + } + DEVMODEW mode{}; + mode.dmSize = sizeof(mode); + mode.dmFields = DM_PELSWIDTH | DM_PELSHEIGHT | DM_BITSPERPEL; + mode.dmPelsWidth = static_cast(chosen.width); + mode.dmPelsHeight = static_cast(chosen.height); + mode.dmBitsPerPel = 32; + const LONG result = ChangeDisplaySettingsExW(device.c_str(), &mode, nullptr, + CDS_UPDATEREGISTRY, nullptr); + if (result != DISP_CHANGE_SUCCESSFUL) { + SetError(error, "usbmmidd_mode_change_failed:" + std::to_string(result)); + return false; + } + LOG_INFO("usbmmidd: display '{}' set to {}x{} (requested {}x{})", + Narrow(device), chosen.width, chosen.height, requested.width, + requested.height); + return true; +} + +bool ProvisionUsbmmiddDisplay(VirtualDisplayMode mode, int* plugged, + std::string* error) { + if (plugged != nullptr) *plugged = 0; + const UsbmmiddPackage package = LocateUsbmmiddPackage(); + if (!package.Available()) { + SetError(error, "usbmmidd_package_missing"); + return false; + } + if (!InstallUsbmmiddDriver(package, error)) return false; + if (CountUsbmmiddMonitors() == 0) { + if (!PlugUsbmmiddMonitor(package, 10 * 1000, error)) return false; + if (plugged != nullptr) *plugged = 1; + } + std::string mode_error; + if (!ApplyUsbmmiddDisplayMode(mode, &mode_error)) { + LOG_WARN("usbmmidd: keeping the driver default mode, error={}", mode_error); + } + return true; +} + +bool ReleaseUsbmmiddDisplays(int count, std::string* error) { + const int to_unplug = (std::min)(count, CountUsbmmiddMonitors()); + if (to_unplug <= 0) return true; + const UsbmmiddPackage package = LocateUsbmmiddPackage(); + if (!package.Available()) { + SetError(error, "usbmmidd_package_missing"); + return false; + } + for (int index = 0; index < to_unplug; ++index) { + if (!UnplugUsbmmiddMonitor(package, 10 * 1000, error)) return false; + } + return true; +} + +} // namespace crossdesk diff --git a/apps/desktop/src/platform/windows/virtual_display/usbmmidd_virtual_display.h b/apps/desktop/src/platform/windows/virtual_display/usbmmidd_virtual_display.h new file mode 100644 index 00000000..3c7963ef --- /dev/null +++ b/apps/desktop/src/platform/windows/virtual_display/usbmmidd_virtual_display.h @@ -0,0 +1,69 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-09-20 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef CROSSDESK_USBMMIDD_VIRTUAL_DISPLAY_H_ +#define CROSSDESK_USBMMIDD_VIRTUAL_DISPLAY_H_ + +#include + +#include +#include + +#include "usbmmidd_policy.h" + +namespace crossdesk { + +// Plug/unplug talks to the driver through Amyuni's installer and needs +// administrator rights inside the interactive session. The session helper +// (SYSTEM, console session) is the intended caller; the GUI only calls these +// directly when it is elevated and no helper is reachable. + +struct UsbmmiddPackage { + std::filesystem::path directory; + std::filesystem::path installer; + std::filesystem::path inf; // empty when only the installer is present + bool Available() const { return !installer.empty(); } +}; + +// Looks next to the running executable (usbmmidd_v2\, usbmmidd\, +// drivers\usbmmidd_v2\). Both the GUI and the helper live in the same folder. +UsbmmiddPackage LocateUsbmmiddPackage(); + +struct UsbmmiddStatus { + bool package_available = false; + bool driver_installed = false; + int plugged_monitors = 0; + std::string package_directory; +}; +UsbmmiddStatus QueryUsbmmiddStatus(); + +// Adapters attached to the desktop, i.e. monitors currently plugged in. +int CountUsbmmiddMonitors(); +// Root-enumerated device node present, plugged or not. +bool IsUsbmmiddDriverInstalled(); + +// Each step is idempotent so a retry after a partial failure converges. +bool InstallUsbmmiddDriver(const UsbmmiddPackage& package, std::string* error); +bool PlugUsbmmiddMonitor(const UsbmmiddPackage& package, DWORD timeout_ms, + std::string* error); +bool UnplugUsbmmiddMonitor(const UsbmmiddPackage& package, DWORD timeout_ms, + std::string* error); +// Applies the closest supported mode to the first plugged usbmmidd monitor. +bool ApplyUsbmmiddDisplayMode(VirtualDisplayMode requested, std::string* error); + +// Installs the driver on first use, plugs one monitor when none is attached +// and applies |mode|. A mode failure is logged but keeps the display. +// |plugged| receives how many monitors this call plugged (0 when an existing +// usbmmidd monitor was reused) so the caller can release exactly those. +bool ProvisionUsbmmiddDisplay(VirtualDisplayMode mode, int* plugged, + std::string* error); +// Unplugs up to |count| usbmmidd monitors, never more than are attached. A +// monitor plugged by the user or another program is left alone. +bool ReleaseUsbmmiddDisplays(int count, std::string* error); + +} // namespace crossdesk + +#endif // CROSSDESK_USBMMIDD_VIRTUAL_DISPLAY_H_ diff --git a/apps/desktop/src/platform/windows/virtual_display/virtual_display_provisioner.cpp b/apps/desktop/src/platform/windows/virtual_display/virtual_display_provisioner.cpp new file mode 100644 index 00000000..5fc7e94d --- /dev/null +++ b/apps/desktop/src/platform/windows/virtual_display/virtual_display_provisioner.cpp @@ -0,0 +1,263 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-09-20 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#include "virtual_display_provisioner.h" + +#include +#include +#include +#include +#include + +#include "display_presence.h" +#include "named_pipe_deadline.h" +#include "rd_log.h" +#include "session_helper_shared.h" +#include "usbmmidd_virtual_display.h" + +namespace crossdesk { + +namespace { + +using Json = nlohmann::json; + +constexpr DWORD kHelperStatusTimeoutMs = 500; +constexpr DWORD kHelperCommandTimeoutMs = 2000; +constexpr DWORD kHelperPollIntervalMs = 250; +constexpr DWORD kPlugTimeoutMs = 20 * 1000; +constexpr DWORD kInstallAndPlugTimeoutMs = 120 * 1000; +constexpr DWORD kUnplugTimeoutMs = 10 * 1000; +constexpr DWORD kPresenceSettleTimeoutMs = 5 * 1000; + +struct HelperVirtualDisplayStatus { + bool ok = false; + bool package_available = false; + bool driver_installed = false; + int plugged_monitors = 0; + bool busy = false; + std::string last_error; + std::string error; // transport or helper-reported error +}; + +std::wstring OwnSessionHelperPipeName() { + DWORD session_id = 0xFFFFFFFF; + if (!ProcessIdToSessionId(GetCurrentProcessId(), &session_id)) return {}; + return GetCrossDeskSessionHelperPipeName(session_id); +} + +bool QueryHelper(const std::wstring& pipe, const std::string& command, + DWORD timeout_ms, Json* json, std::string* error) { + std::vector response; + DWORD code = 0; + if (!QueryNamedPipeWithDeadline(pipe, command, timeout_ms, &response, error, + &code)) { + *error += ":" + std::to_string(code); + return false; + } + *json = Json::parse(response.begin(), response.end(), nullptr, false); + if (json->is_discarded() || !json->is_object()) { + *error = "invalid_helper_json"; + return false; + } + if (!json->value("ok", false)) { + *error = json->value("error", std::string("helper_error")); + return false; + } + return true; +} + +HelperVirtualDisplayStatus QueryHelperStatus(const std::wstring& pipe) { + HelperVirtualDisplayStatus status; + Json json; + if (!QueryHelper(pipe, kCrossDeskVirtualDisplayStatusCommand, + kHelperStatusTimeoutMs, &json, &status.error)) { + return status; + } + status.ok = true; + status.package_available = json.value("package_available", false); + status.driver_installed = json.value("driver_installed", false); + status.plugged_monitors = json.value("plugged_monitors", 0); + status.busy = json.value("busy", false); + status.last_error = json.value("last_error", std::string()); + return status; +} + +// Waits for the helper job to finish; returns false with the job's error. +// |cancelled| stops the wait early (the helper job itself keeps running). +bool WaitForHelperJob(const std::wstring& pipe, DWORD timeout_ms, + const std::function& cancelled, + HelperVirtualDisplayStatus* final_status, + std::string* error) { + const ULONGLONG deadline = GetTickCount64() + timeout_ms; + while (true) { + *final_status = QueryHelperStatus(pipe); + if (!final_status->ok) { + *error = final_status->error; + return false; + } + if (!final_status->busy) { + if (!final_status->last_error.empty()) { + *error = final_status->last_error; + return false; + } + return true; + } + if (cancelled()) { + *error = "cancelled"; + return false; + } + if (GetTickCount64() >= deadline) { + *error = "helper_job_timeout"; + return false; + } + Sleep(kHelperPollIntervalMs); + } +} + +bool WaitForDisplayPresence(DWORD timeout_ms, + const std::function& cancelled) { + const ULONGLONG deadline = GetTickCount64() + timeout_ms; + while (true) { + if (ClassifyDisplayPresence(ProbeDisplayPresence()) == + DisplayPresence::present) { + return true; + } + if (cancelled() || GetTickCount64() >= deadline) return false; + Sleep(kHelperPollIntervalMs); + } +} + +} // namespace + +bool VirtualDisplayProvisioner::AcquireViaHelper(VirtualDisplayMode mode, + std::string* error, + bool* helper_reachable) { + *helper_reachable = false; + const std::wstring pipe = OwnSessionHelperPipeName(); + if (pipe.empty()) { + *error = "session_id_unavailable"; + return false; + } + HelperVirtualDisplayStatus status = QueryHelperStatus(pipe); + if (!status.ok) { + *error = status.error; + return false; + } + *helper_reachable = true; + if (!status.package_available) { + *error = "usbmmidd_package_missing"; + return false; + } + const auto cancelled = [this] { return Cancelled(); }; + if (status.busy) { + // A previous unplug may still be running; let it finish first. + if (!WaitForHelperJob(pipe, kUnplugTimeoutMs, cancelled, &status, error)) + return false; + } + Json json; + if (!QueryHelper(pipe, BuildVirtualDisplayPlugCommand(mode), + kHelperCommandTimeoutMs, &json, error)) { + return false; + } + const DWORD timeout = + status.driver_installed ? kPlugTimeoutMs : kInstallAndPlugTimeoutMs; + if (!WaitForHelperJob(pipe, timeout, cancelled, &status, error)) { + // The helper finishes the plug on its own; Release() must undo it. + if (*error == "cancelled") pending_helper_release_ = true; + return false; + } + if (status.plugged_monitors <= 0) { + *error = "usbmmidd_monitor_not_attached"; + return false; + } + return true; +} + +bool VirtualDisplayProvisioner::AcquireLocally(VirtualDisplayMode mode, + std::string* error) { + if (!LocateUsbmmiddPackage().Available()) { + *error = "usbmmidd_package_missing"; + return false; + } + return ProvisionUsbmmiddDisplay(mode, &plugged_locally_, error); +} + +bool VirtualDisplayProvisioner::Acquire(VirtualDisplayMode mode, + std::string* error) { + std::string local_error; + if (error == nullptr) error = &local_error; + if (active_.load(std::memory_order_relaxed)) return true; + cancel_.store(false, std::memory_order_relaxed); + + bool helper_reachable = false; + const ULONGLONG started = GetTickCount64(); + bool ok = AcquireViaHelper(mode, error, &helper_reachable); + if (ok) { + via_helper_ = true; + } else if (!helper_reachable && *error != "cancelled") { + LOG_INFO("Virtual display: session helper unreachable ({}), trying " + "in-process provisioning", + *error); + ok = AcquireLocally(mode, error); + via_helper_ = false; + } + if (!ok) { + LOG_WARN("Virtual display: provisioning failed after {} ms, error={}", + GetTickCount64() - started, *error); + return false; + } + // The desktop moves onto the new target asynchronously; DXGI needs it. + if (!WaitForDisplayPresence(kPresenceSettleTimeoutMs, + [this] { return Cancelled(); })) { + LOG_WARN("Virtual display: monitor plugged but no display target became " + "available within {} ms", + kPresenceSettleTimeoutMs); + } + active_.store(true, std::memory_order_relaxed); + LOG_INFO("Virtual display: usbmmidd monitor ready via {} in {} ms " + "(requested {}x{})", + via_helper_ ? "session helper" : "this process", + GetTickCount64() - started, mode.width, mode.height); + return true; +} + +void VirtualDisplayProvisioner::Release() { + const bool was_active = active_.exchange(false, std::memory_order_relaxed); + // via_helper_ is only set by a completed helper plug; a cancelled one is + // tracked by pending_helper_release_ instead. + const bool release_helper = + (via_helper_ && was_active) || pending_helper_release_; + const bool release_local = !release_helper && plugged_locally_ > 0; + if (!release_helper && !release_local) return; + std::string error; + bool ok = false; + if (release_helper) { + const std::wstring pipe = OwnSessionHelperPipeName(); + Json json; + HelperVirtualDisplayStatus status; + ok = !pipe.empty() && + QueryHelper(pipe, kCrossDeskVirtualDisplayUnplugCommand, + kHelperCommandTimeoutMs, &json, &error); + // Wait for the unplug so a Start() right after this Stop() probes the + // desktop without the outgoing monitor. A plug still running in the helper + // queues the unplug behind itself; the next Acquire() waits for that job. + if (ok && !json.value("queued", false)) { + ok = WaitForHelperJob(pipe, kUnplugTimeoutMs, [] { return false; }, + &status, &error); + } + if (ok) pending_helper_release_ = false; + } else { + ok = ReleaseUsbmmiddDisplays(plugged_locally_, &error); + if (ok) plugged_locally_ = 0; + } + if (ok) { + LOG_INFO("Virtual display: usbmmidd monitor released"); + } else { + LOG_WARN("Virtual display: release failed, error={}", error); + } +} + +} // namespace crossdesk diff --git a/apps/desktop/src/platform/windows/virtual_display/virtual_display_provisioner.h b/apps/desktop/src/platform/windows/virtual_display/virtual_display_provisioner.h new file mode 100644 index 00000000..00260adf --- /dev/null +++ b/apps/desktop/src/platform/windows/virtual_display/virtual_display_provisioner.h @@ -0,0 +1,63 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-09-20 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef CROSSDESK_VIRTUAL_DISPLAY_PROVISIONER_H_ +#define CROSSDESK_VIRTUAL_DISPLAY_PROVISIONER_H_ + +#include + +#include +#include + +#include "usbmmidd_policy.h" + +namespace crossdesk { + +// Session-scoped usbmmidd virtual display for a host without any monitor. +// Acquire() asks the session helper (SYSTEM in the console session) to +// install/plug the driver; without a reachable helper an elevated GUI does it +// itself. Release() unplugs only what this object plugged. +class VirtualDisplayProvisioner { + public: + VirtualDisplayProvisioner() = default; + ~VirtualDisplayProvisioner() { Release(); } + VirtualDisplayProvisioner(const VirtualDisplayProvisioner&) = delete; + VirtualDisplayProvisioner& operator=(const VirtualDisplayProvisioner&) = + delete; + + bool Acquire(VirtualDisplayMode mode, std::string* error); + void Release(); + bool active() const { return active_.load(std::memory_order_relaxed); } + // True when Release() has something to undo. Read on the owner's thread + // after the provisioning worker has been joined. + bool NeedsRelease() const { + return active() || pending_helper_release_ || plugged_locally_ > 0; + } + // Makes a blocking Acquire() return early with "cancelled" at its next poll + // (helper install/plug can take up to two minutes). Cleared by Acquire(). + void RequestCancel() { cancel_.store(true, std::memory_order_relaxed); } + + private: + // |helper_reachable| tells the caller whether a local fallback makes sense. + bool AcquireViaHelper(VirtualDisplayMode mode, std::string* error, + bool* helper_reachable); + bool AcquireLocally(VirtualDisplayMode mode, std::string* error); + bool Cancelled() const { return cancel_.load(std::memory_order_relaxed); } + + // Acquire/Release run on the owner's provisioning worker; active() and + // RequestCancel() are used from other threads. + std::atomic active_{false}; + std::atomic cancel_{false}; + bool via_helper_ = false; + // A cancelled helper plug still completes in the helper; Release() undoes it. + bool pending_helper_release_ = false; + // Monitors plugged by this process (helper-plugged ones are tracked there). + int plugged_locally_ = 0; +}; + +} // namespace crossdesk + +#endif // CROSSDESK_VIRTUAL_DISPLAY_PROVISIONER_H_ diff --git a/apps/desktop/xmake/targets.lua b/apps/desktop/xmake/targets.lua index 5be86822..f3114a48 100644 --- a/apps/desktop/xmake/targets.lua +++ b/apps/desktop/xmake/targets.lua @@ -230,9 +230,13 @@ function setup_targets() "apps/desktop/src/platform/windows/screen_capturer/screen_capturer_gdi.cpp", "apps/desktop/src/platform/windows/screen_capturer/screen_capturer_win.cpp", "apps/desktop/src/platform/windows/screen_capturer/screen_capturer_factory.cpp") + add_files("apps/desktop/src/platform/windows/virtual_display/usbmmidd_virtual_display.cpp", + "apps/desktop/src/platform/windows/virtual_display/virtual_display_provisioner.cpp") add_includedirs("apps/desktop/src/platform/windows", "apps/desktop/src/platform/windows/screen_capturer", - "apps/desktop/src/platform/windows/service") + "apps/desktop/src/platform/windows/service", + "apps/desktop/src/platform/windows/virtual_display") + add_syslinks("Setupapi", {public = true}) elseif is_os("macosx") then add_deps("privacy") add_mxxflags("-fobjc-arc") @@ -470,13 +474,15 @@ function setup_targets() set_kind("binary") add_packages("libyuv") add_deps("rd_log", "path_manager") - add_links("Advapi32", "User32", "Wtsapi32", "Gdi32") + add_links("Advapi32", "User32", "Wtsapi32", "Gdi32", "Setupapi") add_files("apps/desktop/src/platform/windows/service/session_helper_main.cpp") + add_files("apps/desktop/src/platform/windows/virtual_display/usbmmidd_virtual_display.cpp") add_files("apps/desktop/resources/windows/crossdesk_session_helper.rc", {defines = windows_helper_resource_defines}) add_includedirs("apps/desktop/src/common", "apps/desktop/src/platform/windows/service", - "apps/desktop/src/platform/windows/screen_capturer") + "apps/desktop/src/platform/windows/screen_capturer", + "apps/desktop/src/platform/windows/virtual_display") add_includedirs("apps/desktop/src/platform/windows/input", "apps/desktop/src/platform/common/input") add_includedirs("libs/wire/include")