diff --git a/.github/workflows/third-party-RustDeskTempTopMostWindow.yml b/.github/workflows/third-party-RustDeskTempTopMostWindow.yml index 4b36b54f2..d19b80d9e 100644 --- a/.github/workflows/third-party-RustDeskTempTopMostWindow.yml +++ b/.github/workflows/third-party-RustDeskTempTopMostWindow.yml @@ -45,10 +45,10 @@ jobs: run: | git clone https://github.com/rustdesk-org/RustDeskTempTopMostWindow RustDeskTempTopMostWindow - # Build. commit 53b548a5398624f7149a382000397993542ad796 is tag v0.3 + # Build. commit 3b79772afb754a5a1111804864616c2e81513de8, support multiple monitors - name: Build the project run: | - cd RustDeskTempTopMostWindow && git checkout 53b548a5398624f7149a382000397993542ad796 + cd RustDeskTempTopMostWindow && git checkout 3b79772afb754a5a1111804864616c2e81513de8 msbuild ${{ env.project_path }} -p:Configuration=${{ inputs.configuration }} -p:Platform=${{ inputs.platform }} /p:TargetVersion=${{ inputs.target_version }} - name: Archive build artifacts diff --git a/flutter/lib/common/widgets/toolbar.dart b/flutter/lib/common/widgets/toolbar.dart index 637904c2c..9653b5478 100644 --- a/flutter/lib/common/widgets/toolbar.dart +++ b/flutter/lib/common/widgets/toolbar.dart @@ -72,10 +72,24 @@ Widget waylandKeyboardScopeChip(BuildContext context, String text) { ); } -// macOS privacy mode blacks out all online displays, so switching the remote -// display does not weaken the local privacy protection. -bool allowDisplaySwitchInPrivacyMode(PeerInfo pi) { - return pi.platform == kPeerPlatformMacOS; +bool _isWindowsMode1PrivacyImpl(String privacyModeImpl) { + return privacyModeImpl == kPrivacyModeImplMag || + privacyModeImpl == kPrivacyModeImplExcludeFromCapture; +} + +// macOS privacy mode blacks out all online displays. Windows Mode 1 also +// covers every local monitor with privacy overlay windows, so remote display +// switching does not weaken local privacy protection. +// +// Keep this separate from the capture backend capability. The legacy Windows +// magnifier capturer is not reliable for multi-monitor capture; WebRTC's +// screen_capturer_win_magnifier also disables it when SM_CMONITORS != 1: +// https://webrtc.googlesource.com/src/+/1845922d5a1bf9c27deeffb4a8c8daea124434c1/modules/desktop_capture/win/screen_capturer_win_magnifier.cc +bool allowDisplaySwitchInPrivacyMode(PeerInfo pi, String privacyModeImpl) { + return pi.platform == kPeerPlatformMacOS || + (pi.platform == kPeerPlatformWindows && + _isWindowsMode1PrivacyImpl(privacyModeImpl) && + versionCmp(pi.version, '1.4.8') >= 0); } class TTextMenu { @@ -964,7 +978,8 @@ Future> toolbarDisplayToggle( final privacyModeState = PrivacyModeState.find(id); if (pi.isSupportMultiDisplay && - (privacyModeState.isEmpty || allowDisplaySwitchInPrivacyMode(pi)) && + (privacyModeState.isEmpty || + allowDisplaySwitchInPrivacyMode(pi, privacyModeState.value)) && pi.displaysCount.value > 1 && bind.mainGetUserDefaultOption(key: kKeyShowMonitorsToolbar) == 'Y') { final value = @@ -1048,7 +1063,20 @@ List toolbarPrivacyMode( return []; // No permission and not active, hide options. } - getDefaultMenu(Future Function(SessionID sid, String opt) toggleFunc) { + bool checkDisplayAllowedForPrivacyMode(String targetImplKey, bool turnOn) { + if (!turnOn || + allowDisplaySwitchInPrivacyMode(pi, targetImplKey) || + (ffiModel.pi.currentDisplay == 0 && + !bind.sessionIsMultiUiSession(sessionId: sessionId))) { + return true; + } + msgBox(sessionId, 'custom-nook-nocancel-hasclose', 'info', + 'Please switch to Display 1 first', '', ffi.dialogManager); + return false; + } + + getDefaultMenu(Future Function(SessionID sid, String opt) toggleFunc, + String targetImplKey) { final enabled = !ffiModel.viewOnly && (hasPrivacyModePermission || privacyModeState.isNotEmpty); return TToggleMenu( @@ -1056,16 +1084,7 @@ List toolbarPrivacyMode( onChanged: enabled ? (value) { if (value == null) return; - if (!allowDisplaySwitchInPrivacyMode(pi) && - ffiModel.pi.currentDisplay != 0 && - ffiModel.pi.currentDisplay != kAllDisplayValue) { - msgBox( - sessionId, - 'custom-nook-nocancel-hasclose', - 'info', - 'Please switch to Display 1 first', - '', - ffi.dialogManager); + if (!checkDisplayAllowedForPrivacyMode(targetImplKey, value)) { return; } final option = 'privacy-mode'; @@ -1083,7 +1102,7 @@ List toolbarPrivacyMode( getDefaultMenu((sid, opt) async { bind.sessionToggleOption(sessionId: sid, value: opt); togglePrivacyModeTime = DateTime.now(); - }) + }, kPrivacyModeImplMag) ]; } if (privacyModeImpls.isEmpty) { @@ -1097,7 +1116,7 @@ List toolbarPrivacyMode( bind.sessionTogglePrivacyMode( sessionId: sid, implKey: implKey, on: privacyModeState.isEmpty); togglePrivacyModeTime = DateTime.now(); - }) + }, implKey) ]; } else { final visibleImpls = hasPrivacyModePermission @@ -1118,6 +1137,9 @@ List toolbarPrivacyMode( ? (value) { if (value == null) return; if (value && !hasPrivacyModePermission) return; + if (!checkDisplayAllowedForPrivacyMode(implKey, value)) { + return; + } togglePrivacyModeTime = DateTime.now(); bind.sessionTogglePrivacyMode( sessionId: sessionId, implKey: implKey, on: value); diff --git a/flutter/lib/consts.dart b/flutter/lib/consts.dart index adf7b1d45..8caf6ee11 100644 --- a/flutter/lib/consts.dart +++ b/flutter/lib/consts.dart @@ -29,6 +29,10 @@ const String kPlatformAdditionsHasFileClipboard = "has_file_clipboard"; const String kPlatformAdditionsSupportedPrivacyModeImpl = "supported_privacy_mode_impl"; +const String kPrivacyModeImplMag = 'privacy_mode_impl_mag'; +const String kPrivacyModeImplExcludeFromCapture = + 'privacy_mode_impl_exclude_from_capture'; + const String kPeerPlatformWindows = "Windows"; const String kPeerPlatformLinux = "Linux"; const String kPeerPlatformMacOS = "Mac OS"; diff --git a/flutter/lib/desktop/widgets/remote_toolbar.dart b/flutter/lib/desktop/widgets/remote_toolbar.dart index 210cc8571..686120be5 100644 --- a/flutter/lib/desktop/widgets/remote_toolbar.dart +++ b/flutter/lib/desktop/widgets/remote_toolbar.dart @@ -810,8 +810,9 @@ class _RemoteToolbarState extends State { } toolbarItems.add(Obx(() { - if ((PrivacyModeState.find(widget.id).isEmpty || - allowDisplaySwitchInPrivacyMode(pi)) && + final privacyModeState = PrivacyModeState.find(widget.id); + if ((privacyModeState.isEmpty || + allowDisplaySwitchInPrivacyMode(pi, privacyModeState.value)) && pi.displaysCount.value > 1) { return _MonitorMenu( id: widget.id, diff --git a/flutter/lib/mobile/pages/remote_page.dart b/flutter/lib/mobile/pages/remote_page.dart index 6f3831072..8395f4540 100644 --- a/flutter/lib/mobile/pages/remote_page.dart +++ b/flutter/lib/mobile/pages/remote_page.dart @@ -1220,7 +1220,11 @@ void showOptions( if (image != null) { displays.add(Padding(padding: const EdgeInsets.only(top: 8), child: image)); } - if (pi.displays.length > 1 && pi.currentDisplay != kAllDisplayValue) { + final privacyModeState = PrivacyModeState.find(id); + if (pi.displays.length > 1 && + pi.currentDisplay != kAllDisplayValue && + (privacyModeState.isEmpty || + allowDisplaySwitchInPrivacyMode(pi, privacyModeState.value))) { final cur = pi.currentDisplay; final children = []; final isDarkTheme = MyTheme.currentThemeMode() == ThemeMode.dark; @@ -1274,8 +1278,6 @@ void showOptions( await toolbarDisplayToggle(context, id, gFFI); List privacyModeList = []; - // privacy mode - final privacyModeState = PrivacyModeState.find(id); if ((gFFI.ffiModel.pi.features.privacyMode && gFFI.ffiModel.keyboard) || privacyModeState.isNotEmpty) { privacyModeList = toolbarPrivacyMode(privacyModeState, context, id, gFFI); diff --git a/libs/scrap/src/dxgi/mag.rs b/libs/scrap/src/dxgi/mag.rs index 75fc892cf..313e1c694 100644 --- a/libs/scrap/src/dxgi/mag.rs +++ b/libs/scrap/src/dxgi/mag.rs @@ -52,6 +52,33 @@ lazy_static::lazy_static! { static ref MAG_BUFFER: Mutex<(bool, Vec)> = Default::default(); } +fn find_windows(cls: &str, name: &str) -> Result> { + let name_c = CString::new(name)?; + let cls_c = if cls.is_empty() { + None + } else { + Some(CString::new(cls)?) + }; + let mut hwnds = Vec::new(); + unsafe { + let mut after = NULL as _; + loop { + let hwnd = FindWindowExA( + NULL as _, + after, + cls_c.as_ref().map_or(NULL as _, |c| c.as_ptr()), + name_c.as_ptr(), + ); + if hwnd.is_null() { + break; + } + hwnds.push(hwnd); + after = hwnd; + } + } + Ok(hwnds) +} + pub type REFWICPixelFormatGUID = *const GUID; pub type WICPixelFormatGUID = GUID; @@ -247,6 +274,8 @@ pub struct CapturerMag { rect: RECT, width: usize, height: usize, + excluded_window_target: Option<(String, String)>, + excluded_windows: Vec, } impl Drop for CapturerMag { @@ -261,6 +290,10 @@ impl CapturerMag { MagInterface::new().is_ok() } + // This captures through the legacy Windows Magnification API. Do not infer + // multi-monitor capture support from privacy overlay coverage: WebRTC also + // disables its magnifier capturer when SM_CMONITORS != 1. + // https://webrtc.googlesource.com/src/+/1845922d5a1bf9c27deeffb4a8c8daea124434c1/modules/desktop_capture/win/screen_capturer_win_magnifier.cc pub(crate) fn new(origin: (i32, i32), width: usize, height: usize) -> Result { unsafe { let x = GetSystemMetrics(SM_XVIRTUALSCREEN); @@ -305,6 +338,8 @@ impl CapturerMag { }, width, height, + excluded_window_target: None, + excluded_windows: Vec::new(), }; unsafe { @@ -436,19 +471,41 @@ impl CapturerMag { } pub(crate) fn exclude(&mut self, cls: &str, name: &str) -> Result { - let name_c = CString::new(name)?; + let mut hwnds = find_windows(cls, name)?; + hwnds.sort_unstable_by_key(|hwnd| *hwnd as usize); + self.excluded_window_target = Some((cls.to_owned(), name.to_owned())); + if hwnds.is_empty() { + self.excluded_windows.clear(); + return Ok(false); + } + + self.exclude_windows(&mut hwnds)?; + self.excluded_windows = hwnds; + Ok(true) + } + + fn refresh_excluded_windows(&mut self) -> Result<()> { + let Some((cls, name)) = self.excluded_window_target.as_ref() else { + return Ok(()); + }; + let mut hwnds = find_windows(cls, name)?; + hwnds.sort_unstable_by_key(|hwnd| *hwnd as usize); + // This runs from frame() because refreshed privacy overlays get new + // HWNDs. It is only used on the legacy magnifier backend while privacy + // mode is active; if it shows up as hot-path cost, throttle this check. + // Keep the previous filter list while privacy windows are being recreated. + if hwnds.is_empty() || hwnds == self.excluded_windows { + return Ok(()); + } + + self.exclude_windows(&mut hwnds)?; + self.excluded_windows = hwnds; + Ok(()) + } + + fn exclude_windows(&mut self, hwnds: &mut [HWND]) -> Result { + let count = hwnds.len() as _; unsafe { - let mut hwnd = if cls.len() == 0 { - FindWindowExA(NULL as _, NULL as _, NULL as _, name_c.as_ptr()) - } else { - let cls_c = CString::new(cls).unwrap(); - FindWindowExA(NULL as _, NULL as _, cls_c.as_ptr(), name_c.as_ptr()) - }; - - if hwnd.is_null() { - return Ok(false); - } - if let Some(set_window_filter_list_func) = self.mag_interface.set_window_filter_list_func { @@ -456,16 +513,15 @@ impl CapturerMag { == set_window_filter_list_func( self.magnifier_window, MW_FILTERMODE_EXCLUDE, - 1, - &mut hwnd, + count, + hwnds.as_mut_ptr(), ) { return Err(Error::new( ErrorKind::Other, format!( - "Failed MagSetWindowFilterList for cls {} name {}, error {}", - cls, - name, + "Failed MagSetWindowFilterList for {} windows, error {}", + count, Error::last_os_error() ), )); @@ -496,6 +552,7 @@ impl CapturerMag { } pub(crate) fn frame(&mut self, data: &mut Vec) -> Result<()> { + self.refresh_excluded_windows()?; Self::clear_data(); unsafe { diff --git a/src/privacy_mode/win_topmost_window.rs b/src/privacy_mode/win_topmost_window.rs index a7f80a02d..b30db5fbe 100644 --- a/src/privacy_mode/win_topmost_window.rs +++ b/src/privacy_mode/win_topmost_window.rs @@ -4,13 +4,14 @@ use hbb_common::{allow_err, bail, log, ResultType}; use std::{ ffi::CString, io::Error, + mem::size_of, time::{Duration, Instant}, }; use winapi::{ shared::{ - minwindef::FALSE, + minwindef::{BOOL, FALSE, LPARAM, TRUE}, ntdef::{HANDLE, NULL}, - windef::HWND, + windef::{HDC, HMONITOR, HWND, RECT}, }, um::{ handleapi::CloseHandle, @@ -31,7 +32,13 @@ pub(super) const PRIVACY_MODE_IMPL: &str = "privacy_mode_impl_mag"; pub const ORIGIN_PROCESS_EXE: &'static str = "C:\\Windows\\System32\\RuntimeBroker.exe"; pub const WIN_TOPMOST_INJECTED_PROCESS_EXE: &'static str = "RuntimeBroker_rustdesk.exe"; pub const INJECTED_PROCESS_EXE: &'static str = WIN_TOPMOST_INJECTED_PROCESS_EXE; +pub(super) const PRIVACY_WINDOW_CLASS: &'static str = "RustDeskPrivacyWindowClass"; pub(super) const PRIVACY_WINDOW_NAME: &'static str = "RustDeskPrivacyWindow"; +const PRIVACY_WINDOW_WAIT_MILLIS: u128 = 1_000; +const PRIVACY_WINDOW_WAIT_EXTRA_MONITOR_MILLIS: u128 = 500; +const PRIVACY_WINDOW_POLL_INTERVAL_MILLIS: u64 = 100; +const WM_RUSTDESK_SHOW_WINDOWS: u32 = WM_APP + 3; +const WM_RUSTDESK_HIDE_WINDOWS: u32 = WM_APP + 4; struct WindowHandlers { hthread: u64, @@ -102,22 +109,17 @@ impl PrivacyMode for PrivacyModeImpl { ); } - if self.handlers.is_default() { - log::info!("turn_on_privacy, dll not found when started, try start"); + let should_start_broker = self.handlers.is_default(); + if should_start_broker { + log::info!("turn_on_privacy, broker not running, try start"); self.start()?; std::thread::sleep(std::time::Duration::from_millis(1_000)); } - let hwnd = wait_find_privacy_hwnd(0)?; - if hwnd.is_null() { - bail!("No privacy window created"); + if let Err(e) = self.show_privacy_windows(conn_id, true) { + self.stop(); + return Err(e); } - super::win_input::hook()?; - unsafe { - ShowWindow(hwnd as _, SW_SHOW); - } - self.conn_id = conn_id; - self.hwnd = hwnd as _; Ok(true) } @@ -128,27 +130,33 @@ impl PrivacyMode for PrivacyModeImpl { ) -> ResultType<()> { self.check_off_conn_id(conn_id)?; super::win_input::unhook()?; - - unsafe { - let hwnd = wait_find_privacy_hwnd(0)?; - if !hwnd.is_null() { - ShowWindow(hwnd, SW_HIDE); - } + let hwnds = find_privacy_hwnds()?; + let hide_result = set_privacy_windows_visible(&hwnds, false); + if hide_result.is_err() { + self.stop(); } + // Continue local state cleanup even after stop(); the broker has + // been torn down, so keeping conn_id/hwnd would leave stale state. if self.conn_id != INVALID_PRIVACY_MODE_CONN_ID { - if let Some(state) = state { - allow_err!(super::set_privacy_mode_state( - conn_id, - state, - PRIVACY_MODE_IMPL.to_string(), - 1_000 - )); + // Only publish the off state after the hide message was posted. + // Otherwise the peer may receive a success-like state and then a + // failed turn-off response for the same request. + if hide_result.is_ok() { + if let Some(state) = state { + allow_err!(super::set_privacy_mode_state( + conn_id, + state, + PRIVACY_MODE_IMPL.to_string(), + 1_000 + )); + } } self.conn_id = INVALID_PRIVACY_MODE_CONN_ID.to_owned(); + self.hwnd = 0; } - Ok(()) + hide_result.map(|_| ()) } #[inline] @@ -206,8 +214,7 @@ impl PrivacyModeImpl { ); } - let hwnd = wait_find_privacy_hwnd(1_000)?; - if !hwnd.is_null() { + if wait_find_privacy_hwnds(PRIVACY_WINDOW_WAIT_MILLIS).is_ok() { log::info!("Privacy window is ready"); return Ok(()); } @@ -276,14 +283,19 @@ impl PrivacyModeImpl { ); }; - inject_dll( + if let Err(e) = inject_dll( proc_info.hProcess, proc_info.hThread, dll_file.to_string_lossy().as_ref(), - )?; + ) { + TerminateProcess(proc_info.hProcess, 0); + CloseHandle(proc_info.hThread); + CloseHandle(proc_info.hProcess); + return Err(e); + } if 0xffffffff == ResumeThread(proc_info.hThread) { - // CloseHandle + TerminateProcess(proc_info.hProcess, 0); CloseHandle(proc_info.hThread); CloseHandle(proc_info.hProcess); @@ -296,9 +308,9 @@ impl PrivacyModeImpl { self.handlers.hthread = proc_info.hThread as _; self.handlers.hprocess = proc_info.hProcess as _; - let hwnd = wait_find_privacy_hwnd(1_000)?; - if hwnd.is_null() { - bail!("Failed to get hwnd after started"); + if let Err(e) = wait_find_privacy_hwnds(PRIVACY_WINDOW_WAIT_MILLIS) { + self.handlers.reset(); + return Err(e); } } @@ -309,6 +321,49 @@ impl PrivacyModeImpl { pub fn stop(&mut self) { self.handlers.reset(); } + + fn show_privacy_windows(&mut self, conn_id: i32, hook_input: bool) -> ResultType<()> { + let hwnds = wait_find_privacy_hwnds(PRIVACY_WINDOW_WAIT_MILLIS)?; + if hwnds.is_empty() { + bail!("No privacy window created"); + } + + if hook_input { + super::win_input::hook()?; + } + match set_privacy_windows_visible(&hwnds, true) { + Ok(_) => { + let visible_hwnds = + match wait_find_visible_privacy_hwnds(PRIVACY_WINDOW_WAIT_MILLIS) { + Ok(hwnds) => hwnds, + Err(e) => { + allow_err!(set_privacy_windows_visible(&hwnds, false)); + if hook_input { + allow_err!(super::win_input::unhook()); + } + return Err(e); + } + }; + let Some(hwnd) = visible_hwnds.first() else { + allow_err!(set_privacy_windows_visible(&hwnds, false)); + if hook_input { + allow_err!(super::win_input::unhook()); + } + bail!("No visible privacy window created"); + }; + self.conn_id = conn_id; + self.hwnd = *hwnd as _; + Ok(()) + } + Err(e) => { + allow_err!(set_privacy_windows_visible(&hwnds, false)); + if hook_input { + allow_err!(super::win_input::unhook()); + } + Err(e) + } + } + } } impl Drop for PrivacyModeImpl { @@ -363,21 +418,217 @@ unsafe fn inject_dll<'a>(hproc: HANDLE, hthread: HANDLE, dll_file: &'a str) -> R Ok(()) } -pub(super) fn wait_find_privacy_hwnd(msecs: u128) -> ResultType { +fn wait_find_privacy_hwnds(msecs: u128) -> ResultType> { + wait_find_privacy_hwnds_impl(msecs, false) +} + +fn wait_find_visible_privacy_hwnds(msecs: u128) -> ResultType> { + wait_find_privacy_hwnds_impl(msecs, true) +} + +fn privacy_window_wait_millis(base_millis: u128, monitor_count: usize) -> u128 { + if base_millis == 0 { + return 0; + } + // Privacy Mode 1 creates one overlay per monitor. Keep the single-monitor + // wait as the base and add time for each extra overlay before coverage + // verification times out. + base_millis + + (monitor_count.saturating_sub(1) as u128) * PRIVACY_WINDOW_WAIT_EXTRA_MONITOR_MILLIS +} + +fn wait_find_privacy_hwnds_impl(msecs: u128, require_visible: bool) -> ResultType> { + // This verifies initial turn-on coverage. If displays change during this + // short poll window, the DLL refreshes overlays asynchronously, while this + // check may still time out against the geometry sampled here. + let monitor_rects = get_monitor_rects()?; + if monitor_rects.is_empty() { + bail!("No privacy monitor found"); + } + let msecs = privacy_window_wait_millis(msecs, monitor_rects.len()); + let tm_begin = Instant::now(); - let wndname = CString::new(PRIVACY_WINDOW_NAME)?; loop { - unsafe { - let hwnd = FindWindowA(NULL as _, wndname.as_ptr() as _); - if !hwnd.is_null() { - return Ok(hwnd); - } + let hwnds = find_privacy_hwnds()?; + let visible_hwnds = if require_visible { + filter_visible_hwnds(&hwnds) + } else { + Vec::new() + }; + let covered_hwnds = if require_visible { + visible_hwnds.as_slice() + } else { + hwnds.as_slice() + }; + let covered = count_covered_monitors(covered_hwnds, &monitor_rects); + if covered == monitor_rects.len() { + return Ok(if require_visible { + visible_hwnds + } else { + hwnds + }); } if msecs == 0 || tm_begin.elapsed().as_millis() > msecs { - return Ok(NULL as _); + let visible = if require_visible { "visible " } else { "" }; + bail!( + "Expected {}privacy windows to cover {} monitors, covered {}, found {}", + visible, + monitor_rects.len(), + covered, + hwnds.len(), + ); } - std::thread::sleep(Duration::from_millis(100)); + std::thread::sleep(Duration::from_millis(PRIVACY_WINDOW_POLL_INTERVAL_MILLIS)); } } + +fn find_privacy_hwnds() -> ResultType> { + let class_name = CString::new(PRIVACY_WINDOW_CLASS)?; + let wndname = CString::new(PRIVACY_WINDOW_NAME)?; + let mut hwnds = Vec::new(); + unsafe { + let mut after = NULL as _; + loop { + let hwnd = FindWindowExA( + NULL as _, + after, + class_name.as_ptr() as _, + wndname.as_ptr() as _, + ); + if hwnd.is_null() { + break; + } + hwnds.push(hwnd); + after = hwnd; + } + } + Ok(hwnds) +} + +fn filter_visible_hwnds(hwnds: &[HWND]) -> Vec { + hwnds + .iter() + .copied() + .filter(|hwnd| unsafe { FALSE != IsWindowVisible(*hwnd) }) + .collect() +} + +fn set_privacy_windows_visible(hwnds: &[HWND], show: bool) -> ResultType { + if hwnds.is_empty() { + return Ok(0); + }; + let message = if show { + WM_RUSTDESK_SHOW_WINDOWS + } else { + WM_RUSTDESK_HIDE_WINDOWS + }; + let mut posted = 0; + let mut first_error = None; + for &hwnd in hwnds { + unsafe { + if FALSE == PostMessageA(hwnd, message, 0, 0) { + if first_error.is_none() { + first_error = Some(Error::last_os_error()); + } + } else { + posted += 1; + } + } + } + if let Some(error) = first_error { + bail!( + "Failed to post privacy window visibility message to all privacy windows, posted {}/{}, first error {}", + posted, + hwnds.len(), + error, + ); + } + Ok(posted) +} + +fn get_monitor_rects() -> ResultType> { + let mut rects = Vec::new(); + unsafe { + if FALSE + == EnumDisplayMonitors( + NULL as _, + NULL as _, + Some(enum_monitor_rect_proc), + &mut rects as *mut Vec as LPARAM, + ) + { + bail!( + "Failed EnumDisplayMonitors, error {}", + Error::last_os_error() + ); + } + } + Ok(rects) +} + +unsafe extern "system" fn enum_monitor_rect_proc( + hmon: HMONITOR, + _hdc: HDC, + _rect: *mut RECT, + lparam: LPARAM, +) -> BOOL { + let rects = &mut *(lparam as *mut Vec); + let mut monitor_info = MONITORINFO { + cbSize: size_of::() as _, + rcMonitor: RECT { + left: 0, + top: 0, + right: 0, + bottom: 0, + }, + rcWork: RECT { + left: 0, + top: 0, + right: 0, + bottom: 0, + }, + dwFlags: 0, + }; + if FALSE == GetMonitorInfoA(hmon, &mut monitor_info) { + return FALSE; + } + rects.push(monitor_info.rcMonitor); + TRUE +} + +fn count_covered_monitors(hwnds: &[HWND], monitor_rects: &[RECT]) -> usize { + let mut covered = 0; + for monitor_rect in monitor_rects { + for hwnd in hwnds { + let mut window_rect = RECT { + left: 0, + top: 0, + right: 0, + bottom: 0, + }; + unsafe { + if FALSE == GetWindowRect(*hwnd, &mut window_rect) { + log::warn!( + "Failed GetWindowRect for privacy window, error {}", + Error::last_os_error() + ); + continue; + } + } + if rect_covers(&window_rect, monitor_rect) { + covered += 1; + break; + } + } + } + covered +} + +fn rect_covers(window_rect: &RECT, monitor_rect: &RECT) -> bool { + window_rect.left <= monitor_rect.left + && window_rect.top <= monitor_rect.top + && window_rect.right >= monitor_rect.right + && window_rect.bottom >= monitor_rect.bottom +} diff --git a/src/server/video_service.rs b/src/server/video_service.rs index 13a781c28..15f0ef893 100644 --- a/src/server/video_service.rs +++ b/src/server/video_service.rs @@ -272,6 +272,10 @@ fn create_capturer( if privacy_mode_id > 0 { #[cfg(windows)] { + // Windows Mode 1 can cover every local monitor with overlay windows, + // but the legacy magnifier capture backend is still single-monitor + // constrained. Keep display-switch gating aligned with that backend + // limit, not just the overlay coverage. if let Some(c1) = crate::privacy_mode::win_mag::create_capturer( privacy_mode_id, display.origin(),