From fb556a763db8a78579cf373fde506b4c9af9eff6 Mon Sep 17 00:00:00 2001 From: dijunkun Date: Mon, 21 Sep 2026 01:06:34 +0800 Subject: [PATCH] [fix] keep Windows privacy screen across display changes and validate covers --- apps/desktop/src/gui/runtime/gui_runtime.h | 3 + .../src/gui/runtime/peer_event_handler.cpp | 11 +- .../src/gui/runtime/privacy_runtime.cpp | 39 +++-- .../src/gui/runtime/privacy_session_policy.h | 31 +++- .../windows/privacy/band_module/main.cpp | 64 +++++--- .../windows/privacy/privacy_backend_win.cpp | 138 +++++++++--------- .../windows/privacy/privacy_band_guard.cpp | 31 ++-- .../windows/privacy/privacy_band_ipc.h | 3 +- .../privacy/privacy_capture_exclusion.h | 30 ++++ .../windows/privacy/privacy_topology.h | 34 +++++ .../screen_capturer/screen_capturer_win.cpp | 6 +- .../src/privacy/privacy_controller.cpp | 40 ++++- 12 files changed, 296 insertions(+), 134 deletions(-) create mode 100644 apps/desktop/src/platform/windows/privacy/privacy_capture_exclusion.h create mode 100644 apps/desktop/src/platform/windows/privacy/privacy_topology.h diff --git a/apps/desktop/src/gui/runtime/gui_runtime.h b/apps/desktop/src/gui/runtime/gui_runtime.h index 75b568ce..303a24af 100644 --- a/apps/desktop/src/gui/runtime/gui_runtime.h +++ b/apps/desktop/src/gui/runtime/gui_runtime.h @@ -64,6 +64,9 @@ class GuiRuntime : protected gui_detail::GuiState { void HandlePrivacy(); bool IsAuthorizedController(const std::string& remote_id); void SetControllerPrivacySupport(const std::string& remote_id, bool supported); + // Caller holds connection_status_mutex_ exclusively. Turns privacy off when + // any connected controller lacks support, or on for the first automatic one. + void ApplyPrivacyAdmission(bool automatic_enable); void QueuePrivacyCommand(const std::string& remote_id, const PrivacyCommand& command); void CloseRemoteSession(std::shared_ptr props); diff --git a/apps/desktop/src/gui/runtime/peer_event_handler.cpp b/apps/desktop/src/gui/runtime/peer_event_handler.cpp index 0f191fcd..563ae74f 100644 --- a/apps/desktop/src/gui/runtime/peer_event_handler.cpp +++ b/apps/desktop/src/gui/runtime/peer_event_handler.cpp @@ -363,14 +363,11 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status, { std::unique_lock lock(runtime->connection_status_mutex_); if (status == ConnectionStatus::Connected) { - // Wait for explicit desktop capability before enabling privacy or - // sending privacy messages. Legacy and web controllers never opt in. - runtime->privacy_sessions_.Connected( + // Local privacy requires explicit desktop capability. Host info may + // arrive before Connected; consume that saved capability here. + const bool automatic_enable = runtime->privacy_sessions_.Connected( remote_id, runtime->config_center_->IsEnablePrivacyScreen()); - if (!runtime->privacy_sessions_.CanEnable() && - runtime->privacy_.Engaged()) { - runtime->privacy_.Disable(); - } + runtime->ApplyPrivacyAdmission(automatic_enable); } runtime->connection_status_[remote_id] = status; runtime->devices_.OnControllerConnectionsChanged(); diff --git a/apps/desktop/src/gui/runtime/privacy_runtime.cpp b/apps/desktop/src/gui/runtime/privacy_runtime.cpp index 9a8644c7..2a9f4b7a 100644 --- a/apps/desktop/src/gui/runtime/privacy_runtime.cpp +++ b/apps/desktop/src/gui/runtime/privacy_runtime.cpp @@ -25,14 +25,27 @@ void GuiRuntime::SetControllerPrivacySupport(const std::string& remote_id, bool supported) { std::unique_lock lock(connection_status_mutex_); const auto found = connection_status_.find(remote_id); - if (found == connection_status_.end() || - found->second != ConnectionStatus::Connected) return; + if (found == connection_status_.end()) return; + if (found->second != ConnectionStatus::Connected) { + if (found->second == ConnectionStatus::Connecting || + found->second == ConnectionStatus::Gathering) { + privacy_sessions_.RememberSupport(remote_id, supported); + LOG_INFO("Privacy: retained early controller capability, peer={}, supported={}", + remote_id, supported); + } + return; + } const bool automatic_enable = privacy_sessions_.SetSupported(remote_id, supported); + ApplyPrivacyAdmission(automatic_enable && + config_center_->IsEnablePrivacyScreen()); +} + +void GuiRuntime::ApplyPrivacyAdmission(bool automatic_enable) { if (!privacy_sessions_.CanEnable()) { if (privacy_.Engaged()) privacy_.Disable(); - } else if (automatic_enable && config_center_->IsEnablePrivacyScreen()) { + } else if (automatic_enable) { privacy_.EnableOnConnection(); } } @@ -45,8 +58,10 @@ void GuiRuntime::QueuePrivacyCommand(const std::string& remote_id, found->second != ConnectionStatus::Connected || !privacy_sessions_.Supports(remote_id)) return; std::lock_guard lock(privacy_commands_mutex_); - if (privacy_commands_.size() < 32) - privacy_commands_.emplace_back(remote_id, command); + if (privacy_commands_.size() >= 32) return; + LOG_INFO("Privacy: queued controller command, peer={}, flag={}", remote_id, + static_cast(command.flag)); + privacy_commands_.emplace_back(remote_id, command); } void GuiRuntime::HandlePrivacy() { @@ -71,17 +86,15 @@ void GuiRuntime::HandlePrivacy() { } last_privacy_status_tick_ = 0; } - auto status = privacy_.Snapshot(); + PrivacyStatus status{}; const uint64_t now = SDL_GetTicks(); - if (!peer_ || (last_privacy_status_tick_ != 0 && - status.revision == last_privacy_revision_ && - now - last_privacy_status_tick_ < 1000)) - return; std::vector controllers; { std::shared_lock lock(connection_status_mutex_); + status = privacy_.Snapshot(); for (const auto& [id, state] : connection_status_) - if (state == ConnectionStatus::Connected && privacy_sessions_.Supports(id)) + if (state == ConnectionStatus::Connected && + privacy_sessions_.Supports(id)) controllers.push_back(id); if (!privacy_sessions_.CanEnable()) { status.state = PrivacyState::unsupported; @@ -91,6 +104,10 @@ void GuiRuntime::HandlePrivacy() { "A connected controller does not support privacy screen"); } } + if (!peer_ || (last_privacy_status_tick_ != 0 && + status.revision == last_privacy_revision_ && + now - last_privacy_status_tick_ < 1000)) + return; RemoteAction action{}; action.type = ControlType::privacy_status; action.ps = status; diff --git a/apps/desktop/src/gui/runtime/privacy_session_policy.h b/apps/desktop/src/gui/runtime/privacy_session_policy.h index 6acdaa97..dc6f151a 100644 --- a/apps/desktop/src/gui/runtime/privacy_session_policy.h +++ b/apps/desktop/src/gui/runtime/privacy_session_policy.h @@ -13,19 +13,37 @@ namespace crossdesk { -// Guarded by GuiRuntime's connection_status_mutex_. Privacy covers the shared -// desktop, so every connected controller must explicitly support it. +// Guarded by GuiRuntime's connection_status_mutex_. Privacy covers the local +// displays, so every connected controller must explicitly support its controls. class PrivacySessionPolicy { public: - void Connected(const std::string& remote_id, bool automatic_privacy) { + // Web controllers render through the browser and expose no privacy UI. + static bool IsWebController(const std::string& remote_id) { + return remote_id.find("web") != std::string::npos; + } + + bool Connected(const std::string& remote_id, bool automatic_privacy) { const auto [entry, inserted] = controllers_.try_emplace(remote_id, false); - if (inserted && controllers_.size() == 1 && automatic_privacy) { + if (inserted && controllers_.size() == 1 && automatic_privacy && + !IsWebController(remote_id)) { automatic_controller_ = remote_id; } + const auto early = early_support_.find(remote_id); + if (early == early_support_.end()) return false; + const bool supported = early->second; + early_support_.erase(early); + return SetSupported(remote_id, supported); + } + + // Only transport-authenticated metadata from a connecting session is cached + // by the caller. It grants no control until Connected admits that session. + void RememberSupport(const std::string& remote_id, bool supported) { + early_support_[remote_id] = supported; } void Disconnected(const std::string& remote_id) { controllers_.erase(remote_id); + early_support_.erase(remote_id); if (automatic_controller_ == remote_id) automatic_controller_.clear(); } @@ -34,7 +52,7 @@ class PrivacySessionPolicy { bool SetSupported(const std::string& remote_id, bool supported) { const auto entry = controllers_.find(remote_id); if (entry == controllers_.end()) return false; - entry->second = supported && remote_id.find("web") == std::string::npos; + entry->second = supported && !IsWebController(remote_id); if (automatic_controller_ != remote_id) return false; automatic_controller_.clear(); return CanEnable(); @@ -53,9 +71,10 @@ class PrivacySessionPolicy { private: std::unordered_map controllers_; + std::unordered_map early_support_; std::string automatic_controller_; }; } // namespace crossdesk -#endif \ No newline at end of file +#endif diff --git a/apps/desktop/src/platform/windows/privacy/band_module/main.cpp b/apps/desktop/src/platform/windows/privacy/band_module/main.cpp index 15b697c4..b9725c96 100644 --- a/apps/desktop/src/platform/windows/privacy/band_module/main.cpp +++ b/apps/desktop/src/platform/windows/privacy/band_module/main.cpp @@ -14,6 +14,8 @@ #include "../../windows_thread_dpi.h" #include "../privacy_band_ipc.h" +#include "../privacy_capture_exclusion.h" +#include "../privacy_topology.h" #include "privacy_cover_renderer.h" namespace { @@ -24,11 +26,6 @@ constexpr DWORD kCoverStyles = WS_EX_TOPMOST | WS_EX_NOACTIVATE | WS_EX_TRANSPARENT; HMODULE module = nullptr; -bool SameRect(const RECT& a, const RECT& b) { - return a.left == b.left && a.top == b.top && a.right == b.right && - a.bottom == b.bottom; -} - class Windows { public: explicit Windows(PrivacyBandShared* shared) : shared_(shared) {} @@ -59,6 +56,7 @@ class Windows { return atom_ ? Reconcile() : Fail(L"Register high-band window class"); } bool Reconcile() { + InterlockedExchange(&shared_->topology_pending, 1); std::vector monitors; if (!EnumDisplayMonitors( nullptr, nullptr, @@ -68,16 +66,37 @@ class Windows { result.push_back(*rect); return TRUE; }, - reinterpret_cast(&monitors)) || - monitors.empty()) + reinterpret_cast(&monitors))) return Fail(L"Enumerate high-band displays"); - bool changed = monitors.size() != windows_.size(); + const bool changed = monitors.size() != windows_.size() || + std::any_of(monitors.begin(), monitors.end(), [&](const RECT& rect) { + return std::none_of(windows_.begin(), windows_.end(), + [&](const auto& window) { return SameRect(rect, window.rect); }); + }); + // Mark the shared snapshot busy BEFORE replacing/destroying any HWND. + // Readers must not validate a stale handle from a previous topology. + struct PublishGuard { + PrivacyBandShared* shared; + ~PublishGuard() { InterlockedIncrement(&shared->sequence); } + } publishing{shared_}; + InterlockedIncrement(&shared_->sequence); for (const auto& rect : monitors) { const auto found = std::find_if(windows_.begin(), windows_.end(), [&](const auto& w) { return SameRect(w.rect, rect); }); - if (found != windows_.end()) continue; - changed = true; + if (found != windows_.end()) { + // Windows can reposition a top-level window during DPI/hotplug before + // delivering WM_DISPLAYCHANGE. Restore the intended monitor bounds. + HWND hwnd = reinterpret_cast(found->hwnd); + RECT actual{}; + if ((!GetWindowRect(hwnd, &actual) || !SameRect(actual, rect) || + !IsWindowVisible(hwnd)) && + !SetWindowPos(hwnd, nullptr, rect.left, rect.top, + rect.right - rect.left, rect.bottom - rect.top, + SWP_NOZORDER | SWP_NOACTIVATE | SWP_SHOWWINDOW)) + return Fail(L"Reposition high-band cover"); + continue; + } HWND hwnd = create_(kCoverStyles, atom_, L"CrossDesk Privacy", WS_POPUP, rect.left, rect.top, rect.right - rect.left, rect.bottom - rect.top, nullptr, nullptr, module, @@ -86,7 +105,7 @@ class Windows { windows_.push_back({reinterpret_cast(hwnd), rect}); if (!SetLayeredWindowAttributes(hwnd, 0, 255, LWA_ALPHA)) return Fail(L"Make high-band cover opaque"); - if (!SetWindowDisplayAffinity(hwnd, 0x11)) + if (!ExcludePrivacyWindowFromCapture(hwnd)) return Fail(L"Exclude high-band cover from capture"); const BOOL yes = TRUE; const HRESULT peek = DwmSetWindowAttribute(hwnd, DWMWA_EXCLUDED_FROM_PEEK, @@ -114,13 +133,16 @@ class Windows { ++it; } } - if (!Validate()) return false; - if (changed) { - InterlockedIncrement(&shared_->sequence); - shared_->count = static_cast(windows_.size()); - std::copy(windows_.begin(), windows_.end(), shared_->windows); - InterlockedIncrement(&shared_->sequence); + if (!Validate()) { + // Another topology event can race enumeration/presentation. Retry while + // retaining every surviving cover; actual validation errors stay fatal. + return InterlockedCompareExchange(&shared_->error, 0, 0) == ERROR_SUCCESS; } + // A previous reconciliation may have changed windows_ but failed geometry + // validation. Publish on every success so retries cannot leave stale HWNDs. + shared_->count = static_cast(windows_.size()); + std::copy(windows_.begin(), windows_.end(), shared_->windows); + InterlockedExchange(&shared_->topology_pending, 0); return true; } bool Validate() { @@ -130,13 +152,17 @@ class Windows { DWORD band = 0, affinity = 0, flags = 0, cloaked = 0; BYTE alpha = 0; RECT rect{}; + if (!GetWindowRect(hwnd, &rect) || !SameRect(rect, w.rect)) { + changed = true; + return false; + } if ((GetWindowLongPtrW(hwnd, GWL_EXSTYLE) & kCoverStyles) != kCoverStyles || !get_band_(hwnd, &band) || band != kPrivacyWindowBand || - !GetWindowDisplayAffinity(hwnd, &affinity) || affinity != 0x11 || + !GetWindowDisplayAffinity(hwnd, &affinity) || + affinity != kPrivacyWindowDisplayAffinity || !GetLayeredWindowAttributes(hwnd, nullptr, &alpha, &flags) || alpha != 255 || flags != LWA_ALPHA || !IsWindowVisible(hwnd) || - !GetWindowRect(hwnd, &rect) || !SameRect(rect, w.rect) || FAILED(DwmGetWindowAttribute(hwnd, DWMWA_CLOAKED, &cloaked, sizeof(cloaked))) || cloaked) diff --git a/apps/desktop/src/platform/windows/privacy/privacy_backend_win.cpp b/apps/desktop/src/platform/windows/privacy/privacy_backend_win.cpp index 810e33e8..c5918bd9 100644 --- a/apps/desktop/src/platform/windows/privacy/privacy_backend_win.cpp +++ b/apps/desktop/src/platform/windows/privacy/privacy_backend_win.cpp @@ -16,15 +16,13 @@ #include "../windows_thread_dpi.h" #include "privacy_backend.h" #include "privacy_band_guard.h" +#include "privacy_capture_exclusion.h" #include "privacy_cursor_guard.h" #include "privacy_input_guard.h" +#include "privacy_topology.h" #include "privacy_window_visibility.h" #include "rd_log.h" -#ifndef WDA_EXCLUDEFROMCAPTURE -#define WDA_EXCLUDEFROMCAPTURE 0x00000011 -#endif - namespace crossdesk { namespace { @@ -46,11 +44,6 @@ bool DesktopName(HDESK desktop, wchar_t (&name)[256]) { sizeof(name), &needed); } -bool SameRect(const RECT& a, const RECT& b) { - return a.left == b.left && a.top == b.top && a.right == b.right && - a.bottom == b.bottom; -} - struct MonitorEnumeration { std::vector rects; bool ok = true; @@ -141,6 +134,7 @@ class WindowsPrivacyBackend final : public PrivacyBackend { return false; if (!band_guard_.Start(text, error)) return false; if (!ReconcileMonitors(error)) return false; + layout_changed_ = false; event_owner_ = this; for (DWORD event : {EVENT_OBJECT_SHOW, EVENT_OBJECT_REORDER, EVENT_OBJECT_LOCATIONCHANGE}) { @@ -193,6 +187,8 @@ class WindowsPrivacyBackend final : public PrivacyBackend { last_obstruction_ = nullptr; last_obstruction_log_ = 0; layout_changed_ = false; + topology_pending_ = false; + topology_pending_since_ = 0; coverage_dirty_ = false; emergency_ = false; health_error_.clear(); @@ -235,12 +231,14 @@ class WindowsPrivacyBackend final : public PrivacyBackend { std::string error; if (!input_guard_.Healthy(error)) return {error, false}; } - if (windows_.empty()) return {health_error_, false}; + if (band_guard_.stopped()) return {health_error_, false}; // Input messages wake the thread immediately; expensive health queries // must retain their own cadence instead of running once per input event. - if (!layout_changed_ && - GetTickCount64() - last_check_ < 100) { - if (coverage_dirty_ && last_health_.failure.empty()) { + // Window events only dirty the coverage check, which is cheap enough to + // run between the throttled full health passes. + if (!layout_changed_ && GetTickCount64() - last_check_ < 100) { + if (coverage_dirty_ && last_health_.failure.empty() && + !topology_pending_) { coverage_dirty_ = false; last_health_ = CheckCoverage(); } @@ -253,6 +251,21 @@ class WindowsPrivacyBackend final : public PrivacyBackend { } private: + // The broker reconciles hotplug asynchronously; while it does, coverage + // cannot be verified. Preserve intent for a bounded time only: a broker that + // never settles must not keep privacy "on" without any verification. + static constexpr uint64_t kTopologyPendingLimitMs = 3000; + + PrivacyHealth TopologyPending() { + const uint64_t now = GetTickCount64(); + if (topology_pending_since_ == 0) topology_pending_since_ = now; + if (now - topology_pending_since_ >= kTopologyPendingLimitMs) { + return {"Privacy cover topology did not settle after a display change", + false}; + } + return {{}, false, false}; + } + PrivacyHealth CheckHealth() { if (!health_error_.empty()) return {health_error_, false}; if (!band_guard_.Healthy(health_error_)) return {health_error_, false}; @@ -273,40 +286,23 @@ class WindowsPrivacyBackend final : public PrivacyBackend { "Exclusive fullscreen or unknown display presentation", false}; } - MonitorEnumeration monitors; - if (!EnumDisplayMonitors(nullptr, nullptr, EnumMonitor, - reinterpret_cast(&monitors)) || - !monitors.ok) { - return {Error("EnumDisplayMonitors"), false}; - } - const bool changed = - monitors.rects.size() != windows_.size() || - std::any_of(monitors.rects.begin(), monitors.rects.end(), - [this](const RECT& rect) { - return std::none_of(windows_.begin(), windows_.end(), - [&](const auto& w) { - return SameRect(w.rect, rect); - }); - }); - if (changed || layout_changed_) { - layout_changed_ = false; - // Retain old covers until the monitor check completes, then release - // privacy so capture can refresh its display mapping independently. - std::string error; - if (!ReconcileMonitors(error)) - health_error_ = error; - else - health_error_ = - "Display layout/DPI changed; privacy screen will turn off"; - } - if (!health_error_.empty()) return {health_error_, false}; + if (!ReconcileMonitors(health_error_)) return {health_error_, false}; + if (topology_pending_) return TopologyPending(); BOOL composed = FALSE; if (FAILED(DwmIsCompositionEnabled(&composed)) || !composed) { return { "Desktop composition stopped; privacy coverage cannot be guaranteed", false}; } - return CheckCoverage(); + auto coverage = CheckCoverage(); + if (!coverage.failure.empty()) { + // The broker may replace an HWND after the snapshot above. Refresh once + // before treating a stale geometry/handle as loss of privacy coverage. + if (!ReconcileMonitors(health_error_)) return {health_error_, false}; + if (topology_pending_) return TopologyPending(); + coverage = CheckCoverage(); + } + return coverage; } PrivacyHealth CheckCoverage() { @@ -328,7 +324,7 @@ class WindowsPrivacyBackend final : public PrivacyBackend { &layered_flags) || alpha != 255 || layered_flags != LWA_ALPHA || !GetWindowDisplayAffinity(w.cover, &affinity) || - affinity != WDA_EXCLUDEFROMCAPTURE || + affinity != kPrivacyWindowDisplayAffinity || FAILED(DwmGetWindowAttribute(w.cover, DWMWA_CLOAKED, &cloaked, sizeof(cloaked))) || cloaked) { @@ -430,40 +426,42 @@ class WindowsPrivacyBackend final : public PrivacyBackend { } bool ReconcileMonitors(std::string& error) { + const auto wait_for_broker = [&] { + topology_pending_ = true; + // Preserve intent while a healthy broker reconciles hotplug. Broker + // failure/heartbeat checks still detect real faults independently. + error.clear(); + return true; + }; MonitorEnumeration monitors; if (!EnumDisplayMonitors(nullptr, nullptr, EnumMonitor, - reinterpret_cast(&monitors)) || - !monitors.ok || monitors.rects.empty()) { + reinterpret_cast(&monitors)) || !monitors.ok) { error = Error("EnumDisplayMonitors"); return false; } std::vector covers; - if (!band_guard_.Windows(covers, error)) return false; - for (const RECT& rect : monitors.rects) { - auto existing = - std::find_if(windows_.begin(), windows_.end(), - [&](const auto& w) { return SameRect(w.rect, rect); }); - if (existing != windows_.end()) continue; - const auto cover = std::find_if(covers.begin(), covers.end(), - [&](const auto& candidate) { - return SameRect(candidate.rect, rect); - }); - if (cover == covers.end()) { - error = "High-band broker has not covered every physical monitor"; - return false; - } - windows_.push_back({rect, reinterpret_cast(cover->hwnd)}); - LOG_INFO( - "Privacy high-band cover ready: bounds=({}, {})-({}, {}), " - "affinity=0x11, alpha=255", - rect.left, rect.top, rect.right, rect.bottom); + if (!band_guard_.Windows(covers, error)) { + if (GetLastError() == ERROR_RETRY) return wait_for_broker(); + return false; } - // Cover windows are owned and released by the broker. - windows_.erase(std::remove_if(windows_.begin(), windows_.end(), - [&](const auto& window) { - return std::none_of(monitors.rects.begin(), monitors.rects.end(), - [&](const RECT& rect) { return SameRect(window.rect, rect); }); - }), windows_.end()); + if (!MatchPrivacyTopology(monitors.rects, covers)) return wait_for_broker(); + + std::vector updated; + for (const auto& cover : covers) { + HWND hwnd = reinterpret_cast(cover.hwnd); + if (std::none_of(windows_.begin(), windows_.end(), [&](const auto& old) { + return old.cover == hwnd && SameRect(old.rect, cover.rect); + })) { + LOG_INFO("Privacy: reconciled local cover without changing session state: " + "bounds=({}, {})-({}, {}), affinity=0x11", + cover.rect.left, cover.rect.top, cover.rect.right, cover.rect.bottom); + } + updated.push_back({cover.rect, hwnd}); + } + windows_ = std::move(updated); + layout_changed_ = false; + topology_pending_ = false; + topology_pending_since_ = 0; return true; } @@ -526,6 +524,8 @@ class WindowsPrivacyBackend final : public PrivacyBackend { bool hotkey_ = false; bool emergency_ = false; bool layout_changed_ = false; + bool topology_pending_ = false; + uint64_t topology_pending_since_ = 0; bool coverage_dirty_ = false; uint64_t last_check_ = 0; PrivacyHealth last_health_; diff --git a/apps/desktop/src/platform/windows/privacy/privacy_band_guard.cpp b/apps/desktop/src/platform/windows/privacy/privacy_band_guard.cpp index 15dabde2..bed66df9 100644 --- a/apps/desktop/src/platform/windows/privacy/privacy_band_guard.cpp +++ b/apps/desktop/src/platform/windows/privacy/privacy_band_guard.cpp @@ -119,10 +119,12 @@ bool PrivacyBandGuard::Start(const PrivacyScreenText& text, // Keep both images immutable throughout creation/injection and the session. image_lock_ = CreateFileW(broker_path_.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, 0, nullptr); + if (image_lock_ == INVALID_HANDLE_VALUE) + return Failed("Lock privacy broker image", error); dll_lock_ = CreateFileW(dll.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, 0, nullptr); - if (image_lock_ == INVALID_HANDLE_VALUE || dll_lock_ == INVALID_HANDLE_VALUE) - return Failed("Lock privacy broker images", error); + if (dll_lock_ == INVALID_HANDLE_VALUE) + return Failed("Lock privacy window module", error); SECURITY_ATTRIBUTES inherit{sizeof(inherit), nullptr, TRUE}; ScopedHandle parent; @@ -131,14 +133,14 @@ bool PrivacyBandGuard::Start(const PrivacyScreenText& text, SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, TRUE, 0)) return Failed("Duplicate privacy parent process handle", error); + // Check each object as it is created so the reported error is its own. stop_ = CreateEventW(&inherit, TRUE, FALSE, nullptr); + if (!stop_) return Failed("Create high-band broker stop event", error); ready_ = CreateEventW(&inherit, TRUE, FALSE, nullptr); + if (!ready_) return Failed("Create high-band broker ready event", error); mapping_ = CreateFileMappingW(INVALID_HANDLE_VALUE, &inherit, PAGE_READWRITE, 0, sizeof(PrivacyBandShared), nullptr); - if (!stop_ || !ready_ || !mapping_) { - const auto code = GetLastError(); - return Failed("Create high-band broker IPC", error, code); - } + if (!mapping_) return Failed("Create high-band broker shared memory", error); shared_ = static_cast(MapViewOfFile( mapping_, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(PrivacyBandShared))); if (!shared_) { @@ -271,25 +273,30 @@ bool PrivacyBandGuard::Healthy(std::string& error) { bool PrivacyBandGuard::Windows(std::vector& windows, std::string& error) { if (!Healthy(error)) return false; + const auto retry = [&] { + error = "High-band monitor snapshot is being updated"; + SetLastError(ERROR_RETRY); + return false; + }; + if (InterlockedCompareExchange(&shared_->topology_pending, 0, 0)) return retry(); for (int attempt = 0; attempt < 4; ++attempt) { const LONG before = InterlockedCompareExchange(&shared_->sequence, 0, 0); if (before & 1) continue; const auto count = shared_->count; - if (count == 0 || count > kPrivacyMaxMonitors) break; + if (count > kPrivacyMaxMonitors) break; windows.assign(shared_->windows, shared_->windows + count); - if (before == InterlockedCompareExchange(&shared_->sequence, 0, 0)) { + if (before == InterlockedCompareExchange(&shared_->sequence, 0, 0) && + !InterlockedCompareExchange(&shared_->topology_pending, 0, 0)) { for (const auto& window : windows) { DWORD pid = 0; GetWindowThreadProcessId(reinterpret_cast(window.hwnd), &pid); if (pid != process_id()) - return Failed("Invalid high-band window owner", error, - ERROR_INVALID_HANDLE); + return retry(); } return true; } } - return Failed("High-band monitor snapshot is changing or invalid", error, - ERROR_RETRY); + return retry(); } bool PrivacyBandGuard::Stop(std::string& error) { diff --git a/apps/desktop/src/platform/windows/privacy/privacy_band_ipc.h b/apps/desktop/src/platform/windows/privacy/privacy_band_ipc.h index f321b04d..dece9729 100644 --- a/apps/desktop/src/platform/windows/privacy/privacy_band_ipc.h +++ b/apps/desktop/src/platform/windows/privacy/privacy_band_ipc.h @@ -14,7 +14,7 @@ namespace crossdesk { constexpr uint32_t kPrivacyBandMagic = 0x43534231; -constexpr uint32_t kPrivacyBandVersion = 2; +constexpr uint32_t kPrivacyBandVersion = 3; constexpr DWORD kPrivacyWindowBand = 18; // ZBID_ABOVELOCK_UX, current desktop. constexpr size_t kPrivacyMaxMonitors = 64; constexpr wchar_t kPrivacyBandBootstrap[] = L"CROSSDESK_PRIVACY_BAND_BOOTSTRAP"; @@ -36,6 +36,7 @@ struct PrivacyBandShared { volatile LONG sequence = 0; volatile LONG error = ERROR_IO_PENDING; volatile LONG active = 0; + volatile LONG topology_pending = 0; alignas(8) volatile LONG64 heartbeat = 0; wchar_t operation[128]{}; wchar_t unlock_hint[128]{}; diff --git a/apps/desktop/src/platform/windows/privacy/privacy_capture_exclusion.h b/apps/desktop/src/platform/windows/privacy/privacy_capture_exclusion.h new file mode 100644 index 00000000..f999e97b --- /dev/null +++ b/apps/desktop/src/platform/windows/privacy/privacy_capture_exclusion.h @@ -0,0 +1,30 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-09-21 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _PRIVACY_CAPTURE_EXCLUSION_H_ +#define _PRIVACY_CAPTURE_EXCLUSION_H_ + +#include + +namespace crossdesk { + +// WDA_EXCLUDEFROMCAPTURE: show the opaque cover locally while capture receives +// the desktop behind it. WDA_MONITOR would instead produce an empty/black area. +inline constexpr DWORD kPrivacyWindowDisplayAffinity = 0x00000011; + +inline bool ExcludePrivacyWindowFromCapture(HWND window) { + if (!SetWindowDisplayAffinity(window, kPrivacyWindowDisplayAffinity)) + return false; + DWORD affinity = 0; + if (!GetWindowDisplayAffinity(window, &affinity)) return false; + if (affinity == kPrivacyWindowDisplayAffinity) return true; + SetLastError(ERROR_INVALID_STATE); + return false; +} + +} // namespace crossdesk + +#endif \ No newline at end of file diff --git a/apps/desktop/src/platform/windows/privacy/privacy_topology.h b/apps/desktop/src/platform/windows/privacy/privacy_topology.h new file mode 100644 index 00000000..c5af6c30 --- /dev/null +++ b/apps/desktop/src/platform/windows/privacy/privacy_topology.h @@ -0,0 +1,34 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-09-21 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _PRIVACY_TOPOLOGY_H_ +#define _PRIVACY_TOPOLOGY_H_ + +#include +#include + +#include "privacy_band_ipc.h" + +namespace crossdesk { + +inline bool SameRect(const RECT& a, const RECT& b) { + return a.left == b.left && a.top == b.top && a.right == b.right && + a.bottom == b.bottom; +} + +inline bool MatchPrivacyTopology(const std::vector& monitors, + const std::vector& covers) { + return monitors.size() == covers.size() && + std::all_of(monitors.begin(), monitors.end(), [&](const RECT& monitor) { + return std::any_of(covers.begin(), covers.end(), [&](const auto& cover) { + return SameRect(cover.rect, monitor); + }); + }); +} + +} // namespace crossdesk + +#endif \ No newline at end of file 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 84362bb9..e5bf058a 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 @@ -430,7 +430,7 @@ int ScreenCapturerWin::Init(const int fps, cb_desktop_data cb) { } if (size == ScreenCapturer::kBackendReset) { SharedCapturedCursorState().Reset(); - if (privacy_) privacy_->Fail("Capture restarted; privacy screen will turn off"); + // Backend recovery is not a user request to disable local privacy. return; } if (data && size > 0 && w > 0 && h > 0) { @@ -578,6 +578,8 @@ void ScreenCapturerWin::EmitCapturedFrame( } restore_source{ std::exchange(current_frame_from_secure_desktop, from_secure_desktop)}; + // Privacy is a local, capture-excluded window. Always deliver the actual + // desktop pixels, including while that window is starting or stopping. if (native_frame) { if (!native_output_logged_.exchange(true, std::memory_order_relaxed)) { LOG_INFO("Windows capturer native frame output enabled (type={})", @@ -773,8 +775,6 @@ int ScreenCapturerWin::SwitchTo(int monitor_index) { const int ret = impl_->SwitchTo(backend_index); if (ret == 0) { monitor_index_.store(monitor_index, std::memory_order_relaxed); - } else if (privacy_) { - privacy_->Fail("Display switch failed; privacy screen will turn off"); } return ret; } diff --git a/apps/desktop/src/privacy/privacy_controller.cpp b/apps/desktop/src/privacy/privacy_controller.cpp index 2bad0dc4..26c3b93c 100644 --- a/apps/desktop/src/privacy/privacy_controller.cpp +++ b/apps/desktop/src/privacy/privacy_controller.cpp @@ -85,7 +85,8 @@ void PrivacyController::EnableOnConnection() { } void PrivacyController::Disable() { std::lock_guard lock(mutex_); - if (quit_) return; + // Nothing to restore when privacy is already off and no cleanup is retrying. + if (quit_ || (!engaged_.load() && !disable_pending_)) return; enable_pending_ = false; failure_.clear(); disable_pending_ = true; @@ -118,6 +119,9 @@ void PrivacyController::CaptureChanged(bool running) { WakeLocked(); } void PrivacyController::Run(Factory factory) { + // Only this thread touches backend_; Wake() is safe to call concurrently. + // Native enable/teardown can block for seconds, so those calls run with the + // mutex released to keep Snapshot()/Enable()/Disable() responsive. std::unique_lock lock(mutex_); try { if (factory) backend_ = factory(); @@ -134,7 +138,11 @@ void PrivacyController::Run(Factory factory) { SetState(caps.overlay ? PrivacyState::off : PrivacyState::unsupported, caps.reason); } if (disable_pending_) { - if (backend_) backend_->Recover(); + if (backend_) { + lock.unlock(); + backend_->Recover(); + lock.lock(); + } if (backend_ && backend_->RecoveryPending()) { lock.unlock(); backend_->WaitForEvents(25); @@ -145,7 +153,10 @@ void PrivacyController::Run(Factory factory) { if (backend_ && !backend_->IsRecovered()) { enable_pending_ = false; disable_pending_ = true; // Retry native cleanup without affecting the session. - SetState(PrivacyState::failed, "Could not release every privacy resource"); + // Report the stuck cleanup once; every retry would otherwise bump + // the revision and rebroadcast the status to all controllers. + if (status_.state != PrivacyState::failed) + SetState(PrivacyState::failed, "Could not release every privacy resource"); } else { status_.overlay_active = status_.input_blocked = false; if (!enable_pending_) { @@ -166,12 +177,18 @@ void PrivacyController::Run(Factory factory) { FailLocked("No desktop capture available for privacy screen"); } else { enable_pending_ = false; + const bool block_input = block_requested_; + const PrivacyScreenText text = text_; std::string error; - if (!backend_->Enable(block_requested_, text_, error)) { + lock.unlock(); + const bool enabled = backend_->Enable(block_input, text, error); + lock.lock(); + if (!enabled) { + // No-op if a disable arrived meanwhile; that path recovers anyway. FailLocked(error); } else { status_.overlay_active = true; - status_.input_blocked = block_requested_; + status_.input_blocked = block_input; started_ = Now(); } } @@ -203,8 +220,19 @@ void PrivacyController::Run(Factory factory) { } } catch (const std::exception& e) { if (!lock.owns_lock()) lock.lock(); + // The worker is gone: nothing can resolve a later Enable(), so report the + // fault and stop accepting requests instead of parking them in "starting". + status_.supported = status_.input_block_supported = false; + status_.overlay_active = status_.input_blocked = false; + engaged_.store(false); + quit_ = true; SetState(PrivacyState::failed, std::string("Privacy component failed: ") + e.what()); } - if (backend_) { backend_->Recover(); backend_.reset(); } + if (backend_) { + lock.unlock(); + backend_->Recover(); + lock.lock(); + backend_.reset(); + } } } // namespace crossdesk