From ceb0a1654a86d3ee94922832e3ffbcc199cacb5f Mon Sep 17 00:00:00 2001 From: dijunkun Date: Fri, 11 Sep 2026 16:11:50 +0800 Subject: [PATCH] [feat] add Windows privacy screen and prefer DXGI capture --- .github/workflows/build.yml | 4 +- .../windows/crossdesk_privacy_window.rc | 13 + apps/desktop/scripts/windows/nsis_script.nsi | 6 +- .../src/config_center/config_center.cpp | 23 + .../desktop/src/config_center/config_center.h | 4 + .../src/gui/application/gui_application.cpp | 91 ++- .../assets/localization/localization_data.h | 14 + .../devices/session_device_manager.cpp | 23 +- .../features/devices/session_device_manager.h | 5 +- .../features/input/keyboard_controller.cpp | 50 +- .../gui/features/input/keyboard_controller.h | 4 + .../src/gui/runtime/connection_runtime.cpp | 17 + apps/desktop/src/gui/runtime/gui_runtime.h | 12 + .../src/gui/runtime/peer_data_callbacks.cpp | 50 +- .../src/gui/runtime/peer_event_handler.cpp | 14 + .../src/gui/runtime/privacy_runtime.cpp | 92 +++ apps/desktop/src/gui/runtime/remote_session.h | 10 +- apps/desktop/src/gui/ui/main_window.slint | 8 + apps/desktop/src/gui/ui/stream_window.slint | 42 +- apps/desktop/src/gui/ui/ui_localization.h | 6 +- .../common/gui/service_status_runtime.cpp | 7 + .../gui/runtime/windows_service_runtime.cpp | 4 + .../windows/input/mouse/mouse_controller.cpp | 161 ++-- .../windows/input/mouse/mouse_controller.h | 3 + .../windows/input/windows_input_marker.h | 1 + .../windows/privacy/band_module/main.cpp | 293 ++++++++ .../band_module/privacy_cover_renderer.cpp | 123 +++ .../band_module/privacy_cover_renderer.h | 20 + .../windows/privacy/cursor_helper/main.cpp | 83 ++ .../windows/privacy/privacy_backend_win.cpp | 708 ++++++++++++++++++ .../windows/privacy/privacy_band_guard.cpp | 323 ++++++++ .../windows/privacy/privacy_band_guard.h | 51 ++ .../windows/privacy/privacy_band_ipc.h | 53 ++ .../windows/privacy/privacy_cursor_api.h | 70 ++ .../windows/privacy/privacy_cursor_guard.cpp | 158 ++++ .../windows/privacy/privacy_cursor_guard.h | 39 + .../windows/privacy/privacy_input_guard.cpp | 304 ++++++++ .../windows/privacy/privacy_input_guard.h | 59 ++ .../privacy/privacy_probe_renderer.cpp | 142 ++++ .../windows/privacy/privacy_probe_renderer.h | 32 + .../screen_capturer/screen_capturer_dxgi.cpp | 104 ++- .../screen_capturer/screen_capturer_dxgi.h | 2 + .../screen_capturer/screen_capturer_wgc.cpp | 4 + .../screen_capturer/screen_capturer_win.cpp | 236 +++--- .../screen_capturer/screen_capturer_win.h | 11 +- .../screen_capturer/wgc_session_impl.cpp | 1 + .../src/platform/windows/windows_thread_dpi.h | 53 ++ apps/desktop/src/privacy/privacy_backend.h | 64 ++ .../src/privacy/privacy_controller.cpp | 496 ++++++++++++ apps/desktop/src/privacy/privacy_controller.h | 99 +++ .../src/privacy/privacy_frame_verifier.h | 98 +++ .../src/privacy/privacy_probe_pattern.h | 37 + .../src/screen_capturer/screen_capturer.h | 3 + .../windows_mouse_controller_safety_test.cpp | 2 - apps/desktop/xmake/targets.lua | 30 +- libs/wire/include/remote_action.h | 23 +- libs/wire/src/remote_action.cpp | 40 + 57 files changed, 4208 insertions(+), 217 deletions(-) create mode 100644 apps/desktop/resources/windows/crossdesk_privacy_window.rc create mode 100644 apps/desktop/src/gui/runtime/privacy_runtime.cpp create mode 100644 apps/desktop/src/platform/windows/privacy/band_module/main.cpp create mode 100644 apps/desktop/src/platform/windows/privacy/band_module/privacy_cover_renderer.cpp create mode 100644 apps/desktop/src/platform/windows/privacy/band_module/privacy_cover_renderer.h create mode 100644 apps/desktop/src/platform/windows/privacy/cursor_helper/main.cpp create mode 100644 apps/desktop/src/platform/windows/privacy/privacy_backend_win.cpp create mode 100644 apps/desktop/src/platform/windows/privacy/privacy_band_guard.cpp create mode 100644 apps/desktop/src/platform/windows/privacy/privacy_band_guard.h create mode 100644 apps/desktop/src/platform/windows/privacy/privacy_band_ipc.h create mode 100644 apps/desktop/src/platform/windows/privacy/privacy_cursor_api.h create mode 100644 apps/desktop/src/platform/windows/privacy/privacy_cursor_guard.cpp create mode 100644 apps/desktop/src/platform/windows/privacy/privacy_cursor_guard.h create mode 100644 apps/desktop/src/platform/windows/privacy/privacy_input_guard.cpp create mode 100644 apps/desktop/src/platform/windows/privacy/privacy_input_guard.h create mode 100644 apps/desktop/src/platform/windows/privacy/privacy_probe_renderer.cpp create mode 100644 apps/desktop/src/platform/windows/privacy/privacy_probe_renderer.h create mode 100644 apps/desktop/src/platform/windows/windows_thread_dpi.h create mode 100644 apps/desktop/src/privacy/privacy_backend.h create mode 100644 apps/desktop/src/privacy/privacy_controller.cpp create mode 100644 apps/desktop/src/privacy/privacy_controller.h create mode 100644 apps/desktop/src/privacy/privacy_frame_verifier.h create mode 100644 apps/desktop/src/privacy/privacy_probe_pattern.h diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 849acc9a..339ea5eb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -682,7 +682,9 @@ jobs: $portableFiles = @( @("crossdesk.exe", "CrossDesk.exe"), @("crossdesk_service.exe", "crossdesk_service.exe"), - @("crossdesk_session_helper.exe", "crossdesk_session_helper.exe") + @("crossdesk_session_helper.exe", "crossdesk_session_helper.exe"), + @("crossdesk_privacy_cursor_helper.exe", "crossdesk_privacy_cursor_helper.exe"), + @("crossdesk_privacy_window.dll", "crossdesk_privacy_window.dll") ) foreach ($file in $portableFiles) { diff --git a/apps/desktop/resources/windows/crossdesk_privacy_window.rc b/apps/desktop/resources/windows/crossdesk_privacy_window.rc new file mode 100644 index 00000000..c0d84468 --- /dev/null +++ b/apps/desktop/resources/windows/crossdesk_privacy_window.rc @@ -0,0 +1,13 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-09-11 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +IDI_CROSSDESK_LOGO ICON "crossdesk.ico" + +#define CROSSDESK_FILE_DESCRIPTION "CrossDesk Privacy Window" +#define CROSSDESK_ORIGINAL_FILENAME "crossdesk_privacy_window.dll" +#define CROSSDESK_FILE_TYPE VFT_DLL + +#include "version_info.rcinc" diff --git a/apps/desktop/scripts/windows/nsis_script.nsi b/apps/desktop/scripts/windows/nsis_script.nsi index 16b1bd90..9c0556e8 100644 --- a/apps/desktop/scripts/windows/nsis_script.nsi +++ b/apps/desktop/scripts/windows/nsis_script.nsi @@ -88,8 +88,10 @@ installApp: ; Bundle service-side binaries required by the Windows service flow File "..\..\..\..\build\windows\x64\release\crossdesk_service.exe" File "..\..\..\..\build\windows\x64\release\crossdesk_session_helper.exe" + File "..\..\..\..\build\windows\x64\release\crossdesk_privacy_cursor_helper.exe" + File "..\..\..\..\build\windows\x64\release\crossdesk_privacy_window.dll" ; Bundle runtime DLLs from the release output directory - File "..\..\..\..\build\windows\x64\release\*.dll" + File /x crossdesk_privacy_window.dll "..\..\..\..\build\windows\x64\release\*.dll" Call RegisterInstalledService @@ -145,6 +147,8 @@ uninstallApp: Delete "$INSTDIR\CrossDesk.exe" Delete "$INSTDIR\crossdesk_service.exe" Delete "$INSTDIR\crossdesk_session_helper.exe" + Delete "$INSTDIR\crossdesk_privacy_cursor_helper.exe" + Delete "$INSTDIR\crossdesk_privacy_window.dll" Delete "$INSTDIR\uninstall.exe" ; Recursively delete installation directory diff --git a/apps/desktop/src/config_center/config_center.cpp b/apps/desktop/src/config_center/config_center.cpp index d696df0d..d39ce1e3 100644 --- a/apps/desktop/src/config_center/config_center.cpp +++ b/apps/desktop/src/config_center/config_center.cpp @@ -119,6 +119,8 @@ int ConfigCenter::Load() { enable_autostart_ = ini_.GetBoolValue(section_, "enable_autostart", enable_autostart_); enable_daemon_ = ini_.GetBoolValue(section_, "enable_daemon", enable_daemon_); + enable_privacy_screen_.store( + ini_.GetBoolValue(section_, "enable_privacy_screen", true)); portable_service_prompt_suppressed_ = ini_.GetBoolValue(section_, "portable_service_prompt_suppressed", portable_service_prompt_suppressed_); @@ -165,6 +167,8 @@ int ConfigCenter::Save() { ini_.SetBoolValue(section_, "enable_autostart", enable_autostart_); ini_.SetBoolValue(section_, "enable_daemon", enable_daemon_); + ini_.SetBoolValue(section_, "enable_privacy_screen", + enable_privacy_screen_.load()); ini_.SetBoolValue(section_, "portable_service_prompt_suppressed", portable_service_prompt_suppressed_); @@ -370,6 +374,21 @@ int ConfigCenter::SetDaemon(bool enable_daemon) { return 0; } +int ConfigCenter::SetPrivacyScreen(bool enable_privacy_screen) { + const bool previous = enable_privacy_screen_.load(); + if (previous == enable_privacy_screen) return 0; + ini_.SetBoolValue(section_, "enable_privacy_screen", enable_privacy_screen); + if (ini_.SaveFile(config_path_.c_str()) < 0) { + ini_.SetBoolValue(section_, "enable_privacy_screen", previous); + LOG_ERROR("Failed to save automatic privacy screen preference"); + return -1; + } + // RTC callbacks read only the value committed by the settings thread. + enable_privacy_screen_.store(enable_privacy_screen); + LOG_INFO("Privacy screen on incoming connection: {}", enable_privacy_screen); + return 0; +} + int ConfigCenter::SetPortableServicePromptSuppressed(bool suppressed) { portable_service_prompt_suppressed_ = suppressed; ini_.SetBoolValue(section_, "portable_service_prompt_suppressed", @@ -437,6 +456,10 @@ bool ConfigCenter::IsEnableAutostart() const { return enable_autostart_; } bool ConfigCenter::IsEnableDaemon() const { return enable_daemon_; } +bool ConfigCenter::IsEnablePrivacyScreen() const { + return enable_privacy_screen_.load(); +} + bool ConfigCenter::IsPortableServicePromptSuppressed() const { return portable_service_prompt_suppressed_; } diff --git a/apps/desktop/src/config_center/config_center.h b/apps/desktop/src/config_center/config_center.h index 9b3fdbfe..5f8430e0 100644 --- a/apps/desktop/src/config_center/config_center.h +++ b/apps/desktop/src/config_center/config_center.h @@ -7,6 +7,7 @@ #ifndef _CONFIG_CENTER_H_ #define _CONFIG_CENTER_H_ +#include #include #include "SimpleIni.h" @@ -50,6 +51,7 @@ class ConfigCenter { int SetSelfHosted(bool enable_self_hosted); int SetAutostart(bool enable_autostart); int SetDaemon(bool enable_daemon); + int SetPrivacyScreen(bool enable_privacy_screen); int SetPortableServicePromptSuppressed(bool suppressed); int SetFileTransferSavePath(const std::string& path); @@ -71,6 +73,7 @@ class ConfigCenter { bool IsSelfHosted() const; bool IsEnableAutostart() const; bool IsEnableDaemon() const; + bool IsEnablePrivacyScreen() const; bool IsPortableServicePromptSuppressed() const; std::string GetFileTransferSavePath() const; @@ -98,6 +101,7 @@ class ConfigCenter { bool enable_self_hosted_ = false; bool enable_autostart_ = false; bool enable_daemon_ = false; + std::atomic enable_privacy_screen_{true}; bool portable_service_prompt_suppressed_ = false; std::string file_transfer_save_path_ = ""; }; diff --git a/apps/desktop/src/gui/application/gui_application.cpp b/apps/desktop/src/gui/application/gui_application.cpp index 48b6633a..7dc64a78 100644 --- a/apps/desktop/src/gui/application/gui_application.cpp +++ b/apps/desktop/src/gui/application/gui_application.cpp @@ -1027,6 +1027,9 @@ void GuiApplication::InitializeSettings() { language_button_value_ = localization_language_index_; localization_language_ = static_cast(localization_language_index_); + privacy_.SetText({ + localization::privacy_verification[localization_language_index_], + localization::privacy_screen_unlock_hint[localization_language_index_]}); } bool GuiApplication::InitializeSDL() { @@ -1227,6 +1230,11 @@ void GuiApplication::ResetSettingsUi() { ui_->main->set_self_hosted_enabled(enable_self_hosted_); ui_->main->set_autostart_enabled(enable_autostart_); ui_->main->set_daemon_enabled(enable_daemon_); +#ifdef _WIN32 + ui_->main->set_privacy_setting_visible(true); + ui_->main->set_privacy_on_connect_enabled( + config_center_->IsEnablePrivacyScreen()); +#endif ui_->main->set_file_save_path(file_transfer_save_path_buf_); ui_->main->set_server_host(signal_server_ip_self_); ui_->main->set_server_port(signal_server_port_self_); @@ -1587,6 +1595,37 @@ void GuiApplication::BindStreamCallbacks() { SendReliableDataFrame(props->peer_, message.c_str(), message.size(), props->control_data_label_.c_str()); }); + stream->on_toggle_privacy([this] { + auto props = SelectedSession(); + if (!props || !props->peer_ || !props->control_mouse_ || + props->connection_status_.load() != ConnectionStatus::Connected) return; + RemoteAction action{}; + action.type = ControlType::privacy_command; + { + std::lock_guard lock(props->privacy_status_mutex_); + const auto& status = props->privacy_status_; + action.pc.flag = status.overlay_active || status.remote_paused || status.state == PrivacyState::on || + status.state == PrivacyState::failed ? PrivacyCommandFlag::disable : PrivacyCommandFlag::enable; + action.pc.block_local_input = props->privacy_block_local_input_; + props->privacy_command_pending_ = true; + props->privacy_command_tick_ = SDL_GetTicks(); + props->privacy_request_revision_ = status.revision; + } + const auto message = action.to_json(); + if (SendReliableDataFrame(props->peer_, message.data(), message.size(), + props->control_data_label_.c_str()) != 0) { + std::lock_guard lock(props->privacy_status_mutex_); + props->privacy_command_pending_ = false; + props->privacy_status_received_ = false; + LOG_WARN("Privacy command send failed, remote_id={}", props->remote_id_); + } + }); + stream->on_toggle_privacy_input([this] { + if (auto props = SelectedSession()) { + std::lock_guard lock(props->privacy_status_mutex_); + props->privacy_block_local_input_ = !props->privacy_block_local_input_; + } + }); stream->on_toggle_mouse_control([this] { auto props = SelectedSession(); if (!props || !props->connection_established_) { @@ -1794,6 +1833,7 @@ void GuiApplication::Tick() { HandleConnectionStatusChange(); HandlePendingPresenceProbe(); HandlePresenceProbeTimeout(); + HandlePrivacy(); HandleWindowsServiceIntegration(); #if defined(__linux__) && !defined(__APPLE__) SyncXWaylandWindowActivation(); @@ -2104,7 +2144,7 @@ void GuiApplication::SyncMainWindow() { ui_->main->set_latest_version(UiText(latest_version_)); ui_->main->set_release_name(UiText(release_name_)); ui_->main->set_release_date(UiText(release_date_)); - ui_->main->set_settings_session_active(stream_window_inited_); + ui_->main->set_settings_session_active(HasActiveSession()); #if (((defined(_WIN32) || defined(__linux__)) && !defined(__aarch64__) && \ !defined(__arm__) && USE_CUDA) || \ defined(__APPLE__)) @@ -2507,6 +2547,29 @@ void GuiApplication::SyncStreamWindow() { ? localization::receiving_screen[localization_language_index_] : std::string{})); (*ui_->stream)->set_mouse_control_enabled(props->control_mouse_); + { + std::lock_guard lock(props->privacy_status_mutex_); + const auto& status = props->privacy_status_; + const uint64_t now = SDL_GetTicks(); + const bool timeout = props->privacy_command_pending_ && now - props->privacy_command_tick_ > 15000; + const bool stale = props->privacy_status_received_ && now - props->privacy_status_tick_ > 5000; + const bool pending = props->privacy_command_pending_ && !timeout; + const bool active = status.overlay_active || status.remote_paused || status.state == PrivacyState::on || status.state == PrivacyState::failed; + (*ui_->stream)->set_privacy_active(active); + (*ui_->stream)->set_privacy_can_toggle(props->control_mouse_ && + props->connection_status_.load() == ConnectionStatus::Connected && !pending && + (active || (props->privacy_status_received_ && status.supported && !stale))); + (*ui_->stream)->set_privacy_can_change_input(props->control_mouse_ && !active && !pending && status.input_block_supported); + (*ui_->stream)->set_privacy_block_input(active ? status.input_blocked : props->privacy_block_local_input_); + (*ui_->stream)->set_privacy_remote_paused(status.remote_paused || (active && (stale || timeout))); + const int language = localization_language_index_; + std::string text = !props->privacy_status_received_ ? localization::privacy_unknown[language] : + (timeout || stale) ? localization::privacy_timeout[language] : + pending ? localization::privacy_pending[language] : + status.remote_paused ? localization::privacy_paused[language] : status.reason; + if (status.remote_paused && !pending) text += std::string("\n") + status.reason; + (*ui_->stream)->set_privacy_status_text(UiText(text)); + } int remote_cursor_shape = static_cast(RemoteCursorShape::default_cursor); bool remote_cursor_active = false; @@ -2750,7 +2813,14 @@ void GuiApplication::SyncServerWindow() { }); } - if (has_connected_controller && !ui_->server) { + // Keep the local connection panel out of captured privacy sessions and + // transitions; it is unnecessary while the physical screens are covered. + const auto privacy_status = privacy_.Snapshot(); + const bool privacy_covering = privacy_status.overlay_active || + privacy_status.state == PrivacyState::starting || + privacy_status.state == PrivacyState::stopping; + const bool show_controller_window = has_connected_controller && !privacy_covering; + if (show_controller_window && !ui_->server) { ui_->server.emplace(ui::ServerWindow::create()); RegisterFontAwesome((*ui_->server)->window()); (*ui_->server)->set_controllers(ui_->controller_model); @@ -2774,7 +2844,7 @@ void GuiApplication::SyncServerWindow() { server_window_created_ = true; server_window_inited_ = true; } - if (!has_connected_controller && ui_->server) { + if (!show_controller_window && ui_->server) { (*ui_->server)->hide(); ui_->server.reset(); server_window_created_ = false; @@ -2918,6 +2988,9 @@ void GuiApplication::SaveSettingsFromUi() { static_cast(language_button_value_); localization_language_index_ = language_button_value_; config_center_->SetLanguage(localization_language_); + privacy_.SetText({ + localization::privacy_verification[localization_language_index_], + localization::privacy_screen_unlock_hint[localization_language_index_]}); config_center_->SetVideoQuality( static_cast(video_quality_button_value_)); config_center_->SetVideoFrameRate(static_cast( @@ -2934,6 +3007,13 @@ void GuiApplication::SaveSettingsFromUi() { config_center_->SetSelfHosted(enable_self_hosted_); config_center_->SetAutostart(enable_autostart_); config_center_->SetDaemon(enable_daemon_); +#ifdef _WIN32 + if (config_center_->SetPrivacyScreen(main->get_privacy_on_connect_enabled()) != + 0) { + main->set_privacy_on_connect_enabled( + config_center_->IsEnablePrivacyScreen()); + } +#endif const std::string path(main->get_file_save_path()); std::memset(file_transfer_save_path_buf_, 0, @@ -2965,7 +3045,7 @@ void GuiApplication::SaveSettingsFromUi() { file_transfer_save_path_last_ = path; ui_->localized_language = -1; - if (!stream_window_inited_) { + if (!HasActiveSession()) { CloseAllRemoteSessions(); CreateConnectionPeer(); } @@ -3267,9 +3347,12 @@ void GuiApplication::Cleanup() { #if _WIN32 && CROSSDESK_PORTABLE JoinPortableWindowsServiceInstallThread(); #endif + privacy_.Fail("Application exiting; remote operation paused"); clipboard_.Shutdown(); keyboard_.ForceReleasePressedKeys(); + keyboard_.ReleaseAllRemotePressedKeys("application_exit"); devices_.DestroyDevices(); + privacy_.Shutdown(); devices_.DestroyFactories(); CloseAllRemoteSessions(); WaitForSessionCleanup(); diff --git a/apps/desktop/src/gui/assets/localization/localization_data.h b/apps/desktop/src/gui/assets/localization/localization_data.h index 03726883..648d256c 100644 --- a/apps/desktop/src/gui/assets/localization/localization_data.h +++ b/apps/desktop/src/gui/assets/localization/localization_data.h @@ -20,6 +20,13 @@ struct TranslationRow { // Single source of truth for all UI strings. #define CROSSDESK_LOCALIZATION_ALL(X) \ + X(privacy_enable, u8"开启隐私屏", "Enable privacy screen", u8"Включить приватный экран") \ + X(privacy_disable, u8"关闭隐私屏 / 恢复操作", "Turn privacy off / recover", u8"Выключить / восстановить") \ + X(privacy_block_input, u8"开启时屏蔽本地输入", "Block local input on enable", u8"Блокировать локальный ввод") \ + X(privacy_unknown, u8"隐私屏:未收到被控端确认(旧版本可能不支持)", "Privacy: no host confirmation (older hosts may not support it)", u8"Приватность: нет подтверждения от узла") \ + X(privacy_pending, u8"隐私屏:等待被控端完成验证", "Privacy: waiting for host verification", u8"Приватность: ожидание проверки") \ + X(privacy_timeout, u8"隐私屏状态未知:被控端确认超时,可关闭后重试", "Privacy state unknown: host confirmation timed out; turn off to recover", u8"Статус неизвестен: время ожидания истекло") \ + X(privacy_paused, u8"隐私保护未就绪,远程操作已暂停。可在快捷菜单关闭隐私屏恢复。", "Privacy protection is not ready; remote operation paused. Turn privacy off in the shortcut menu to recover.", u8"Приватность не готова; удалённое управление приостановлено.") \ X(local_desktop, u8"本桌面", "Local Desktop", u8"Локальный рабочий стол") \ X(local_id, u8"本机ID", "Local ID", u8"Локальный ID") \ X(local_id_copied_to_clipboard, u8"已复制到剪贴板", "Copied to clipboard", \ @@ -202,6 +209,8 @@ struct TranslationRow { "Confirm to delete this connection", u8"Удалить это подключение?") \ X(enable_autostart, u8"开机自启:", "Auto Start:", u8"Автозапуск:") \ X(enable_daemon, u8"启用守护进程:", "Enable Daemon:", u8"Включить демон:") \ + X(privacy_on_connect, u8"被连接时开启隐私屏:", "Privacy screen on connect:", \ + u8"Приватность при подключении:") \ X(takes_effect_after_restart, u8"重启后生效", "Takes effect after restart", \ u8"Вступит в силу после перезапуска") \ X(select_file, u8"选择文件发送", "Select File to Send", \ @@ -237,6 +246,11 @@ struct TranslationRow { u8"Для работы приложения требуются следующие разрешения:") \ X(show_main_window, u8"显示主界面", "Show Main Window", \ u8"Показать главное окно") \ + X(privacy_verification, u8"隐私屏验证", "Privacy screen verification", \ + u8"Проверка приватности") \ + X(privacy_screen_unlock_hint, u8"按下快捷键,解除隐私屏", \ + "To turn off the privacy screen, press", \ + u8"Чтобы отключить приватный экран, нажмите") \ X(exit_program, u8"退出", "Exit", u8"Выход") inline constexpr TranslationRow kTranslationRows[] = { diff --git a/apps/desktop/src/gui/features/devices/session_device_manager.cpp b/apps/desktop/src/gui/features/devices/session_device_manager.cpp index 85b91429..0139ca76 100644 --- a/apps/desktop/src/gui/features/devices/session_device_manager.cpp +++ b/apps/desktop/src/gui/features/devices/session_device_manager.cpp @@ -10,6 +10,10 @@ #include "rd_log.h" #include "runtime/gui_runtime.h" #include "speaker_capturer_factory.h" +#ifdef _WIN32 +#include "platform/windows/screen_capturer/screen_capturer_win.h" +#include "platform/windows/input/mouse/mouse_controller.h" +#endif namespace crossdesk { namespace { @@ -28,7 +32,7 @@ SessionDeviceManager::SessionDeviceManager(GuiRuntime &owner) SpeakerCapturerFactory().Create()); }, [this](unsigned char *data, size_t size, const char *) { - if (!owner_.peer_) return; + if (!owner_.peer_ || !owner_.privacy_.RemoteAllowed()) return; MiniRtcAudioFrame frame{}; frame.data = reinterpret_cast(data); frame.size = size; @@ -74,6 +78,10 @@ int SessionDeviceManager::InitializeScreenCapturer() { if (!screen_capturer_) { screen_capturer_ = static_cast(screen_capturer_factory_->Create()); +#ifdef _WIN32 + if (auto windows = dynamic_cast(screen_capturer_)) + windows->SetPrivacyController(&owner_.privacy_); +#endif } last_frame_time_ = {}; @@ -92,6 +100,7 @@ int SessionDeviceManager::InitializeScreenCapturer() { fps, [this, fps](unsigned char *data, int size, int width, int height, const char *display_name, const MiniRtcNativeVideoFrame *native_frame) { + if (!owner_.privacy_.RemoteAllowed()) return; const auto now_time = std::chrono::steady_clock::now(); if (!ShouldSendCapturedFrame(now_time, fps)) { return; @@ -154,6 +163,9 @@ int SessionDeviceManager::InitializeScreenCapturer() { } MiniRtcVideoFrame frame{}; + // A new controller can connect after this callback's first gate check + // while it is collecting the Connected peer IDs above. + if (!owner_.privacy_.RemoteAllowed()) return; frame.data = reinterpret_cast(data); frame.size = size; frame.width = width; @@ -216,6 +228,13 @@ int SessionDeviceManager::StopScreenCapturer() { return 0; } +#ifdef _WIN32 +void SessionDeviceManager::ReleaseRemoteMouseButtons() { + if (auto mouse = dynamic_cast(mouse_controller_)) + mouse->ReleasePressedButtons(); +} +#endif + void SessionDeviceManager::StartSpeakerCapturer() { speaker_capture_.SetEnabled(true); } @@ -488,6 +507,7 @@ void SessionDeviceManager::DrainCapturedKeyboardInput() { std::lock_guard lock(captured_keyboard_inputs_mutex_); inputs.swap(captured_keyboard_inputs_); } + if (owner_.privacy_.Snapshot().input_blocked) return; for (const CapturedKeyboardInput &input : inputs) { owner_.keyboard_.SendKeyCommand(input.key_code, input.is_down, input.scan_code, input.extended); @@ -508,6 +528,7 @@ bool SessionDeviceManager::SendKeyboardCommand(int key_code, bool is_down, void SessionDeviceManager::SendMouseCommand(const RemoteAction &action, int selected_display) { + if (!owner_.privacy_.RemoteAllowed()) return; if (mouse_controller_) { mouse_controller_->SendMouseCommand(action, selected_display); } diff --git a/apps/desktop/src/gui/features/devices/session_device_manager.h b/apps/desktop/src/gui/features/devices/session_device_manager.h index ddbfa37c..ca84b8c3 100644 --- a/apps/desktop/src/gui/features/devices/session_device_manager.h +++ b/apps/desktop/src/gui/features/devices/session_device_manager.h @@ -46,6 +46,9 @@ public: int InitializeScreenCapturer(); int StartScreenCapturer(); int StopScreenCapturer(); +#ifdef _WIN32 + void ReleaseRemoteMouseButtons(); +#endif // Requests are asynchronous; Stop drains/revokes audio delivery before // returning so the connection peer can be destroyed safely. void StartSpeakerCapturer(); @@ -102,4 +105,4 @@ private: } // namespace crossdesk -#endif \ No newline at end of file +#endif diff --git a/apps/desktop/src/gui/features/input/keyboard_controller.cpp b/apps/desktop/src/gui/features/input/keyboard_controller.cpp index ec49c5b3..0720ccb4 100644 --- a/apps/desktop/src/gui/features/input/keyboard_controller.cpp +++ b/apps/desktop/src/gui/features/input/keyboard_controller.cpp @@ -212,8 +212,13 @@ int KeyboardController::SendKeyCommand(int key_code, bool is_down, bool KeyboardController::InjectRemoteKey(int key_code, bool is_down, uint32_t scan_code, bool extended) { + if (is_down && !owner_.privacy_.RemoteAllowed()) return false; #if _WIN32 - if (owner_.local_service_status_received_ && + if (owner_.privacy_.Engaged() && !IsWindowsPrivacyDesktopAvailable()) { + owner_.privacy_.Fail("Secure desktop or locked session; remote key injection paused"); + return false; + } + if (!owner_.privacy_.Engaged() && owner_.local_service_status_received_ && IsSecureDesktopInteractionRequired(owner_.local_interactive_stage_)) { const std::string response = SendCrossDeskSecureDesktopKeyInput( key_code, is_down, scan_code, extended, 1000); @@ -246,6 +251,8 @@ bool KeyboardController::InjectRemoteKey(int key_code, bool is_down, return true; } #endif + // Recheck after the desktop query; key-up cleanup remains allowed while paused. + if (is_down && !owner_.privacy_.RemoteAllowed()) return false; return owner_.devices_.SendKeyboardCommand(key_code, is_down, scan_code, extended); } @@ -342,11 +349,50 @@ void KeyboardController::ReleaseRemotePressedKeys(const std::string& remote_id, keys_to_release.size(), remote_id, reason ? reason : "unknown"); } for (const PressedKey& key : keys_to_release) { - InjectRemoteKey(key.key_code, false, key.scan_code, key.extended); + if (!InjectRemoteKey(key.key_code, false, key.scan_code, key.extended)) { +#ifdef _WIN32 + if (owner_.privacy_.Engaged()) { + std::lock_guard lock(remote_states_mutex_); + pending_privacy_releases_[key.key_code] = key; + } +#endif + } } } +void KeyboardController::ReleaseAllRemotePressedKeys(const char* reason) { + std::vector remotes; + { + std::lock_guard lock(remote_states_mutex_); + for (const auto& [id, state] : remote_states_) remotes.push_back(id); + } + for (const auto& id : remotes) ReleaseRemotePressedKeys(id, reason); + CheckRemoteTimeouts(); +} + void KeyboardController::CheckRemoteTimeouts() { +#ifdef _WIN32 + bool pending = false; + { + std::lock_guard lock(remote_states_mutex_); + pending = !pending_privacy_releases_.empty(); + } + // A key-up rejected on the secure desktop is retained until the ordinary + // desktop returns. Never route cleanup through the security UI helper. + if (pending && IsWindowsPrivacyDesktopAvailable()) { + std::unordered_map releases; + { + std::lock_guard lock(remote_states_mutex_); + releases.swap(pending_privacy_releases_); + } + for (const auto& [code, key] : releases) { + if (!owner_.devices_.SendKeyboardCommand(code, false, key.scan_code, key.extended)) { + std::lock_guard lock(remote_states_mutex_); + pending_privacy_releases_[code] = key; + } + } + } +#endif const uint32_t now = static_cast(SDL_GetTicks()); std::vector timed_out_remotes; { diff --git a/apps/desktop/src/gui/features/input/keyboard_controller.h b/apps/desktop/src/gui/features/input/keyboard_controller.h index 672e8276..0dd3c4c3 100644 --- a/apps/desktop/src/gui/features/input/keyboard_controller.h +++ b/apps/desktop/src/gui/features/input/keyboard_controller.h @@ -30,6 +30,7 @@ public: const RemoteAction &remote_action); void ReleaseRemotePressedKeys(const std::string &remote_id, const char *reason); + void ReleaseAllRemotePressedKeys(const char* reason); void CheckRemoteTimeouts(); private: @@ -58,6 +59,9 @@ private: uint32_t last_heartbeat_tick_ = 0; std::unordered_map remote_states_; std::mutex remote_states_mutex_; +#ifdef _WIN32 + std::unordered_map pending_privacy_releases_; +#endif }; } // namespace crossdesk diff --git a/apps/desktop/src/gui/runtime/connection_runtime.cpp b/apps/desktop/src/gui/runtime/connection_runtime.cpp index 2733c753..a2046040 100644 --- a/apps/desktop/src/gui/runtime/connection_runtime.cpp +++ b/apps/desktop/src/gui/runtime/connection_runtime.cpp @@ -25,6 +25,16 @@ constexpr auto kPresenceRefreshInterval = std::chrono::seconds(30); constexpr auto kPresenceRetryInterval = std::chrono::seconds(1); } // namespace +bool GuiRuntime::HasActiveSession() { + if (stream_window_inited_) return true; + std::shared_lock lock(connection_status_mutex_); + return std::any_of(connection_status_.begin(), connection_status_.end(), + [](const auto& entry) { + return entry.second == ConnectionStatus::Connecting || + entry.second == ConnectionStatus::Connected; + }); +} + void GuiRuntime::HandleConnectionStatusChange() { if (!signal_connected_ || !peer_) { return; @@ -121,6 +131,9 @@ void GuiRuntime::HandlePresenceProbeTimeout() { void GuiRuntime::HandleServerControllerDisconnected( const std::string& remote_id, const char* reason) { keyboard_.ReleaseRemotePressedKeys(remote_id, reason); +#ifdef _WIN32 + devices_.ReleaseRemoteMouseButtons(); +#endif { std::lock_guard lock(remote_pointer_input_mutex_); last_remote_pointer_input_time_.erase(remote_id); @@ -151,6 +164,10 @@ void GuiRuntime::HandleServerControllerDisconnected( return; } + LOG_INFO("Last remote controller disconnected; recovering privacy, reason={}", + reason ? reason : "unknown"); + privacy_.Disconnected(); + need_to_create_server_window_.store(false, std::memory_order_release); need_to_destroy_server_window_.store(true, std::memory_order_release); is_server_mode_ = false; diff --git a/apps/desktop/src/gui/runtime/gui_runtime.h b/apps/desktop/src/gui/runtime/gui_runtime.h index 22812f0d..b4261951 100644 --- a/apps/desktop/src/gui/runtime/gui_runtime.h +++ b/apps/desktop/src/gui/runtime/gui_runtime.h @@ -8,6 +8,7 @@ #define _GUI_RUNTIME_H_ #include +#include #include #include @@ -16,6 +17,7 @@ #include "features/file_transfer/file_transfer_manager.h" #include "features/input/keyboard_controller.h" #include "features/settings/settings_manager.h" +#include "privacy_controller.h" #include "runtime/gui_state.h" #include "runtime/peer_event_handler.h" @@ -55,9 +57,13 @@ class GuiRuntime : protected gui_detail::GuiState { void HandleConnectionStatusChange(); void HandlePendingPresenceProbe(); void HandlePresenceProbeTimeout(); + bool HasActiveSession(); void HandleServerControllerDisconnected(const std::string& remote_id, const char* reason); void HandleWindowsServiceIntegration(); + void HandlePrivacy(); + bool IsAuthorizedController(const std::string& remote_id); + void QueuePrivacyCommand(const std::string& remote_id, const PrivacyCommand& command); void CloseRemoteSession(std::shared_ptr props); void CloseAllRemoteSessions(); @@ -87,6 +93,12 @@ class GuiRuntime : protected gui_detail::GuiState { bool EnsureMacAccessibilityPermission(); #endif + PrivacyController privacy_; + std::mutex privacy_commands_mutex_; + std::deque> privacy_commands_; + uint32_t last_privacy_revision_ = 0; + uint64_t last_privacy_status_tick_ = 0; + bool privacy_was_paused_ = false; ClipboardController clipboard_; SessionDeviceManager devices_; FileTransferManager transfers_; diff --git a/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp b/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp index de616560..62ed24f8 100644 --- a/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp +++ b/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp @@ -88,6 +88,8 @@ void PeerEventHandler::OnReceiveDataBuffer( } std::string source_id = std::string(src_id, src_id_size); + if (!runtime->privacy_.RemoteAllowed() && + (source_id == runtime->clipboard_label_ || source_id == runtime->file_label_)) return; if (source_id == runtime->file_label_) { std::string remote_user_id = std::string(user_id, user_id_size); @@ -147,6 +149,48 @@ void PeerEventHandler::OnReceiveDataBuffer( } std::string remote_id(user_id, user_id_size); + if (remote_action.type == ControlType::privacy_status) { + if (auto props = runtime->FindRemoteSession(remote_id)) { + if (source_id != props->control_data_label_) return; + std::lock_guard lock(props->privacy_status_mutex_); + if (!props->privacy_status_received_ || + static_cast(remote_action.ps.revision - props->privacy_status_.revision) >= 0) { + props->privacy_status_ = remote_action.ps; + props->privacy_status_received_ = true; + props->privacy_status_tick_ = SDL_GetTicks(); + if (static_cast(remote_action.ps.revision - props->privacy_request_revision_) > 0 && + remote_action.ps.state != PrivacyState::starting && + remote_action.ps.state != PrivacyState::stopping) + props->privacy_command_pending_ = false; + } + } + return; + } + if (remote_action.type == ControlType::privacy_command) { + if (source_id == runtime->control_data_label_) + runtime->QueuePrivacyCommand(remote_id, remote_action.pc); + return; + } + const bool operates_host = remote_action.type == ControlType::mouse || + remote_action.type == ControlType::keyboard || remote_action.type == ControlType::keyboard_state || + remote_action.type == ControlType::service_command || remote_action.type == ControlType::display_id || + remote_action.type == ControlType::audio_capture; + if (operates_host) { + if (!runtime->IsAuthorizedController(remote_id)) return; +#ifdef _WIN32 + // KeyboardController checks the desktop immediately before each native + // key injection, including state reconciliation and cleanup. Avoid a + // second synchronous session query for every received keyboard packet. + const bool keyboard_input = remote_action.type == ControlType::keyboard || + remote_action.type == ControlType::keyboard_state; + if (!keyboard_input && runtime->privacy_.Engaged() && + !IsWindowsPrivacyDesktopAvailable()) { + runtime->privacy_.Fail("Secure desktop or lock screen; privacy cannot cover system security UI. Remote input paused."); + return; + } +#endif + if (!runtime->privacy_.RemoteAllowed()) return; + } if (remote_action.type == ControlType::service_status) { if (auto props = runtime->FindRemoteSession(remote_id)) { runtime->ApplyRemoteServiceStatus(*props, remote_action.ss); @@ -155,6 +199,10 @@ void PeerEventHandler::OnReceiveDataBuffer( } if (remote_action.type == ControlType::service_command) { + if (runtime->privacy_.Engaged()) { + runtime->privacy_.Fail("Security shortcuts require leaving privacy mode; remote operation paused. Turn privacy off first."); + return; + } #if _WIN32 if (remote_action.c.flag == ServiceCommandFlag::send_sas) { runtime->pending_windows_service_sas_.store(true, @@ -258,7 +306,7 @@ void PeerEventHandler::OnReceiveDataBuffer( std::chrono::steady_clock::now(); } #if _WIN32 - if (runtime->local_service_status_received_ && + if (!runtime->privacy_.Engaged() && runtime->local_service_status_received_ && IsSecureDesktopInteractionRequired(runtime->local_interactive_stage_) && remote_action.type != ControlType::keyboard && remote_action.type != ControlType::keyboard_state) { diff --git a/apps/desktop/src/gui/runtime/peer_event_handler.cpp b/apps/desktop/src/gui/runtime/peer_event_handler.cpp index 15506997..c98e3e2e 100644 --- a/apps/desktop/src/gui/runtime/peer_event_handler.cpp +++ b/apps/desktop/src/gui/runtime/peer_event_handler.cpp @@ -405,6 +405,20 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status, runtime->show_connection_status_window_ = true; { std::unique_lock lock(runtime->connection_status_mutex_); +#ifdef _WIN32 + if (status == ConnectionStatus::Connected && + runtime->config_center_->IsEnablePrivacyScreen() && + std::none_of(runtime->connection_status_.begin(), + runtime->connection_status_.end(), [](const auto& entry) { + return entry.second == ConnectionStatus::Connected; + })) { + // Pause before Connected becomes observable by capture/input threads. + // Only the first controller takes over local input. A later join must + // not release an existing controller's keys or re-enable privacy that + // the current session explicitly turned off. + runtime->privacy_.EnableOnConnection(); + } +#endif runtime->connection_status_[remote_id] = status; } diff --git a/apps/desktop/src/gui/runtime/privacy_runtime.cpp b/apps/desktop/src/gui/runtime/privacy_runtime.cpp new file mode 100644 index 00000000..3da6148c --- /dev/null +++ b/apps/desktop/src/gui/runtime/privacy_runtime.cpp @@ -0,0 +1,92 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-09-11 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#include + +#include "rd_log.h" +#include "runtime/gui_runtime.h" + +namespace crossdesk { + +bool GuiRuntime::IsAuthorizedController(const std::string& remote_id) { + // Connections appear here only after the existing password/signalling/RTC + // admission path. Outgoing/viewer sessions live in remote_sessions_ instead. + std::shared_lock lock(connection_status_mutex_); + const auto found = connection_status_.find(remote_id); + return found != connection_status_.end() && + found->second == ConnectionStatus::Connected; +} + +void GuiRuntime::QueuePrivacyCommand(const std::string& remote_id, + const PrivacyCommand& command) { + if (!IsAuthorizedController(remote_id)) return; + std::lock_guard lock(privacy_commands_mutex_); + if (privacy_commands_.size() < 32) + privacy_commands_.emplace_back(remote_id, command); +} + +void GuiRuntime::HandlePrivacy() { + std::deque> commands; + { + std::lock_guard lock(privacy_commands_mutex_); + commands.swap(privacy_commands_); + } + for (const auto& [remote_id, command] : commands) { + if (!IsAuthorizedController(remote_id)) continue; + if (command.flag == PrivacyCommandFlag::enable) { + privacy_.Enable(command.block_local_input); + } else if (command.flag == PrivacyCommandFlag::disable) { + privacy_.Disable(); + } + last_privacy_status_tick_ = 0; + } + const auto status = privacy_.Snapshot(); + if (status.remote_paused && !privacy_was_paused_) { + keyboard_.ReleaseAllRemotePressedKeys("privacy_paused"); + } +#ifdef _WIN32 + if (status.remote_paused && IsWindowsPrivacyDesktopAvailable()) + devices_.ReleaseRemoteMouseButtons(); +#endif + if (privacy_was_paused_ && !status.remote_paused && + status.state == PrivacyState::off && start_screen_capturer_) { + // A capture failure may have stopped the underlying capturer. Reuse the + // normal start/retry path when the user explicitly leaves privacy mode. + screen_capturer_is_started_ = devices_.StartScreenCapturer() == 0; + } + privacy_was_paused_ = status.remote_paused; + 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_); + for (const auto& [id, state] : connection_status_) + if (state == ConnectionStatus::Connected) controllers.push_back(id); + } + RemoteAction action{}; + action.type = ControlType::privacy_status; + action.ps = status; + const auto message = action.to_json(); + bool sent = true; + for (const auto& id : controllers) { + const int result = SendReliableDataFrameToPeer( + peer_, message.data(), message.size(), control_data_label_.c_str(), + id.data(), id.size()); + if (result != 0) { + sent = false; + LOG_WARN("Privacy status send failed, peer={}, ret={}", id, result); + } + } + if (sent) { + last_privacy_revision_ = status.revision; + last_privacy_status_tick_ = now; + } +} + +} // namespace crossdesk diff --git a/apps/desktop/src/gui/runtime/remote_session.h b/apps/desktop/src/gui/runtime/remote_session.h index 24aa275c..a8d6242a 100644 --- a/apps/desktop/src/gui/runtime/remote_session.h +++ b/apps/desktop/src/gui/runtime/remote_session.h @@ -157,6 +157,14 @@ struct RemoteSession { std::string audio_capture_button_label_ = "Audio Capture"; std::string remote_host_name_; bool remote_service_status_received_ = false; + std::mutex privacy_status_mutex_; + PrivacyStatus privacy_status_{}; + bool privacy_status_received_ = false; + bool privacy_command_pending_ = false; + bool privacy_block_local_input_ = true; + uint64_t privacy_command_tick_ = 0; + uint32_t privacy_request_revision_ = 0; + uint64_t privacy_status_tick_ = 0; bool remote_service_available_ = false; std::string remote_interactive_stage_; std::vector display_info_list_; @@ -184,4 +192,4 @@ using RemoteSessionPtr = std::shared_ptr; } // namespace crossdesk::gui_detail -#endif \ No newline at end of file +#endif diff --git a/apps/desktop/src/gui/ui/main_window.slint b/apps/desktop/src/gui/ui/main_window.slint index 8de436a1..fc1ce2f8 100644 --- a/apps/desktop/src/gui/ui/main_window.slint +++ b/apps/desktop/src/gui/ui/main_window.slint @@ -50,6 +50,7 @@ export global UiStrings { in-out property self-hosted: "Self-hosted server"; in-out property autostart: "Start with system"; in-out property daemon: "Run in background"; + in-out property privacy-on-connect: "Privacy screen on connect"; in-out property file-save-path: "File save path"; in-out property default-desktop: "Desktop"; in-out property server-host: "Server host"; @@ -246,6 +247,8 @@ export component MainWindow inherits Window { in-out property self-hosted-enabled: false; in-out property autostart-enabled: false; in-out property daemon-enabled: false; + in-out property privacy-setting-visible: false; + in-out property privacy-on-connect-enabled: true; in-out property file-save-path: ""; in-out property server-host: ""; in-out property server-port: ""; @@ -1445,6 +1448,11 @@ export component MainWindow inherits Window { Text { x: 8px; y: 3px; width: root.compact-language ? 94px : 162px; height: 24px; text: UiStrings.language; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; } CompactComboBox { x: root.compact-language ? 106px : 174px; y: 2px; model: ["中文", "English", "Русский"]; current-index <=> root.language-index; } } + if root.privacy-setting-visible: Rectangle { + height: 30px; + Text { x: 8px; y: 3px; width: root.compact-language ? 145px : 212px; height: 24px; text: UiStrings.privacy-on-connect; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; } + ImGuiCheckBox { x: root.compact-language ? 157px : 224px; y: 3px; checked <=> root.privacy-on-connect-enabled; } + } Rectangle { height: 30px; Text { x: 8px; y: 3px; width: root.compact-language ? 94px : 162px; height: 24px; text: UiStrings.video-quality; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; } diff --git a/apps/desktop/src/gui/ui/stream_window.slint b/apps/desktop/src/gui/ui/stream_window.slint index d1453259..b7d3f494 100644 --- a/apps/desktop/src/gui/ui/stream_window.slint +++ b/apps/desktop/src/gui/ui/stream_window.slint @@ -25,6 +25,9 @@ export struct NetworkStatsRow { export global StreamStrings { in-out property select-display: "Select Display"; in-out property send-shortcut: "Send Shortcut"; + in-out property privacy-enable: "Enable privacy screen"; + in-out property privacy-disable: "Turn privacy off / recover"; + in-out property privacy-block-input: "Block local input on enable"; in-out property control-mouse: "Control Mouse"; in-out property release-mouse: "Release Mouse"; in-out property audio: "Audio Capture"; @@ -134,6 +137,12 @@ export component StreamWindow inherits Window { in property <[string]> displays; in-out property selected-display: 0; in property mouse-control-enabled: true; + in property privacy-can-toggle: false; + in property privacy-active: false; + in property privacy-block-input: true; + in property privacy-can-change-input: false; + in property privacy-remote-paused: false; + in property privacy-status-text: "Privacy screen: waiting for host"; // Numeric values mirror crossdesk::RemoteCursorShape and Slint's MouseCursor. in property remote-cursor-active: false; in property remote-cursor-shape: 0; @@ -154,6 +163,8 @@ export component StreamWindow inherits Window { callback switch-display(int); callback send-shortcut(string); callback toggle-mouse-control; + callback toggle-privacy; + callback toggle-privacy-input; callback toggle-audio; callback select-file; callback close-file-transfer; @@ -232,9 +243,9 @@ export component StreamWindow inherits Window { let shortcut-menu-y = control.y + 40px; let over-shortcut-menu = root.shortcut-menu-open && pointer-x >= shortcut-menu-x - && pointer-x <= shortcut-menu-x + 150px + && pointer-x <= shortcut-menu-x + 360px && pointer-y >= shortcut-menu-y - && pointer-y <= shortcut-menu-y + 68px; + && pointer-y <= shortcut-menu-y + 200px; return over-control || over-display-menu || over-shortcut-menu; } @@ -943,7 +954,7 @@ export component StreamWindow inherits Window { } if root.shortcut-menu-open: Rectangle { - x: control.x + (root.control-docked-left ? 41px : 74px); y: control.y + 40px; width: 150px; height: 68px; + x: control.x + (root.control-docked-left ? 41px : 74px); y: control.y + 40px; width: 360px; height: 200px; background: white; border-width: 1px; border-color: ImGuiLineStyle.border; border-radius: 5px; z: 11; VerticalLayout { padding: 4px; spacing: 1px; @@ -959,9 +970,34 @@ export component StreamWindow inherits Window { clicked => { root.send-shortcut(shortcut); root.shortcut-menu-open = false; } } } + Rectangle { + height: 29px; background: privacy-touch.has-hover ? #e8eef9 : transparent; + Text { x: 8px; text: root.privacy-active ? StreamStrings.privacy-disable : StreamStrings.privacy-enable; color: root.privacy-can-toggle ? #30343b : #8a8f96; font-size: ImGuiFontStyle.body; vertical-alignment: center; } + privacy-touch := TouchArea { + enabled: root.privacy-can-toggle; + pointer-event(event) => { if event.kind == PointerEventKind.down { root.suppress-next-remote-pointer-release = true; } } + clicked => { root.toggle-privacy(); } + } + } + Rectangle { + height: 29px; background: privacy-input-touch.has-hover ? #e8eef9 : transparent; + Text { x: 8px; text: (root.privacy-block-input ? "[✓] " : "[ ] ") + StreamStrings.privacy-block-input; color: root.privacy-can-change-input ? #30343b : #8a8f96; font-size: ImGuiFontStyle.body; vertical-alignment: center; } + privacy-input-touch := TouchArea { + enabled: root.privacy-can-change-input; + pointer-event(event) => { if event.kind == PointerEventKind.down { root.suppress-next-remote-pointer-release = true; } } + clicked => { root.toggle-privacy-input(); } + } + } + Text { height: 68px; text: root.privacy-status-text; wrap: word-wrap; color: #555b66; font-size: ImGuiFontStyle.body; } } } + if root.privacy-remote-paused: Rectangle { + x: 0px; y: 65px; width: parent.width; height: parent.height - 65px; + background: #16181d; z: 6; + Text { x: 30px; width: parent.width - 60px; text: root.privacy-status-text; color: #eeeeee; wrap: word-wrap; horizontal-alignment: center; vertical-alignment: center; } + } + // Transfer progress overlay at bottom-left. if root.file-transfer-visible: Rectangle { x: 18px; y: parent.height - min(210px, parent.height * 0.35); width: min(430px, parent.width * 0.6); height: min(190px, parent.height * 0.32); diff --git a/apps/desktop/src/gui/ui/ui_localization.h b/apps/desktop/src/gui/ui/ui_localization.h index 9a8c6c88..db92fc5c 100644 --- a/apps/desktop/src/gui/ui/ui_localization.h +++ b/apps/desktop/src/gui/ui/ui_localization.h @@ -71,6 +71,7 @@ inline int ApplyMainWindowStrings( Text(localization::self_hosted_server_config[language])); strings.set_autostart(Text(localization::enable_autostart[language])); strings.set_daemon(Text(localization::enable_daemon[language])); + strings.set_privacy_on_connect(Text(localization::privacy_on_connect[language])); strings.set_file_save_path( Text(localization::file_transfer_save_path[language])); strings.set_default_desktop(Text(localization::default_desktop[language])); @@ -138,6 +139,9 @@ inline int ApplyStreamWindowStrings( auto& strings = window->template global(); strings.set_select_display(Text(localization::select_display[language])); strings.set_send_shortcut(Text(localization::send_shortcut[language])); + strings.set_privacy_enable(Text(localization::privacy_enable[language])); + strings.set_privacy_disable(Text(localization::privacy_disable[language])); + strings.set_privacy_block_input(Text(localization::privacy_block_input[language])); strings.set_control_mouse(Text(localization::control_mouse[language])); strings.set_release_mouse(Text(localization::release_mouse[language])); strings.set_audio(Text(localization::audio_capture[language])); @@ -167,4 +171,4 @@ inline int ApplyStreamWindowStrings( } // namespace crossdesk::ui_localization -#endif \ No newline at end of file +#endif diff --git a/apps/desktop/src/platform/common/gui/service_status_runtime.cpp b/apps/desktop/src/platform/common/gui/service_status_runtime.cpp index 2425ef0e..8253241e 100644 --- a/apps/desktop/src/platform/common/gui/service_status_runtime.cpp +++ b/apps/desktop/src/platform/common/gui/service_status_runtime.cpp @@ -3,6 +3,13 @@ namespace crossdesk { void GuiRuntime::ResetRemoteServiceStatus(RemoteSession& props) { + { + std::lock_guard lock(props.privacy_status_mutex_); + props.privacy_status_ = {}; + props.privacy_status_received_ = false; + props.privacy_command_pending_ = false; + props.privacy_status_tick_ = 0; + } props.remote_service_status_received_ = false; props.remote_service_available_ = false; props.remote_interactive_stage_.clear(); diff --git a/apps/desktop/src/platform/windows/gui/runtime/windows_service_runtime.cpp b/apps/desktop/src/platform/windows/gui/runtime/windows_service_runtime.cpp index 9d3f1a5b..e28c2203 100644 --- a/apps/desktop/src/platform/windows/gui/runtime/windows_service_runtime.cpp +++ b/apps/desktop/src/platform/windows/gui/runtime/windows_service_runtime.cpp @@ -114,6 +114,10 @@ void GuiRuntime::HandleWindowsServiceIntegration() { bool force_broadcast = false; if (pending_windows_service_sas_.exchange(false, std::memory_order_relaxed)) { + if (privacy_.Engaged()) { + privacy_.Fail("Queued security shortcut cancelled during privacy mode; remote operation paused"); + return; + } const std::string response = QueryCrossDeskService("sas", kWindowsServiceSasTimeoutMs); auto json = nlohmann::json::parse(response, nullptr, false); diff --git a/apps/desktop/src/platform/windows/input/mouse/mouse_controller.cpp b/apps/desktop/src/platform/windows/input/mouse/mouse_controller.cpp index a6f292a2..3826c108 100644 --- a/apps/desktop/src/platform/windows/input/mouse/mouse_controller.cpp +++ b/apps/desktop/src/platform/windows/input/mouse/mouse_controller.cpp @@ -1,16 +1,20 @@ #include "mouse_controller.h" +#include #include -#include +#include +#include +#include "../../windows_thread_dpi.h" #include "rd_log.h" +#include "windows_input_marker.h" namespace crossdesk { PlatformMouseController::PlatformMouseController() {} -PlatformMouseController::~PlatformMouseController() {} +PlatformMouseController::~PlatformMouseController() { Destroy(); } int PlatformMouseController::Init(std::vector display_info_list) { display_info_list_ = display_info_list; @@ -18,10 +22,27 @@ int PlatformMouseController::Init(std::vector display_info_list) { return 0; } -int PlatformMouseController::Destroy() { return 0; } +int PlatformMouseController::Destroy() { return ReleasePressedButtons(); } + +int PlatformMouseController::ReleasePressedButtons() { + const unsigned pressed = pressed_buttons_.load(); + if (!pressed) return 0; + INPUT input{}; + input.type = INPUT_MOUSE; + input.mi.dwExtraInfo = kInjectedMouseInputMarker; + if (pressed & 1) input.mi.dwFlags |= MOUSEEVENTF_LEFTUP; + if (pressed & 2) input.mi.dwFlags |= MOUSEEVENTF_RIGHTUP; + if (pressed & 4) input.mi.dwFlags |= MOUSEEVENTF_MIDDLEUP; + if (SendInput(1, &input, sizeof(INPUT)) != 1) { + LOG_WARN("Release remote mouse buttons failed, error={}", GetLastError()); + return -1; + } + pressed_buttons_.fetch_and(~pressed); + return 0; +} int PlatformMouseController::SendMouseCommand(RemoteAction remote_action, - int display_index) { + int display_index) { if (display_index < 0 || display_index >= static_cast(display_info_list_.size())) { LOG_WARN("Mouse command skipped, invalid display_index={}, displays={}", @@ -30,69 +51,83 @@ int PlatformMouseController::SendMouseCommand(RemoteAction remote_action, } INPUT ip = {0}; + ScopedWindowsPhysicalCoordinates physical_coordinates; - if (remote_action.type == ControlType::mouse) { - ip.type = INPUT_MOUSE; - ip.mi.dx = - (LONG)(remote_action.m.x * display_info_list_[display_index].width) + - display_info_list_[display_index].left; - ip.mi.dy = - (LONG)(remote_action.m.y * display_info_list_[display_index].height) + - display_info_list_[display_index].top; + if (remote_action.type != ControlType::mouse) return 0; + if (!std::isfinite(remote_action.m.x) || !std::isfinite(remote_action.m.y)) + return -1; + ip.type = INPUT_MOUSE; + ip.mi.dx = (LONG)(std::clamp(remote_action.m.x, 0.0f, 1.0f) * + (display_info_list_[display_index].width - 1)) + + display_info_list_[display_index].left; + ip.mi.dy = (LONG)(std::clamp(remote_action.m.y, 0.0f, 1.0f) * + (display_info_list_[display_index].height - 1)) + + display_info_list_[display_index].top; - switch (remote_action.m.flag) { - case MouseFlag::left_down: - ip.mi.dwFlags = MOUSEEVENTF_LEFTDOWN | MOUSEEVENTF_ABSOLUTE; - break; - case MouseFlag::left_up: - ip.mi.dwFlags = MOUSEEVENTF_LEFTUP | MOUSEEVENTF_ABSOLUTE; - break; - case MouseFlag::right_down: - ip.mi.dwFlags = MOUSEEVENTF_RIGHTDOWN | MOUSEEVENTF_ABSOLUTE; - break; - case MouseFlag::right_up: - ip.mi.dwFlags = MOUSEEVENTF_RIGHTUP | MOUSEEVENTF_ABSOLUTE; - break; - case MouseFlag::middle_down: - ip.mi.dwFlags = MOUSEEVENTF_MIDDLEDOWN | MOUSEEVENTF_ABSOLUTE; - break; - case MouseFlag::middle_up: - ip.mi.dwFlags = MOUSEEVENTF_MIDDLEUP | MOUSEEVENTF_ABSOLUTE; - break; - case MouseFlag::wheel_vertical: - ip.mi.dwFlags = MOUSEEVENTF_WHEEL; - ip.mi.mouseData = remote_action.m.s * 120; - break; - case MouseFlag::wheel_horizontal: - ip.mi.dwFlags = MOUSEEVENTF_HWHEEL; - ip.mi.mouseData = remote_action.m.s * 120; - break; - default: - ip.mi.dwFlags = MOUSEEVENTF_MOVE; - break; - } - - ip.mi.time = 0; - - if (!SetCursorPos(ip.mi.dx, ip.mi.dy)) { - LOG_WARN("SetCursorPos failed for mouse x={}, y={}, flag={}, err={}", - ip.mi.dx, ip.mi.dy, static_cast(remote_action.m.flag), - GetLastError()); - return -1; - } - - if (ip.mi.dwFlags != MOUSEEVENTF_MOVE) { - UINT sent = SendInput(1, &ip, sizeof(INPUT)); - if (sent != 1) { - LOG_WARN( - "SendInput failed for mouse x={}, y={}, wheel={}, flag={}, err={}", - ip.mi.dx, ip.mi.dy, remote_action.m.s, - static_cast(remote_action.m.flag), GetLastError()); - return -1; - } - } + switch (remote_action.m.flag) { + case MouseFlag::left_down: + ip.mi.dwFlags = MOUSEEVENTF_LEFTDOWN; + break; + case MouseFlag::left_up: + ip.mi.dwFlags = MOUSEEVENTF_LEFTUP; + break; + case MouseFlag::right_down: + ip.mi.dwFlags = MOUSEEVENTF_RIGHTDOWN; + break; + case MouseFlag::right_up: + ip.mi.dwFlags = MOUSEEVENTF_RIGHTUP; + break; + case MouseFlag::middle_down: + ip.mi.dwFlags = MOUSEEVENTF_MIDDLEDOWN; + break; + case MouseFlag::middle_up: + ip.mi.dwFlags = MOUSEEVENTF_MIDDLEUP; + break; + case MouseFlag::wheel_vertical: + ip.mi.dwFlags = MOUSEEVENTF_WHEEL; + ip.mi.mouseData = remote_action.m.s * 120; + break; + case MouseFlag::wheel_horizontal: + ip.mi.dwFlags = MOUSEEVENTF_HWHEEL; + ip.mi.mouseData = remote_action.m.s * 120; + break; + default: + break; } + const int virtual_left = GetSystemMetrics(SM_XVIRTUALSCREEN); + const int virtual_top = GetSystemMetrics(SM_YVIRTUALSCREEN); + const int virtual_width = GetSystemMetrics(SM_CXVIRTUALSCREEN); + const int virtual_height = GetSystemMetrics(SM_CYVIRTUALSCREEN); + if (virtual_width <= 0 || virtual_height <= 0) { + LOG_WARN("Remote mouse skipped: invalid virtual desktop geometry"); + return -1; + } + // Pixel centres in the virtual desktop, including negative monitor origins. + // SetCursorPos cannot carry dwExtraInfo through the privacy mouse hook. + ip.mi.dx = static_cast( + (static_cast(ip.mi.dx - virtual_left) * 65536 + 32768) / + virtual_width); + ip.mi.dy = static_cast( + (static_cast(ip.mi.dy - virtual_top) * 65536 + 32768) / + virtual_height); + ip.mi.dwFlags |= + MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK; + ip.mi.dwExtraInfo = kInjectedMouseInputMarker; + const UINT sent = SendInput(1, &ip, sizeof(INPUT)); + if (sent != 1) { + LOG_WARN("SendInput failed for mouse x={}, y={}, wheel={}, flag={}, err={}", + ip.mi.dx, ip.mi.dy, remote_action.m.s, + static_cast(remote_action.m.flag), GetLastError()); + return -1; + } + if (ip.mi.dwFlags & MOUSEEVENTF_LEFTDOWN) pressed_buttons_.fetch_or(1); + if (ip.mi.dwFlags & MOUSEEVENTF_RIGHTDOWN) pressed_buttons_.fetch_or(2); + if (ip.mi.dwFlags & MOUSEEVENTF_MIDDLEDOWN) pressed_buttons_.fetch_or(4); + if (ip.mi.dwFlags & MOUSEEVENTF_LEFTUP) pressed_buttons_.fetch_and(~1u); + if (ip.mi.dwFlags & MOUSEEVENTF_RIGHTUP) pressed_buttons_.fetch_and(~2u); + if (ip.mi.dwFlags & MOUSEEVENTF_MIDDLEUP) pressed_buttons_.fetch_and(~4u); + return 0; } } // namespace crossdesk diff --git a/apps/desktop/src/platform/windows/input/mouse/mouse_controller.h b/apps/desktop/src/platform/windows/input/mouse/mouse_controller.h index af97f241..98722fe6 100644 --- a/apps/desktop/src/platform/windows/input/mouse/mouse_controller.h +++ b/apps/desktop/src/platform/windows/input/mouse/mouse_controller.h @@ -8,6 +8,7 @@ #define _MOUSE_CONTROLLER_H_ #include +#include #include @@ -25,9 +26,11 @@ class PlatformMouseController final : public MouseController { virtual int Init(std::vector display_info_list); virtual int Destroy(); virtual int SendMouseCommand(RemoteAction remote_action, int display_index); + int ReleasePressedButtons(); private: std::vector display_info_list_; + std::atomic pressed_buttons_{0}; }; } // namespace crossdesk #endif diff --git a/apps/desktop/src/platform/windows/input/windows_input_marker.h b/apps/desktop/src/platform/windows/input/windows_input_marker.h index c48cb8cb..a594042f 100644 --- a/apps/desktop/src/platform/windows/input/windows_input_marker.h +++ b/apps/desktop/src/platform/windows/input/windows_input_marker.h @@ -9,6 +9,7 @@ namespace crossdesk { // keyboard input so the controller hook can ignore only its own injections // while still accepting input from accessibility tools or remote sessions. inline constexpr std::uintptr_t kInjectedKeyboardInputMarker = 0x4353444B; +inline constexpr std::uintptr_t kInjectedMouseInputMarker = 0x4353444D; } // namespace crossdesk diff --git a/apps/desktop/src/platform/windows/privacy/band_module/main.cpp b/apps/desktop/src/platform/windows/privacy/band_module/main.cpp new file mode 100644 index 00000000..3bdd8a9f --- /dev/null +++ b/apps/desktop/src/platform/windows/privacy/band_module/main.cpp @@ -0,0 +1,293 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-09-10 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#include +#include + +#include +#include +#include +#include + +#include "../../windows_thread_dpi.h" +#include "../privacy_band_ipc.h" +#include "privacy_cover_renderer.h" + +namespace { +using namespace crossdesk; +constexpr wchar_t kWindowClass[] = L"CrossDesk.HighBandPrivacy"; +constexpr DWORD kCoverStyles = WS_EX_TOPMOST | WS_EX_NOACTIVATE | + WS_EX_TOOLWINDOW | WS_EX_LAYERED | + 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) {} + ~Windows() { + for (const auto& window : windows_) + DestroyWindow(reinterpret_cast(window.hwnd)); + if (atom_) UnregisterClassW(kWindowClass, module); + } + bool Fail(const wchar_t* operation, DWORD code = GetLastError()) { + wcsncpy_s(shared_->operation, operation, _TRUNCATE); + InterlockedExchange(&shared_->error, code ? code : ERROR_GEN_FAILURE); + return false; + } + bool Initialize() { + const HMODULE user = GetModuleHandleW(L"user32.dll"); + create_ = reinterpret_cast( + GetProcAddress(user, "CreateWindowInBand")); + get_band_ = reinterpret_cast( + GetProcAddress(user, "GetWindowBand")); + if (!create_ || !get_band_) + return Fail(L"Resolve high-band APIs", ERROR_PROC_NOT_FOUND); + WNDCLASSEXW cls{sizeof(cls)}; + cls.hInstance = module; + cls.lpfnWndProc = WindowProc; + cls.lpszClassName = kWindowClass; + cls.hbrBackground = static_cast(GetStockObject(BLACK_BRUSH)); + atom_ = RegisterClassExW(&cls); + return atom_ ? Reconcile() : Fail(L"Register high-band window class"); + } + bool Reconcile() { + std::vector monitors; + if (!EnumDisplayMonitors( + nullptr, nullptr, + [](HMONITOR, HDC, LPRECT rect, LPARAM data) -> BOOL { + auto& result = *reinterpret_cast*>(data); + if (result.size() >= kPrivacyMaxMonitors) return FALSE; + result.push_back(*rect); + return TRUE; + }, + reinterpret_cast(&monitors)) || + monitors.empty()) + return Fail(L"Enumerate high-band displays"); + bool changed = monitors.size() != windows_.size(); + 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; + 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, + this, kPrivacyWindowBand); + if (!hwnd) return Fail(L"CreateWindowInBand ZBID_ABOVELOCK_UX"); + 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)) + return Fail(L"Exclude high-band cover from capture"); + const BOOL yes = TRUE; + const HRESULT peek = DwmSetWindowAttribute(hwnd, DWMWA_EXCLUDED_FROM_PEEK, + &yes, sizeof(yes)); + if (FAILED(peek)) return Fail(L"Exclude high-band cover from Peek", peek); + if (!SetWindowPos(hwnd, nullptr, rect.left, rect.top, + rect.right - rect.left, rect.bottom - rect.top, + SWP_NOZORDER | SWP_NOACTIVATE | SWP_SHOWWINDOW) || + !RedrawWindow(hwnd, nullptr, nullptr, RDW_INVALIDATE | RDW_UPDATENOW)) + return Fail(L"Present high-band cover"); + } + if (changed) { + const HRESULT result = DwmFlush(); + if (FAILED(result)) + return Fail(L"Flush high-band cover presentation", result); + // New areas are covered before old windows are removed. + for (auto it = windows_.begin(); it != windows_.end();) { + if (std::none_of( + monitors.begin(), monitors.end(), + [&](const auto& rect) { return SameRect(rect, it->rect); })) { + if (!DestroyWindow(reinterpret_cast(it->hwnd))) + return Fail(L"Destroy obsolete high-band cover"); + it = windows_.erase(it); + } else + ++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); + } + return true; + } + bool Validate() { + if (paint_failed_) return false; + for (const auto& w : windows_) { + HWND hwnd = reinterpret_cast(w.hwnd); + DWORD band = 0, affinity = 0, flags = 0, cloaked = 0; + BYTE alpha = 0; + RECT rect{}; + if ((GetWindowLongPtrW(hwnd, GWL_EXSTYLE) & kCoverStyles) != + kCoverStyles || + !get_band_(hwnd, &band) || band != kPrivacyWindowBand || + !GetWindowDisplayAffinity(hwnd, &affinity) || affinity != 0x11 || + !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) + return Fail(L"High-band cover lost style/band/affinity/visibility", + ERROR_INVALID_STATE); + } + return true; + } + bool changed = false; + + private: + static LRESULT CALLBACK WindowProc(HWND hwnd, UINT message, WPARAM wp, + LPARAM lp) { + if (message == WM_NCCREATE) { + auto create = reinterpret_cast(lp); + SetWindowLongPtrW(hwnd, GWLP_USERDATA, + reinterpret_cast(create->lpCreateParams)); + } + auto self = + reinterpret_cast(GetWindowLongPtrW(hwnd, GWLP_USERDATA)); + if (message == WM_MOUSEACTIVATE) return MA_NOACTIVATE; + if (message == WM_NCHITTEST) return HTTRANSPARENT; + if (message == WM_SETCURSOR) { + SetCursor(nullptr); + return TRUE; + } + if (message == WM_CLOSE) return 0; + if ((message == WM_DISPLAYCHANGE || message == WM_DPICHANGED) && self) { + self->changed = true; + InvalidateRect(hwnd, nullptr, TRUE); + return 0; + } + if (message == WM_PAINT) { + PAINTSTRUCT paint{}; + HDC dc = BeginPaint(hwnd, &paint); + RECT rect{}; + GetClientRect(hwnd, &rect); + if (self && !DrawPrivacyCover(dc, rect, PrivacyWindowDpi(hwnd), module, + self->shared_->unlock_hint)) { + self->paint_failed_ = true; + self->Fail(L"Paint privacy logo and unlock instructions"); + } + EndPaint(hwnd, &paint); + return 0; + } + return DefWindowProcW(hwnd, message, wp, lp); + } + PrivacyBandShared* shared_; + bool paint_failed_ = false; + std::vector windows_; + ATOM atom_ = 0; + CreatePrivacyWindowInBand create_ = nullptr; + GetPrivacyWindowBand get_band_ = nullptr; +}; + +DWORD WINAPI Run(void*) { + wchar_t bootstrap[256]{}; + if (!GetEnvironmentVariableW(kPrivacyBandBootstrap, bootstrap, 256)) + return ERROR_INVALID_PARAMETER; + SetEnvironmentVariableW(kPrivacyBandBootstrap, nullptr); + std::array handles{}; + wchar_t* item = bootstrap; + for (size_t i = 0; i < handles.size(); ++i) { + wchar_t* end = nullptr; + const auto value = wcstoull(item, &end, 10); + if (!value || end == item || + (i + 1 == handles.size() ? *end != 0 : *end != L',')) + return ERROR_INVALID_PARAMETER; + handles[i] = reinterpret_cast(value); + item = end + 1; + } + HANDLE parent = handles[0], stop = handles[1], ready = handles[2], + mapping = handles[3]; + auto shared = static_cast(MapViewOfFile( + mapping, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(PrivacyBandShared))); + if (!shared) return GetLastError(); + if (shared->magic != kPrivacyBandMagic || + shared->version != kPrivacyBandVersion || + shared->size != sizeof(PrivacyBandShared) || + shared->broker_pid != GetCurrentProcessId() || + shared->parent_pid != GetProcessId(parent) || + WaitForSingleObject(parent, 0) != WAIT_TIMEOUT) { + UnmapViewOfFile(shared); + return ERROR_INVALID_DATA; + } + DWORD exit_code = ERROR_SUCCESS; + { + ScopedWindowsPhysicalCoordinates dpi; + Windows windows(shared); + try { + bool healthy = dpi.active() && windows.Initialize(); + const bool initialized = healthy; + if (!dpi.active()) windows.Fail(L"Set high-band thread DPI context"); + InterlockedExchange64(&shared->heartbeat, GetTickCount64()); + if (healthy) { + InterlockedExchange(&shared->error, ERROR_SUCCESS); + InterlockedExchange(&shared->active, 1); + } + SetEvent(ready); + auto last_check = GetTickCount64(); + HANDLE watched[] = {parent, stop}; + // Once enabled, retain surviving covers on a validation/layout fault. + // The parent pauses the session after reading the latched error. Only + // an explicit stop or parent death tears the remaining windows down. + while (initialized) { + const DWORD wait = MsgWaitForMultipleObjectsEx( + 2, watched, 100, QS_ALLINPUT, MWMO_INPUTAVAILABLE); + if (wait == WAIT_OBJECT_0 || wait == WAIT_OBJECT_0 + 1) break; + if (wait == WAIT_FAILED) { + windows.Fail(L"Wait for high-band lifecycle"); + break; + } + MSG message{}; + while (PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE)) { + if (message.message == WM_QUIT) healthy = false; + TranslateMessage(&message); + DispatchMessageW(&message); + } + const auto now = GetTickCount64(); + if (healthy && (windows.changed || now - last_check >= 250)) { + windows.changed = false; + last_check = now; + healthy = windows.Reconcile(); + } + InterlockedExchange64(&shared->heartbeat, now); + } + } catch (...) { + windows.Fail(L"High-band window thread exception", + ERROR_UNHANDLED_EXCEPTION); + SetEvent(ready); + } + exit_code = InterlockedCompareExchange(&shared->error, 0, 0); + InterlockedExchange(&shared->active, 0); + } + UnmapViewOfFile(shared); + for (HANDLE handle : handles) CloseHandle(handle); + // This module runs only in the dedicated process created for this session. + // Exit outside DllMain/loader lock, after HWND destruction on their UI + // thread. + ExitProcess(exit_code); + return exit_code; +} +} // namespace + +BOOL WINAPI DllMain(HINSTANCE instance, DWORD reason, LPVOID) { + if (reason == DLL_PROCESS_ATTACH) { + module = instance; + // Keep thread notifications enabled: this module uses the static CRT. + HANDLE thread = CreateThread(nullptr, 0, Run, nullptr, 0, nullptr); + if (!thread) return FALSE; + CloseHandle(thread); + } + return TRUE; +} diff --git a/apps/desktop/src/platform/windows/privacy/band_module/privacy_cover_renderer.cpp b/apps/desktop/src/platform/windows/privacy/band_module/privacy_cover_renderer.cpp new file mode 100644 index 00000000..50eafdfe --- /dev/null +++ b/apps/desktop/src/platform/windows/privacy/band_module/privacy_cover_renderer.cpp @@ -0,0 +1,123 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-09-11 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#include "privacy_cover_renderer.h" + +#include +#include + +namespace crossdesk { +namespace { + +constexpr size_t kInstructionLines = 2; +constexpr size_t kBrandLine = kInstructionLines; + +struct PaintResources { + HDC dc; + int saved; + HICON logo = nullptr; + std::array fonts{}; + ~PaintResources() { + // Deselect every owned GDI object before releasing it. + if (saved) RestoreDC(dc, saved); + for (auto font : fonts) + if (font) DeleteObject(font); + if (logo) DestroyIcon(logo); + } +}; + +bool Select(HDC dc, HGDIOBJ object) { + const auto previous = SelectObject(dc, object); + return previous && previous != HGDI_ERROR; +} + +} // namespace + +bool DrawPrivacyCover(HDC dc, const RECT& rect, UINT dpi, HINSTANCE module, + const wchar_t* unlock_hint) { + if (!FillRect(dc, &rect, static_cast(GetStockObject(BLACK_BRUSH)))) + return false; + const int width = rect.right - rect.left; + const int height = rect.bottom - rect.top; + if (width <= 0 || height <= 0) return false; + // Scale with the monitor DPI, shrinking the whole group on small displays. + // All coordinates remain in the owning window's physical-pixel context. + const double scale = (std::min)({dpi / 96.0, width / 640.0, height / 200.0}); + const auto px = [scale](int value) { + return (std::max)(1, static_cast(value * scale + 0.5)); + }; + const int margin = px(32); + const int text_width = px(398); + PaintResources resources{dc, SaveDC(dc)}; + if (!resources.saved || !SetBkMode(dc, TRANSPARENT)) return false; + + struct Line { + const wchar_t* text; + int size; + int weight; + COLORREF color; + RECT bounds{}; + }; + std::array lines{{ + {unlock_hint, 16, FW_NORMAL, RGB(181, 190, 204)}, + {L"Ctrl + Alt + Shift + F12", 24, FW_SEMIBOLD, RGB(245, 247, 250)}, + {L"CrossDesk", 14, FW_SEMIBOLD, RGB(245, 247, 250)}, + }}; + constexpr UINT flags = DT_LEFT | DT_WORDBREAK | DT_NOPREFIX; + for (size_t i = 0; i < lines.size(); ++i) { + auto& line = lines[i]; + resources.fonts[i] = + CreateFontW(-px(line.size), 0, 0, 0, line.weight, FALSE, FALSE, FALSE, + DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, + ANTIALIASED_QUALITY, DEFAULT_PITCH, L"Microsoft YaHei UI"); + if (!resources.fonts[i] || !Select(dc, resources.fonts[i])) return false; + line.bounds = {0, 0, text_width, 0}; + if (!DrawTextW(dc, line.text, -1, &line.bounds, flags | DT_CALCRECT) || + line.bounds.right > text_width) + return false; + } + const int content_height = + static_cast(lines[0].bounds.bottom + lines[1].bounds.bottom) + + px(12); + // Match the icon width to the name; each side keeps its own height. + const int brand_gap = px(6); + const int brand_width = static_cast(lines[kBrandLine].bounds.right); + const int logo_size = brand_width; + const int brand_height = + logo_size + brand_gap + static_cast(lines[kBrandLine].bounds.bottom); + if (logo_size <= 0 || + (std::max)(brand_height, content_height) + 2 * margin > height || + brand_width + px(24) + text_width + 2 * margin > width) + return false; + resources.logo = static_cast(LoadImageW( + module, L"IDI_CROSSDESK_LOGO", IMAGE_ICON, logo_size, logo_size, 0)); + if (!resources.logo) return false; + const int brand_left = rect.left + margin; + const int left = brand_left + brand_width + px(24); + const int brand_top = rect.bottom - margin - brand_height; + int top = rect.bottom - margin - content_height; + if (!DrawIconEx(dc, brand_left, brand_top, resources.logo, logo_size, + logo_size, 0, nullptr, DI_NORMAL)) + return false; + if (!Select(dc, resources.fonts[kBrandLine])) return false; + RECT brand_bounds{brand_left, brand_top + logo_size + brand_gap, + brand_left + brand_width, rect.bottom - margin}; + SetTextColor(dc, lines[kBrandLine].color); + if (!DrawTextW(dc, lines[kBrandLine].text, -1, &brand_bounds, + DT_CENTER | DT_SINGLELINE | DT_NOPREFIX)) + return false; + for (size_t i = 0; i < kInstructionLines; ++i) { + const auto& line = lines[i]; + if (!Select(dc, resources.fonts[i])) return false; + RECT bounds{left, top, left + text_width, top + line.bounds.bottom}; + SetTextColor(dc, line.color); + if (!DrawTextW(dc, line.text, -1, &bounds, flags)) return false; + top += line.bounds.bottom + px(12); + } + return true; +} + +} // namespace crossdesk diff --git a/apps/desktop/src/platform/windows/privacy/band_module/privacy_cover_renderer.h b/apps/desktop/src/platform/windows/privacy/band_module/privacy_cover_renderer.h new file mode 100644 index 00000000..4df11e33 --- /dev/null +++ b/apps/desktop/src/platform/windows/privacy/band_module/privacy_cover_renderer.h @@ -0,0 +1,20 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-09-11 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _PRIVACY_COVER_RENDERER_H_ +#define _PRIVACY_COVER_RENDERER_H_ + +#include + +namespace crossdesk { + +// Paints the local, opaque cover. The caller owns the HWND and its DPI context. +bool DrawPrivacyCover(HDC dc, const RECT& rect, UINT dpi, HINSTANCE module, + const wchar_t* unlock_hint); + +} // namespace crossdesk + +#endif // _PRIVACY_COVER_RENDERER_H_ diff --git a/apps/desktop/src/platform/windows/privacy/cursor_helper/main.cpp b/apps/desktop/src/platform/windows/privacy/cursor_helper/main.cpp new file mode 100644 index 00000000..e64a1e5b --- /dev/null +++ b/apps/desktop/src/platform/windows/privacy/cursor_helper/main.cpp @@ -0,0 +1,83 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-09-11 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#include + +#include +#include +#include +#include + +#include "../privacy_cursor_api.h" + +namespace { +HANDLE ParseHandle(const wchar_t* text) { + wchar_t* end = nullptr; + errno = 0; + const auto value = std::wcstoull(text, &end, 10); + if (errno || !text[0] || *end || text[0] == L'-' || value == 0 || + value > (std::numeric_limits::max)()) + return nullptr; + return reinterpret_cast(static_cast(value)); +} +DWORD FailureCode() { + const DWORD code = GetLastError(); + return code ? code : ERROR_GEN_FAILURE; +} +} // namespace + +int wmain(int argc, wchar_t** argv) { + if (argc != 5) return ERROR_INVALID_PARAMETER; + HANDLE parent = ParseHandle(argv[1]); + HANDLE stop = ParseHandle(argv[2]); + HANDLE ready = ParseHandle(argv[3]); + HANDLE mapping = ParseHandle(argv[4]); + // Observe the inherited process OBJECT, never a reused PID or a process name. + if (!parent || !stop || !ready || !mapping || + WaitForSingleObject(parent, 0) != WAIT_TIMEOUT || + WaitForSingleObject(stop, 0) != WAIT_TIMEOUT) + return ERROR_INVALID_HANDLE; + auto shared = static_cast(MapViewOfFile( + mapping, FILE_MAP_WRITE, 0, 0, sizeof(crossdesk::PrivacyCursorReady))); + if (!shared) return static_cast(FailureCode()); + + crossdesk::PrivacyCursorApi cursor; + DWORD error = ERROR_SUCCESS; + if (!cursor.Initialize() || !cursor.Hide()) error = FailureCode(); + InterlockedExchange(&shared->error, static_cast(error)); + if (!SetEvent(ready)) error = FailureCode(); + if (!error) { + HANDLE watched[] = {parent, stop}; + for (;;) { + const DWORD result = + MsgWaitForMultipleObjects(2, watched, FALSE, 250, QS_ALLINPUT); + if (result == WAIT_OBJECT_0 || result == WAIT_OBJECT_0 + 1) break; + if (result == WAIT_OBJECT_0 + 2) { + MSG message{}; + while (PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE)) { + TranslateMessage(&message); + DispatchMessageW(&message); + } + } else if (result != WAIT_TIMEOUT) { + error = FailureCode(); + break; + } + if (!cursor.Hide()) { + error = FailureCode(); + break; + } + } + } + // Also runs when the parent is force-terminated or crashes. The helper does + // not own any input hooks or privacy HWNDs and never changes magnification. + if (!cursor.RestoreAndClose()) error = FailureCode(); + UnmapViewOfFile(shared); + CloseHandle(mapping); + CloseHandle(ready); + CloseHandle(stop); + CloseHandle(parent); + return static_cast(error); +} diff --git a/apps/desktop/src/platform/windows/privacy/privacy_backend_win.cpp b/apps/desktop/src/platform/windows/privacy/privacy_backend_win.cpp new file mode 100644 index 00000000..a0921a5c --- /dev/null +++ b/apps/desktop/src/platform/windows/privacy/privacy_backend_win.cpp @@ -0,0 +1,708 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-09-11 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "../windows_thread_dpi.h" +#include "privacy_backend.h" +#include "privacy_band_guard.h" +#include "privacy_cursor_guard.h" +#include "privacy_input_guard.h" +#include "privacy_probe_pattern.h" +#include "privacy_probe_renderer.h" +#include "rd_log.h" + +#ifndef WDA_EXCLUDEFROMCAPTURE +#define WDA_EXCLUDEFROMCAPTURE 0x00000011 +#endif + +namespace crossdesk { +namespace { + +constexpr wchar_t kClassName[] = L"CrossDesk.PrivacyWindow"; +constexpr UINT kEmergency = WM_APP + 71; +constexpr int kHotkeyId = 0x4353; + +std::string Error(const char* operation, DWORD code = GetLastError()) { + const std::string result = std::string(operation) + + " failed (Windows error " + std::to_string(code) + + ")"; + LOG_ERROR("Privacy: {}", result); + return result; +} + +bool DesktopName(HDESK desktop, wchar_t (&name)[256]) { + DWORD needed = 0; + return desktop && GetUserObjectInformationW(desktop, UOI_NAME, name, + 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; +}; + +BOOL CALLBACK EnumMonitor(HMONITOR monitor, HDC, LPRECT, LPARAM data) { + auto& result = *reinterpret_cast(data); + MONITORINFO info{sizeof(info)}; + if (!GetMonitorInfoW(monitor, &info)) { + Error("GetMonitorInfoW"); + result.ok = false; + return FALSE; + } + result.rects.push_back(info.rcMonitor); + return TRUE; +} + +class WindowsPrivacyBackend final : public PrivacyBackend { + public: + WindowsPrivacyBackend() { + wake_event_ = CreateEventW(nullptr, FALSE, FALSE, nullptr); + if (!wake_event_) wake_error_ = Error("CreateEventW privacy commands"); + } + + ~WindowsPrivacyBackend() override { + Recover(); + if (sink_) DestroyWindow(sink_); + if (registered_) UnregisterClassW(kClassName, GetModuleHandleW(nullptr)); + if (wake_event_) CloseHandle(wake_event_); + } + + PrivacyCapabilities Query() override { + if (!wake_event_) return {false, false, wake_error_}; + using RtlGetVersionFn = LONG(WINAPI*)(PRTL_OSVERSIONINFOW); + const auto rtl = reinterpret_cast( + GetProcAddress(GetModuleHandleW(L"ntdll.dll"), "RtlGetVersion")); + RTL_OSVERSIONINFOW version{}; + version.dwOSVersionInfoSize = sizeof(version); + if (!rtl || rtl(&version) != 0 || version.dwMajorVersion < 10 || + version.dwBuildNumber < 19041) { + return {false, false, "Requires Windows 10 2004 (build 19041) or later"}; + } + BOOL composed = FALSE; + if (FAILED(DwmIsCompositionEnabled(&composed)) || !composed) { + return {false, false, "Desktop composition is unavailable"}; + } + if (GetSystemMetrics(SM_REMOTESESSION)) { + return {false, false, "Privacy screen requires a local console session"}; + } + if (!IsWindowsPrivacyDesktopAvailable()) { + return {false, false, + "Privacy screen requires the unlocked Default desktop"}; + } + QUERY_USER_NOTIFICATION_STATE shell_state{}; + if (FAILED(SHQueryUserNotificationState(&shell_state)) || + shell_state == QUNS_RUNNING_D3D_FULL_SCREEN) { + return {false, false, + "Exclusive fullscreen or unknown display presentation cannot " + "guarantee privacy coverage"}; + } + std::string high_band_error; + if (!PrivacyBandGuard::Available(high_band_error)) + return {false, false, high_band_error}; + return {true, true, + "High-band privacy; live capture exclusion will be verified"}; + } + + bool Enable(bool block_input, const PrivacyScreenText& text, + std::string& error) override { + const auto caps = Query(); + if (!caps.overlay) { + error = caps.reason; + return false; + } + if (!Initialize(error)) return false; + emergency_ = false; + health_error_.clear(); + if (!RegisterHotKey(sink_, kHotkeyId, + MOD_CONTROL | MOD_ALT | MOD_SHIFT | MOD_NOREPEAT, + VK_F12)) { + error = Error("RegisterHotKey Ctrl+Alt+Shift+F12"); + return false; + } + hotkey_ = true; + if (!cursor_guard_.Start(error)) return false; + block_input_ = block_input; + if (block_input && !input_guard_.Start(sink_, kEmergency, error)) + return false; + if (!band_guard_.Start(text, error)) return false; + if (!ReconcileMonitors(error)) return false; + event_owner_ = this; + for (DWORD event : {EVENT_OBJECT_SHOW, EVENT_OBJECT_REORDER, + EVENT_OBJECT_LOCATIONCHANGE}) { + auto hook = SetWinEventHook(event, event, nullptr, WindowEvent, 0, 0, + WINEVENT_OUTOFCONTEXT); + if (!hook) { + error = Error("Watch privacy window coverage changes"); + return false; + } + window_events_.push_back(hook); + } + return true; + } + + void Recover() override { + for (auto& hook : window_events_) { + if (UnhookWinEvent(hook)) + hook = nullptr; + else + Error("Unhook privacy window events"); + } + window_events_.erase( + std::remove(window_events_.begin(), window_events_.end(), nullptr), + window_events_.end()); + if (window_events_.empty()) event_owner_ = nullptr; + // Remove verification panels before exposing the local desktop. + for (auto& window : windows_) { + Destroy(window.probe); + } + std::string band_error; + band_guard_.Stop(band_error); + for (auto& window : windows_) { + if (band_guard_.stopped()) window.cover = nullptr; + } + windows_.erase( + std::remove_if(windows_.begin(), windows_.end(), + [](const auto& w) { return !w.cover && !w.probe; }), + windows_.end()); + input_guard_.Stop(); + block_input_ = false; + if (hotkey_) { + if (UnregisterHotKey(sink_, kHotkeyId) || + GetLastError() == ERROR_HOTKEY_NOT_REGISTERED) + hotkey_ = false; + else + Error("UnregisterHotKey"); + } + // The sink lives across sessions. Do not let an already-posted emergency + // command from a closed session cancel a subsequent connection. + if (sink_) { + MSG discarded{}; + while (PeekMessageW(&discarded, sink_, WM_HOTKEY, WM_HOTKEY, PM_REMOVE)) { + } + while ( + PeekMessageW(&discarded, sink_, kEmergency, kEmergency, PM_REMOVE)) { + } + } + last_check_ = 0; + last_health_ = {}; + last_obstruction_ = nullptr; + last_obstruction_log_ = 0; + layout_changed_ = false; + coverage_dirty_ = false; + emergency_ = false; + health_error_.clear(); + std::string cursor_error; + cursor_guard_.Stop(cursor_error); + } + + bool IsRecovered() const override { + return band_guard_.stopped() && windows_.empty() && + window_events_.empty() && input_guard_.stopped() && !hotkey_ && + cursor_guard_.stopped(); + } + + void Wake() override { + if (wake_event_) SetEvent(wake_event_); + } + + void WaitForEvents(unsigned timeout_ms) override { + const DWORD result = MsgWaitForMultipleObjectsEx( + wake_event_ ? 1 : 0, wake_event_ ? &wake_event_ : nullptr, timeout_ms, + QS_ALLINPUT, MWMO_INPUTAVAILABLE); + if (result == WAIT_FAILED) + health_error_ = Error("MsgWaitForMultipleObjectsEx privacy thread"); + } + + PrivacyHealth Poll(bool force_check = false) override { + MSG message{}; + while (PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE)) { + if (message.message == WM_QUIT) + health_error_ = + "Privacy message thread is exiting; remote operation paused"; + TranslateMessage(&message); + DispatchMessageW(&message); + } + if (emergency_) { + emergency_ = false; + return {{}, true}; + } + if (block_input_) { + std::string error; + if (!input_guard_.Healthy(error)) return {error, false}; + } + if (windows_.empty()) 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 (!force_check && !layout_changed_ && + GetTickCount64() - last_check_ < 100) { + if (coverage_dirty_ && last_health_.failure.empty()) { + coverage_dirty_ = false; + last_health_ = CheckCoverage(); + } + return last_health_; + } + coverage_dirty_ = false; + last_check_ = GetTickCount64(); + last_health_ = CheckHealth(); + return last_health_; + } + + private: + PrivacyHealth CheckHealth() { + if (!health_error_.empty()) return {health_error_, false}; + if (!band_guard_.Healthy(health_error_)) return {health_error_, false}; + if (!IsWindowsPrivacyDesktopAvailable()) { + std::string cursor_error; + cursor_guard_.Stop(cursor_error); + return { + "Secure desktop, lock screen or unavailable input desktop; remote " + "operation paused. Privacy windows cannot cover system security UI.", + false}; + } + std::string cursor_error; + if (!cursor_guard_.Healthy(cursor_error)) return {cursor_error, false}; + QUERY_USER_NOTIFICATION_STATE shell_state{}; + if (FAILED(SHQueryUserNotificationState(&shell_state)) || + shell_state == QUNS_RUNNING_D3D_FULL_SCREEN) { + return { + "Exclusive fullscreen or unknown display presentation; remote " + "operation paused", + 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 new monitor regions are covered. Never + // automatically resume remote work after a topology/DPI transition. + std::string error; + if (!ReconcileMonitors(error)) + health_error_ = error; + else + health_error_ = + "Display layout/DPI changed; coverage rebuilt, remote operation " + "paused. Turn privacy off and reconnect to refresh display " + "mapping."; + } + if (!health_error_.empty()) return {health_error_, false}; + BOOL composed = FALSE; + if (FAILED(DwmIsCompositionEnabled(&composed)) || !composed) { + return { + "Desktop composition stopped; privacy coverage cannot be guaranteed", + false}; + } + return CheckCoverage(); + } + + PrivacyHealth CheckCoverage() { + const auto get_band = reinterpret_cast( + GetProcAddress(GetModuleHandleW(L"user32.dll"), "GetWindowBand")); + if (!get_band) return {"High-band window API became unavailable", false}; + for (const auto& w : windows_) { + DWORD band = 0; + DWORD affinity = 0; + BYTE alpha = 0; + DWORD layered_flags = 0; + RECT rect{}; + DWORD cloaked = 0; + if (!get_band(w.cover, &band) || band != kPrivacyWindowBand || + !IsWindowVisible(w.cover) || !GetWindowRect(w.cover, &rect) || + !SameRect(rect, w.rect) || + !(GetWindowLongPtrW(w.cover, GWL_EXSTYLE) & WS_EX_TOPMOST) || + !GetLayeredWindowAttributes(w.cover, nullptr, &alpha, + &layered_flags) || + alpha != 255 || layered_flags != LWA_ALPHA || + !GetWindowDisplayAffinity(w.cover, &affinity) || + affinity != WDA_EXCLUDEFROMCAPTURE || + FAILED(DwmGetWindowAttribute(w.cover, DWMWA_CLOAKED, &cloaked, + sizeof(cloaked))) || + cloaked) { + return { + "Privacy window visibility, geometry or capture affinity was lost; " + "remote operation paused", + false}; + } + } + for (const auto& w : windows_) { + // Detect windows in special bands above the cover. A high-band window + // still cannot cover another desktop or guarantee exclusive fullscreen. + for (HWND above = GetWindow(w.cover, GW_HWNDPREV); above; + above = GetWindow(above, GW_HWNDPREV)) { + if (!IsWindowVisible(above) || IsIconic(above)) continue; + DWORD above_band = 0; + if (!get_band(above, &above_band)) + return {"Could not inspect a window above the high-band cover", + false}; + // HWND list order alone does not express compositor band order. + // Only skip the two known ordinary desktop bands, never unknown bands. + if (above_band == 0 || above_band == 1) continue; + if (std::any_of(windows_.begin(), windows_.end(), + [above](const auto& own) { + return above == own.cover || above == own.probe; + })) + continue; + RECT bounds{}, intersection{}; + DWORD hidden = 0; + if (SUCCEEDED(DwmGetWindowAttribute(above, DWMWA_CLOAKED, &hidden, + sizeof(hidden))) && + hidden) + continue; + if (GetWindowRect(above, &bounds) && + IntersectRect(&intersection, &bounds, &w.rect)) { + char window_class[128]{}; + DWORD process_id = 0; + GetClassNameA(above, window_class, sizeof(window_class)); + GetWindowThreadProcessId(above, &process_id); + const auto now = GetTickCount64(); + if (above != last_obstruction_ || + now - last_obstruction_log_ >= 1000) { + LOG_WARN( + "Privacy cover obstructed: class={}, process={}; " + "remote operation paused", + window_class, process_id); + last_obstruction_ = above; + last_obstruction_log_ = now; + } + // A higher-band/system window cannot be repaired by raising an + // ordinary cover. Retain protection and require explicit recovery. + return { + "A system or higher-band window overrides privacy coverage; " + "remote operation paused. Turn privacy off to recover.", + false}; + } + } + } + return {health_error_, false}; + } + + public: + bool PaintChallenge(uint32_t value, const std::string& title, + std::string& error) override { + using Pattern = PrivacyProbePattern; + for (auto& w : windows_) { + if (!w.probe && !CreateProbe(w, error)) return false; + PrivacyProbeImage image; + if (!RenderPrivacyProbe(title, PrivacyWindowDpi(w.probe), + w.rect.right - w.rect.left - 2 * Pattern::kOffset, + w.rect.bottom - w.rect.top - 2 * Pattern::kOffset, + value, image, error)) { + LOG_ERROR("Privacy verification panel: {}", error); + return false; + } + HDC screen = GetDC(nullptr); + HDC memory = CreateCompatibleDC(screen); + BITMAPINFO info{}; + info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + info.bmiHeader.biWidth = image.width; + info.bmiHeader.biHeight = -image.height; + info.bmiHeader.biPlanes = 1; + info.bmiHeader.biBitCount = 32; + info.bmiHeader.biCompression = BI_RGB; + void* bits = nullptr; + HBITMAP bitmap = + CreateDIBSection(screen, &info, DIB_RGB_COLORS, &bits, nullptr, 0); + bool ok = screen && memory && bitmap && bits; + if (ok) { + std::memcpy(bits, image.pixels.data(), + image.pixels.size() * sizeof(uint32_t)); + const auto old = SelectObject(memory, bitmap); + POINT position{w.rect.left + Pattern::kOffset, + w.rect.top + Pattern::kOffset}, + source{}; + SIZE size{image.width, image.height}; + ok = old && old != HGDI_ERROR; + if (ok) { + ok = UpdateLayeredWindow(w.probe, screen, &position, &size, memory, + &source, 0, nullptr, ULW_OPAQUE); + SelectObject(memory, old); + } + } + if (!ok) error = Error("Present opaque privacy verification panel"); + if (bitmap) DeleteObject(bitmap); + if (memory) DeleteDC(memory); + if (screen) ReleaseDC(nullptr, screen); + if (!ok) return false; + // Present only after the complete text and square bitmap is ready. + if (!IsWindowVisible(w.probe) && + (!SetWindowPos( + w.probe, HWND_TOPMOST, 0, 0, 0, 0, + SWP_NOACTIVATE | SWP_NOMOVE | SWP_NOSIZE | SWP_SHOWWINDOW) || + !IsWindowVisible(w.probe))) { + error = Error("Show privacy verification panel beneath cover"); + return false; + } + } + return true; + } + + bool ClearChallenges(std::string& error) override { + for (auto& w : windows_) { + Destroy(w.probe); + if (w.probe) { + error = "Could not remove the temporary capture verification window"; + return false; + } + } + return true; + } + + bool HasMonitor(int left, int top, int width, int height) const override { + // Metadata only; also serialized against Poll by PrivacyController. + return std::any_of(windows_.begin(), windows_.end(), [&](const auto& w) { + return w.rect.left == left && w.rect.top == top && + w.rect.right - left == width && w.rect.bottom - top == height; + }); + } + + private: + struct Window { + RECT rect{}; + HWND cover = nullptr; + HWND probe = nullptr; + }; + + static void Destroy(HWND& hwnd) { + if (hwnd && IsWindow(hwnd) && !DestroyWindow(hwnd)) { + Error("DestroyWindow"); + return; + } + hwnd = nullptr; + } + + bool Initialize(std::string& error) { + if (sink_) return true; + // This dedicated thread owns no existing application windows. Never alter + // process-wide or Slint/SDL DPI awareness. + if (!dpi_scope_ || !dpi_scope_->active()) { + dpi_scope_.reset(); + dpi_scope_ = std::make_unique(); + } + if (!dpi_scope_->active()) { + error = Error("SetThreadDpiAwarenessContext"); + return false; + } + WNDCLASSEXW cls{sizeof(cls)}; + cls.lpfnWndProc = WindowProc; + cls.hInstance = GetModuleHandleW(nullptr); + cls.lpszClassName = kClassName; + cls.hCursor = nullptr; + cls.hbrBackground = static_cast(GetStockObject(BLACK_BRUSH)); + if (!registered_ && !RegisterClassExW(&cls)) { + error = Error("RegisterClassExW"); + return false; + } + registered_ = true; + sink_ = CreateWindowExW(WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE, kClassName, + L"", WS_POPUP, 0, 0, 0, 0, nullptr, nullptr, + cls.hInstance, this); + if (!sink_) { + error = Error("CreateWindowExW message sink"); + return false; + } + return true; + } + + bool CreateProbe(Window& w, std::string& error) { + constexpr DWORD styles = WS_EX_TOPMOST | WS_EX_TOOLWINDOW | + WS_EX_NOACTIVATE | WS_EX_LAYERED | + WS_EX_TRANSPARENT; + w.probe = + CreateWindowExW(styles, kClassName, L"CrossDesk capture verification", + WS_POPUP, w.rect.left + PrivacyProbePattern::kOffset, + w.rect.top + PrivacyProbePattern::kOffset, 1, 1, + nullptr, nullptr, GetModuleHandleW(nullptr), this); + if (!w.probe) { + error = Error("CreateWindowExW capture challenge"); + return false; + } + return true; + } + + bool ReconcileMonitors(std::string& error) { + MonitorEnumeration monitors; + if (!EnumDisplayMonitors(nullptr, nullptr, EnumMonitor, + reinterpret_cast(&monitors)) || + !monitors.ok || monitors.rects.empty()) { + 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), nullptr}); + LOG_INFO( + "Privacy high-band cover ready: bounds=({}, {})-({}, {}), " + "affinity=0x11, alpha=255", + rect.left, rect.top, rect.right, rect.bottom); + } + for (auto it = windows_.begin(); it != windows_.end();) { + if (std::none_of(monitors.rects.begin(), monitors.rects.end(), + [&](const RECT& r) { return SameRect(it->rect, r); })) { + Destroy(it->probe); + it->cover = nullptr; // The broker owns cover destruction. + if (!it->probe) + it = windows_.erase(it); + else { + error = "Old privacy window could not be released"; + return false; + } + } else + ++it; + } + return true; + } + + static LRESULT CALLBACK WindowProc(HWND hwnd, UINT msg, WPARAM wp, + LPARAM lp) { + if (msg == WM_NCCREATE) { + const auto create = reinterpret_cast(lp); + SetWindowLongPtrW(hwnd, GWLP_USERDATA, + reinterpret_cast(create->lpCreateParams)); + } + auto self = reinterpret_cast( + GetWindowLongPtrW(hwnd, GWLP_USERDATA)); + if (msg == WM_HOTKEY || msg == kEmergency) { + if (self && hwnd == self->sink_) self->emergency_ = true; + return 0; + } + if (msg == WM_MOUSEACTIVATE) return MA_NOACTIVATE; + if (msg == WM_SETCURSOR) { + SetCursor(nullptr); + return TRUE; + } + if (msg == WM_NCHITTEST) return HTTRANSPARENT; + if (msg == WM_CLOSE) return 0; + if (msg == WM_PAINT) { + // UpdateLayeredWindow owns the complete probe image; WM_PAINT only + // validates the update region and never draws a second copy. + PAINTSTRUCT paint{}; + BeginPaint(hwnd, &paint); + EndPaint(hwnd, &paint); + return 0; + } + if ((msg == WM_DISPLAYCHANGE || msg == WM_DPICHANGED) && self) { + self->layout_changed_ = true; + return 0; + } + return DefWindowProcW(hwnd, msg, wp, lp); + } + + static void CALLBACK WindowEvent(HWINEVENTHOOK, DWORD, HWND hwnd, LONG object, + LONG child, DWORD, DWORD) { + auto self = event_owner_; + if (!self || !hwnd || object != OBJID_WINDOW || child != CHILDID_SELF || + hwnd == self->sink_ || GetAncestor(hwnd, GA_ROOT) != hwnd) + return; + for (const auto& w : self->windows_) + if (hwnd == w.cover || hwnd == w.probe) return; + // OUTOFCONTEXT delivers on this window thread. Only mark work here; + // monitor/session queries and Z-order changes remain in the control loop. + const auto get_band = reinterpret_cast( + GetProcAddress(GetModuleHandleW(L"user32.dll"), "GetWindowBand")); + DWORD band = 0; + if (get_band && get_band(hwnd, &band) && (band == 0 || band == 1)) return; + self->coverage_dirty_ = true; + } + + inline static thread_local WindowsPrivacyBackend* event_owner_ = nullptr; + std::vector window_events_; + std::vector windows_; + HWND sink_ = nullptr; + HANDLE wake_event_ = nullptr; + std::string wake_error_; + PrivacyInputGuard input_guard_; + bool block_input_ = false; + PrivacyCursorGuard cursor_guard_; + PrivacyBandGuard band_guard_; + std::unique_ptr dpi_scope_; + bool registered_ = false; + bool hotkey_ = false; + bool emergency_ = false; + bool layout_changed_ = false; + bool coverage_dirty_ = false; + uint64_t last_check_ = 0; + PrivacyHealth last_health_; + HWND last_obstruction_ = nullptr; + uint64_t last_obstruction_log_ = 0; + std::string health_error_; +}; + +} // namespace + +bool IsWindowsPrivacyDesktopAvailable() { + // LockApp can display the lock screen while OpenInputDesktop still reports + // Default. Desktop-name checks alone are NOT a session-unlocked check. + DWORD session = 0; + if (!ProcessIdToSessionId(GetCurrentProcessId(), &session) || + session != WTSGetActiveConsoleSessionId()) + return false; + LPWSTR buffer = nullptr; + DWORD bytes = 0; + if (!WTSQuerySessionInformationW(WTS_CURRENT_SERVER_HANDLE, session, + WTSSessionInfoEx, &buffer, &bytes)) + return false; + const auto info = reinterpret_cast(buffer); + const bool unlocked = + bytes >= sizeof(WTSINFOEXW) && info->Level == 1 && + info->Data.WTSInfoExLevel1.SessionFlags == WTS_SESSIONSTATE_UNLOCK; + WTSFreeMemory(buffer); + if (!unlocked) return false; + HDESK input = OpenInputDesktop(0, FALSE, DESKTOP_READOBJECTS); + if (!input) return false; + wchar_t input_name[256]{}, thread_name[256]{}; + const bool ok = + DesktopName(input, input_name) && + DesktopName(GetThreadDesktop(GetCurrentThreadId()), thread_name) && + _wcsicmp(input_name, L"Default") == 0 && + _wcsicmp(input_name, thread_name) == 0; + CloseDesktop(input); + return ok; +} + +std::unique_ptr CreateWindowsPrivacyBackend() { + return std::make_unique(); +} + +} // namespace crossdesk diff --git a/apps/desktop/src/platform/windows/privacy/privacy_band_guard.cpp b/apps/desktop/src/platform/windows/privacy/privacy_band_guard.cpp new file mode 100644 index 00000000..15dabde2 --- /dev/null +++ b/apps/desktop/src/platform/windows/privacy/privacy_band_guard.cpp @@ -0,0 +1,323 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-09-10 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#include "privacy_band_guard.h" + +#include + +#include +#include +#include + +#include "privacy_backend.h" +#include "rd_log.h" + +namespace crossdesk { +namespace { +struct ScopedHandle { + HANDLE value = nullptr; + ~ScopedHandle() { + if (value && value != INVALID_HANDLE_VALUE) CloseHandle(value); + } +}; +bool Failed(const char* operation, std::string& error, + DWORD code = GetLastError()) { + error = + std::string(operation) + " (Windows error " + std::to_string(code) + ")"; + LOG_ERROR("Privacy high-band: {}", error); + return false; +} + +std::filesystem::path ModuleDirectory() { + std::vector path(32768); + const DWORD length = GetModuleFileNameW(nullptr, path.data(), path.size()); + if (!length || length >= path.size()) return {}; + return std::filesystem::path(path.data()).parent_path(); +} + +std::vector ChildEnvironment(const std::wstring& bootstrap) { + std::vector entries; + LPWCH environment = GetEnvironmentStringsW(); + if (!environment) return {}; + const std::wstring prefix = std::wstring(kPrivacyBandBootstrap) + L"="; + for (const wchar_t* item = environment; *item; item += wcslen(item) + 1) { + if (_wcsnicmp(item, prefix.c_str(), prefix.size()) != 0) + entries.emplace_back(item); + } + FreeEnvironmentStringsW(environment); + entries.push_back(prefix + bootstrap); + std::sort(entries.begin(), entries.end(), [](const auto& a, const auto& b) { + return _wcsicmp(a.c_str(), b.c_str()) < 0; + }); + std::vector result; + for (const auto& entry : entries) { + result.insert(result.end(), entry.begin(), entry.end()); + result.push_back(0); + } + result.push_back(0); + return result; +} +} // namespace + +PrivacyBandGuard::~PrivacyBandGuard() { + std::string error; + if (!Stop(error)) { + // Last-resort teardown still targets only this object's dedicated job. + if (job_) CloseHandle(job_); + job_ = nullptr; + Stop(error); + } +} + +bool PrivacyBandGuard::Available(std::string& error) { +#if !defined(_M_X64) && !defined(__x86_64__) + error = "High-band privacy currently requires an x64 Windows process"; + return false; +#else + const HMODULE user = GetModuleHandleW(L"user32.dll"); + if (!GetProcAddress(user, "CreateWindowInBand") || + !GetProcAddress(user, "GetWindowBand")) { + error = "Windows high-band window APIs are unavailable"; + return false; + } + const auto directory = ModuleDirectory(); + if (directory.empty()) + return Failed("Resolve privacy module directory", error); + const auto dll = directory / kPrivacyBandDll; + if (GetFileAttributesW(dll.c_str()) == INVALID_FILE_ATTRIBUTES) + return Failed("Missing crossdesk_privacy_window.dll", error); + return true; +#endif +} + +bool PrivacyBandGuard::Start(const PrivacyScreenText& text, + std::string& error) { + if (process_) + return Failed("High-band broker is already running", error, ERROR_BUSY); + if (!Available(error)) return false; + const auto directory = ModuleDirectory(); + if (directory.empty()) + return Failed("Resolve privacy module directory", error); + const auto dll = directory / kPrivacyBandDll; + wchar_t system[MAX_PATH]{}, temporary[MAX_PATH]{}, guid_text[40]{}; + GUID guid{}; + if (!GetSystemDirectoryW(system, MAX_PATH) || + !GetTempPathW(MAX_PATH, temporary) || FAILED(CoCreateGuid(&guid)) || + !StringFromGUID2(guid, guid_text, 40)) + return Failed("Resolve high-band broker paths", error); + broker_directory_ = std::filesystem::path(temporary) / + (std::wstring(L"CrossDesk-privacy-") + guid_text); + if (!CreateDirectoryW(broker_directory_.c_str(), nullptr)) + return Failed("Create private broker directory", error); + broker_path_ = broker_directory_ / L"RuntimeBroker_crossdesk.exe"; + if (!CopyFileW((std::filesystem::path(system) / L"RuntimeBroker.exe").c_str(), + broker_path_.c_str(), TRUE)) + return Failed("Copy system RuntimeBroker for this session", error); + // 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); + 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); + + SECURITY_ATTRIBUTES inherit{sizeof(inherit), nullptr, TRUE}; + ScopedHandle parent; + if (!DuplicateHandle(GetCurrentProcess(), GetCurrentProcess(), + GetCurrentProcess(), &parent.value, + SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, TRUE, + 0)) + return Failed("Duplicate privacy parent process handle", error); + stop_ = CreateEventW(&inherit, TRUE, FALSE, nullptr); + ready_ = CreateEventW(&inherit, TRUE, FALSE, nullptr); + 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); + } + shared_ = static_cast(MapViewOfFile( + mapping_, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(PrivacyBandShared))); + if (!shared_) { + return Failed("Map high-band broker IPC", error); + } + new (shared_) PrivacyBandShared{}; + // Immutable after process startup; no cross-process string objects or + // concurrent writes while the window thread paints. + if (!MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, + text.unlock_hint.c_str(), -1, shared_->unlock_hint, + static_cast(std::size(shared_->unlock_hint)))) + return Failed("Encode privacy screen text", error); + shared_->parent_pid = GetCurrentProcessId(); + HANDLE handles[] = {parent.value, stop_, ready_, mapping_}; + std::wstring bootstrap; + for (HANDLE handle : handles) { + if (!bootstrap.empty()) bootstrap += L","; + bootstrap += std::to_wstring(reinterpret_cast(handle)); + } + auto environment = ChildEnvironment(bootstrap); + if (environment.empty()) { + return Failed("Build broker environment", error); + } + + job_ = CreateJobObjectW(nullptr, nullptr); + JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{}; + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + if (!job_ || !SetInformationJobObject(job_, JobObjectExtendedLimitInformation, + &limits, sizeof(limits))) { + return Failed("Bind high-band broker lifetime", error); + } + SIZE_T bytes = 0; + InitializeProcThreadAttributeList(nullptr, 1, 0, &bytes); + std::vector attributes(bytes); + STARTUPINFOEXW startup{}; + startup.StartupInfo.cb = sizeof(startup); + startup.lpAttributeList = + reinterpret_cast(attributes.data()); + if (!InitializeProcThreadAttributeList(startup.lpAttributeList, 1, 0, + &bytes)) { + return Failed("Initialize broker handle list", error); + } + bool ok = UpdateProcThreadAttribute( + startup.lpAttributeList, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + handles, sizeof(handles), nullptr, nullptr) != FALSE; + PROCESS_INFORMATION child{}; + std::wstring command = L"\"" + broker_path_.wstring() + L"\""; + if (ok) { + ok = CreateProcessW( + broker_path_.c_str(), command.data(), nullptr, nullptr, TRUE, + CREATE_SUSPENDED | CREATE_NO_WINDOW | CREATE_UNICODE_ENVIRONMENT | + EXTENDED_STARTUPINFO_PRESENT, + environment.data(), broker_directory_.c_str(), + &startup.StartupInfo, &child) != FALSE; + } + const DWORD create_error = GetLastError(); + DeleteProcThreadAttributeList(startup.lpAttributeList); + if (!ok) + return Failed("Create owned RuntimeBroker process", error, create_error); + process_ = child.hProcess; + shared_->broker_pid = child.dwProcessId; + if (!AssignProcessToJobObject(job_, process_)) { + const DWORD code = GetLastError(); + TerminateProcess(process_, code); + CloseHandle(child.hThread); + return Failed("Assign owned RuntimeBroker to privacy job", error, code); + } + const auto path = dll.wstring(); + const auto size = (path.size() + 1) * sizeof(wchar_t); + void* remote_path = VirtualAllocEx(process_, nullptr, size, + MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); + SIZE_T written = 0; + const auto loader = + GetProcAddress(GetModuleHandleW(L"kernel32.dll"), "LoadLibraryW"); + ok = + remote_path && loader && + WriteProcessMemory(process_, remote_path, path.c_str(), size, &written) && + written == size && + QueueUserAPC(reinterpret_cast(loader), child.hThread, + reinterpret_cast(remote_path)); + DWORD injection_error = GetLastError(); + if (ok && ResumeThread(child.hThread) == DWORD(-1)) { + ok = false; + injection_error = GetLastError(); + } + CloseHandle(child.hThread); + if (!ok) + return Failed("Load high-band window module into owned broker", error, + injection_error); + HANDLE startup_events[] = {ready_, process_}; + const DWORD result = WaitForMultipleObjects(2, startup_events, FALSE, 5000); + if (result != WAIT_OBJECT_0) { + DWORD code = ERROR_TIMEOUT; + if (result == WAIT_OBJECT_0 + 1) GetExitCodeProcess(process_, &code); + return Failed("High-band broker did not become ready", error, code); + } + std::vector windows; + if (!Windows(windows, error)) return false; + LOG_INFO("Privacy high-band broker ready: process={}, band={}, monitors={}", + process_id(), kPrivacyWindowBand, windows.size()); + return true; +} + +bool PrivacyBandGuard::Healthy(std::string& error) { + if (!process_ || !shared_) + return Failed("High-band broker is not initialized", error, + ERROR_INVALID_HANDLE); + const LONG code = InterlockedCompareExchange(&shared_->error, 0, 0); + if (code != ERROR_SUCCESS) { + char operation[256]{}; + WideCharToMultiByte(CP_UTF8, 0, shared_->operation, -1, operation, + sizeof(operation), nullptr, nullptr); + return Failed(operation[0] ? operation : "High-band window startup failed", + error, code); + } + if (WaitForSingleObject(process_, 0) != WAIT_TIMEOUT) { + DWORD exit_code = ERROR_PROCESS_ABORTED; + GetExitCodeProcess(process_, &exit_code); + return Failed("High-band window process exited", error, exit_code); + } + if (!InterlockedCompareExchange(&shared_->active, 0, 0) || + GetTickCount64() - static_cast(InterlockedCompareExchange64( + &shared_->heartbeat, 0, 0)) > + 1500) + return Failed("High-band window thread stopped responding", error, + ERROR_TIMEOUT); + return true; +} + +bool PrivacyBandGuard::Windows(std::vector& windows, + std::string& error) { + if (!Healthy(error)) return false; + 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; + windows.assign(shared_->windows, shared_->windows + count); + if (before == InterlockedCompareExchange(&shared_->sequence, 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 true; + } + } + return Failed("High-band monitor snapshot is changing or invalid", error, + ERROR_RETRY); +} + +bool PrivacyBandGuard::Stop(std::string& error) { + bool ok = true; + if (process_) { + if (stop_) SetEvent(stop_); + if (WaitForSingleObject(process_, 1500) != WAIT_OBJECT_0) { + // This handle came only from our CreateProcessW call, never discovery. + if (!TerminateProcess(process_, ERROR_CANCELLED) || + WaitForSingleObject(process_, 1500) != WAIT_OBJECT_0) + ok = Failed("Stop owned high-band broker", error); + } + if (!ok) return false; + CloseHandle(process_); + process_ = nullptr; + } + if (job_) CloseHandle(job_); + job_ = nullptr; + if (shared_) UnmapViewOfFile(shared_); + shared_ = nullptr; + for (HANDLE handle : {stop_, ready_, mapping_, image_lock_, dll_lock_}) + if (handle && handle != INVALID_HANDLE_VALUE) CloseHandle(handle); + stop_ = ready_ = mapping_ = image_lock_ = dll_lock_ = nullptr; + if (!broker_path_.empty()) DeleteFileW(broker_path_.c_str()); + if (!broker_directory_.empty()) RemoveDirectoryW(broker_directory_.c_str()); + broker_path_.clear(); + broker_directory_.clear(); + return true; +} + +} // namespace crossdesk diff --git a/apps/desktop/src/platform/windows/privacy/privacy_band_guard.h b/apps/desktop/src/platform/windows/privacy/privacy_band_guard.h new file mode 100644 index 00000000..2760b4ae --- /dev/null +++ b/apps/desktop/src/platform/windows/privacy/privacy_band_guard.h @@ -0,0 +1,51 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-09-10 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _PRIVACY_BAND_GUARD_H_ +#define _PRIVACY_BAND_GUARD_H_ + +#include + +#include +#include +#include + +#include "privacy_band_ipc.h" + +namespace crossdesk { + +struct PrivacyScreenText; + +// Lifecycle and snapshots are owned by the privacy control thread. The broker +// owns all high-band HWND operations. This class never injects an existing +// process and never terminates a process found by name or PID. +class PrivacyBandGuard { + public: + ~PrivacyBandGuard(); + static bool Available(std::string& error); + bool Start(const PrivacyScreenText& text, std::string& error); + bool Stop(std::string& error); + bool Healthy(std::string& error); + bool Windows(std::vector& windows, std::string& error); + bool stopped() const { return !process_; } + DWORD process_id() const { return process_ ? GetProcessId(process_) : 0; } + + private: + HANDLE process_ = nullptr; + HANDLE job_ = nullptr; + HANDLE stop_ = nullptr; + HANDLE ready_ = nullptr; + HANDLE mapping_ = nullptr; + HANDLE image_lock_ = nullptr; + HANDLE dll_lock_ = nullptr; + PrivacyBandShared* shared_ = nullptr; + std::filesystem::path broker_path_; + std::filesystem::path broker_directory_; +}; + +} // namespace crossdesk + +#endif // _PRIVACY_BAND_GUARD_H_ diff --git a/apps/desktop/src/platform/windows/privacy/privacy_band_ipc.h b/apps/desktop/src/platform/windows/privacy/privacy_band_ipc.h new file mode 100644 index 00000000..f321b04d --- /dev/null +++ b/apps/desktop/src/platform/windows/privacy/privacy_band_ipc.h @@ -0,0 +1,53 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-09-10 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _PRIVACY_BAND_IPC_H_ +#define _PRIVACY_BAND_IPC_H_ + +#include + +#include + +namespace crossdesk { + +constexpr uint32_t kPrivacyBandMagic = 0x43534231; +constexpr uint32_t kPrivacyBandVersion = 2; +constexpr DWORD kPrivacyWindowBand = 18; // ZBID_ABOVELOCK_UX, current desktop. +constexpr size_t kPrivacyMaxMonitors = 64; +constexpr wchar_t kPrivacyBandBootstrap[] = L"CROSSDESK_PRIVACY_BAND_BOOTSTRAP"; +constexpr wchar_t kPrivacyBandDll[] = L"crossdesk_privacy_window.dll"; + +struct PrivacyBandWindow { + uint64_t hwnd = 0; + RECT rect{}; +}; + +// Unnamed mapping and synchronization/process handles are inherited only by +// the newly created broker. No global window-name/PID discovery or named IPC. +struct PrivacyBandShared { + uint32_t magic = kPrivacyBandMagic; + uint32_t version = kPrivacyBandVersion; + uint32_t size = sizeof(PrivacyBandShared); + DWORD parent_pid = 0; + DWORD broker_pid = 0; + volatile LONG sequence = 0; + volatile LONG error = ERROR_IO_PENDING; + volatile LONG active = 0; + alignas(8) volatile LONG64 heartbeat = 0; + wchar_t operation[128]{}; + wchar_t unlock_hint[128]{}; + uint32_t count = 0; + PrivacyBandWindow windows[kPrivacyMaxMonitors]{}; +}; + +using CreatePrivacyWindowInBand = HWND(WINAPI*)(DWORD, ATOM, LPCWSTR, DWORD, + int, int, int, int, HWND, HMENU, + HINSTANCE, LPVOID, DWORD); +using GetPrivacyWindowBand = BOOL(WINAPI*)(HWND, DWORD*); + +} // namespace crossdesk + +#endif // _PRIVACY_BAND_IPC_H_ diff --git a/apps/desktop/src/platform/windows/privacy/privacy_cursor_api.h b/apps/desktop/src/platform/windows/privacy/privacy_cursor_api.h new file mode 100644 index 00000000..055ba640 --- /dev/null +++ b/apps/desktop/src/platform/windows/privacy/privacy_cursor_api.h @@ -0,0 +1,70 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-09-10 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _PRIVACY_CURSOR_API_H_ +#define _PRIVACY_CURSOR_API_H_ + +#include + +namespace crossdesk { + +// A separate cursor plane can remain visible above a capture-excluded HWND. +// Magnification's documented cursor API hides that plane across input queues; +// it does not change cursor schemes, shapes, positions, focus or DPI awareness. +class PrivacyCursorApi { + public: + PrivacyCursorApi() = default; + PrivacyCursorApi(const PrivacyCursorApi&) = delete; + PrivacyCursorApi& operator=(const PrivacyCursorApi&) = delete; + ~PrivacyCursorApi() { RestoreAndClose(); } + bool Initialize() { + module_ = LoadLibraryExW(L"Magnification.dll", nullptr, + LOAD_LIBRARY_SEARCH_SYSTEM32); + if (!module_) return false; + const auto initialize = reinterpret_cast( + GetProcAddress(module_, "MagInitialize")); + uninitialize_ = reinterpret_cast( + GetProcAddress(module_, "MagUninitialize")); + show_ = reinterpret_cast( + GetProcAddress(module_, "MagShowSystemCursor")); + if (!initialize || !uninitialize_ || !show_) { + SetLastError(ERROR_PROC_NOT_FOUND); + return false; + } + initialized_ = initialize() != FALSE; + return initialized_; + } + bool Hide() { return initialized_ && show_(FALSE); } + bool RestoreAndClose() { + bool ok = true; + if (initialized_) { + ok = show_(TRUE) != FALSE; + ok = (uninitialize_() != FALSE) && ok; + initialized_ = false; + } + if (module_) { + FreeLibrary(module_); + module_ = nullptr; + } + return ok; + } + + private: + using InitializeFn = BOOL(WINAPI*)(); + using ShowFn = BOOL(WINAPI*)(BOOL); + HMODULE module_ = nullptr; + InitializeFn uninitialize_ = nullptr; + ShowFn show_ = nullptr; + bool initialized_ = false; +}; + +struct PrivacyCursorReady { + volatile LONG error; +}; + +} // namespace crossdesk + +#endif // _PRIVACY_CURSOR_API_H_ diff --git a/apps/desktop/src/platform/windows/privacy/privacy_cursor_guard.cpp b/apps/desktop/src/platform/windows/privacy/privacy_cursor_guard.cpp new file mode 100644 index 00000000..6da5bbd6 --- /dev/null +++ b/apps/desktop/src/platform/windows/privacy/privacy_cursor_guard.cpp @@ -0,0 +1,158 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-09-11 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#include "privacy_cursor_guard.h" + +#include +#include + +#include "privacy_cursor_api.h" +#include "rd_log.h" + +namespace crossdesk { +namespace { +struct Handle { + HANDLE value = nullptr; + ~Handle() { + if (value) CloseHandle(value); + } +}; +bool Failed(const char* operation, std::string& error, + DWORD code = GetLastError()) { + error = std::string(operation) + " failed (Windows error " + + std::to_string(code) + ")"; + LOG_ERROR("Privacy cursor: {}", error); + return false; +} +} // namespace + +PrivacyCursorGuard::~PrivacyCursorGuard() { + std::string error; + Stop(error); + // The inherited parent handle still lets a slow helper restore on exit. + if (process_) CloseHandle(process_); + if (stop_event_) CloseHandle(stop_event_); +} + +bool PrivacyCursorGuard::Start(std::string& error) { + if (process_ || restore_pending_) + return Failed("Cursor guard already active", error, ERROR_BUSY); + helper_failure_reported_ = false; + std::vector module(32768); + const DWORD count = GetModuleFileNameW(nullptr, module.data(), + static_cast(module.size())); + if (!count || count >= module.size()) + return Failed("GetModuleFileNameW", error); + const auto helper = std::filesystem::path(module.data()).parent_path() / + L"crossdesk_privacy_cursor_helper.exe"; + SECURITY_ATTRIBUTES security{sizeof(security), nullptr, TRUE}; + Handle parent; + if (!DuplicateHandle(GetCurrentProcess(), GetCurrentProcess(), + GetCurrentProcess(), &parent.value, SYNCHRONIZE, TRUE, + 0)) + return Failed("Duplicate parent process handle", error); + Handle stop{CreateEventW(&security, TRUE, FALSE, nullptr)}; + Handle ready{CreateEventW(&security, TRUE, FALSE, nullptr)}; + Handle mapping{CreateFileMappingW(INVALID_HANDLE_VALUE, &security, + PAGE_READWRITE, 0, + sizeof(PrivacyCursorReady), nullptr)}; + if (!stop.value || !ready.value || !mapping.value) + return Failed("Create cursor guard IPC", error); + auto shared = static_cast(MapViewOfFile( + mapping.value, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(PrivacyCursorReady))); + if (!shared) return Failed("Map cursor guard readiness", error); + InterlockedExchange(&shared->error, ERROR_IO_PENDING); + + SIZE_T bytes = 0; + InitializeProcThreadAttributeList(nullptr, 1, 0, &bytes); + std::vector attributes(bytes); + STARTUPINFOEXW startup{}; + startup.StartupInfo.cb = sizeof(startup); + startup.lpAttributeList = + reinterpret_cast(attributes.data()); + if (!InitializeProcThreadAttributeList(startup.lpAttributeList, 1, 0, + &bytes)) { + const DWORD code = GetLastError(); + UnmapViewOfFile(shared); + return Failed("Initialize cursor guard handle list", error, code); + } + HANDLE inherited[] = {parent.value, stop.value, ready.value, mapping.value}; + bool ok = UpdateProcThreadAttribute( + startup.lpAttributeList, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + inherited, sizeof(inherited), nullptr, nullptr) != FALSE; + PROCESS_INFORMATION child{}; + if (ok) { + std::wstring command = L"\"" + helper.wstring() + L"\""; + for (HANDLE handle : inherited) + command += L" " + std::to_wstring(reinterpret_cast(handle)); + ok = + CreateProcessW(helper.c_str(), command.data(), nullptr, nullptr, TRUE, + CREATE_NO_WINDOW | EXTENDED_STARTUPINFO_PRESENT | + CREATE_BREAKAWAY_FROM_JOB, + nullptr, nullptr, &startup.StartupInfo, &child) != FALSE; + } + const DWORD start_error = GetLastError(); + DeleteProcThreadAttributeList(startup.lpAttributeList); + if (!ok) { + UnmapViewOfFile(shared); + return Failed("Start crossdesk_privacy_cursor_helper.exe", error, + start_error); + } + CloseHandle(child.hThread); + process_ = child.hProcess; + stop_event_ = stop.value; + stop.value = nullptr; + HANDLE startup_events[] = {ready.value, process_}; + const DWORD result = WaitForMultipleObjects(2, startup_events, FALSE, 2000); + const DWORD ready_error = + static_cast(InterlockedCompareExchange(&shared->error, 0, 0)); + UnmapViewOfFile(shared); + if (result != WAIT_OBJECT_0 || ready_error != ERROR_SUCCESS) { + std::string cleanup; + Stop(cleanup); + return Failed( + "Hide system cursor", error, + ready_error == ERROR_IO_PENDING ? ERROR_TIMEOUT : ready_error); + } + return true; +} + +bool PrivacyCursorGuard::RestoreAfterFailure(std::string& error) { + PrivacyCursorApi cursor; + if (!cursor.Initialize() || !cursor.RestoreAndClose()) { + restore_pending_ = true; + return Failed("Restore system cursor after helper failure", error); + } + restore_pending_ = false; + return true; +} + +bool PrivacyCursorGuard::Healthy(std::string& error) { + if (process_ && WaitForSingleObject(process_, 0) == WAIT_TIMEOUT) return true; + if (restore_pending_ || (process_ && !helper_failure_reported_)) + RestoreAfterFailure(error); + helper_failure_reported_ = true; + error = "System cursor helper exited; remote operation paused"; + return false; +} + +bool PrivacyCursorGuard::Stop(std::string& error) { + if (!process_) return !restore_pending_ || RestoreAfterFailure(error); + if (!SetEvent(stop_event_)) return Failed("Stop cursor helper", error); + if (WaitForSingleObject(process_, 2000) != WAIT_OBJECT_0) { + return Failed("Wait for cursor helper restoration", error, ERROR_TIMEOUT); + } + DWORD code = ERROR_GEN_FAILURE; + GetExitCodeProcess(process_, &code); + CloseHandle(process_); + process_ = nullptr; + CloseHandle(stop_event_); + stop_event_ = nullptr; + if (code != ERROR_SUCCESS) return RestoreAfterFailure(error); + return true; +} + +} // namespace crossdesk diff --git a/apps/desktop/src/platform/windows/privacy/privacy_cursor_guard.h b/apps/desktop/src/platform/windows/privacy/privacy_cursor_guard.h new file mode 100644 index 00000000..dea99e7d --- /dev/null +++ b/apps/desktop/src/platform/windows/privacy/privacy_cursor_guard.h @@ -0,0 +1,39 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-09-10 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _PRIVACY_CURSOR_GUARD_H_ +#define _PRIVACY_CURSOR_GUARD_H_ + +#include + +#include + +namespace crossdesk { + +// Called only on the privacy message thread. The child owns cursor suppression +// and restores it when either this guard stops it or its parent process exits. +class PrivacyCursorGuard { + public: + PrivacyCursorGuard() = default; + PrivacyCursorGuard(const PrivacyCursorGuard&) = delete; + PrivacyCursorGuard& operator=(const PrivacyCursorGuard&) = delete; + ~PrivacyCursorGuard(); + bool Start(std::string& error); + bool Healthy(std::string& error); + bool Stop(std::string& error); + bool stopped() const { return !process_ && !restore_pending_; } + + private: + bool RestoreAfterFailure(std::string& error); + HANDLE process_ = nullptr; + HANDLE stop_event_ = nullptr; + bool restore_pending_ = false; + bool helper_failure_reported_ = false; +}; + +} // namespace crossdesk + +#endif // _PRIVACY_CURSOR_GUARD_H_ diff --git a/apps/desktop/src/platform/windows/privacy/privacy_input_guard.cpp b/apps/desktop/src/platform/windows/privacy/privacy_input_guard.cpp new file mode 100644 index 00000000..37335450 --- /dev/null +++ b/apps/desktop/src/platform/windows/privacy/privacy_input_guard.cpp @@ -0,0 +1,304 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-09-10 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#include "privacy_input_guard.h" + +#include "rd_log.h" +#include "windows_input_marker.h" + +namespace crossdesk { + +PrivacyInputGuard::~PrivacyInputGuard() { Stop(); } + +void PrivacyInputGuard::Fault(const std::string& operation, DWORD code) { + { + std::lock_guard lock(error_mutex_); + error_ = operation + " (Windows error " + std::to_string(code) + ")"; + LOG_ERROR("Privacy input: {}", error_); + } + healthy_.store(false); + // Wake the owner's message loop; recovery is never performed in a hook. + PostMessageW(owner_, WM_NULL, 0, 0); +} + +bool PrivacyInputGuard::Start(HWND owner, UINT exit_message, + std::string& error) { + if (thread_.joinable()) { + error = "Privacy input thread is already running"; + return false; + } + owner_ = owner; + exit_message_ = exit_message; + healthy_.store(false); + { + std::lock_guard lock(error_mutex_); + error_.clear(); + } + stop_ = CreateEventW(nullptr, TRUE, FALSE, nullptr); + ready_ = CreateEventW(nullptr, TRUE, FALSE, nullptr); + if (!stop_ || !ready_) { + Fault("Create privacy input events failed", GetLastError()); + Healthy(error); + Stop(); + return false; + } + try { + thread_ = std::thread([this] { Run(); }); + } catch (const std::exception& e) { + error = std::string("Start privacy input thread failed: ") + e.what(); + Stop(); + return false; + } + if (WaitForSingleObject(ready_, 2000) != WAIT_OBJECT_0) { + error = "Privacy input thread startup timed out"; + Stop(); + return false; + } + if (!Healthy(error)) { + Stop(); + return false; + } + return true; +} + +void PrivacyInputGuard::Stop() { + if (stop_) SetEvent(stop_); + if (thread_.joinable()) thread_.join(); + if (stop_) CloseHandle(stop_); + if (ready_) CloseHandle(ready_); + stop_ = ready_ = nullptr; + owner_ = nullptr; +} + +bool PrivacyInputGuard::Healthy(std::string& error) const { + if (healthy_.load() && running_.load() && + GetTickCount64() - last_pump_.load() < 1500) + return true; + std::lock_guard lock(error_mutex_); + error = error_.empty() ? "Privacy input message thread stopped responding" + : error_; + return false; +} + +bool PrivacyInputGuard::Renew(HHOOK& hook, int kind, HOOKPROC procedure) { + HHOOK replacement = + SetWindowsHookExW(kind, procedure, GetModuleHandleW(nullptr), 0); + if (!replacement) { + Fault("Renew privacy input hook failed", GetLastError()); + return false; + } + HHOOK previous = hook; + hook = replacement; + if (UnhookWindowsHookEx(previous)) return true; + const DWORD code = GetLastError(); + if (code != ERROR_INVALID_HOOK_HANDLE) retired_.push_back(previous); + Fault("Privacy input hook was lost or could not be retired", code); + return false; +} + +bool PrivacyInputGuard::PumpMessages() { + MSG message{}; + while (PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE)) { + if (message.message == WM_QUIT) return false; + TranslateMessage(&message); + DispatchMessageW(&message); + } + last_pump_.store(GetTickCount64()); + return WaitForSingleObject(stop_, 0) == WAIT_TIMEOUT; +} + +bool PrivacyInputGuard::ReleasePressedInputs() { + // Both hooks must already be installed: otherwise a new local press can + // slip between this snapshot and the transition to blocked input. + std::array pressed{}; + for (int key = 1; key < 256; ++key) + pressed[key] = (GetAsyncKeyState(key) & 0x8000) != 0; + // Keep held modifiers available to the physical emergency-exit chord. + // Our injected releases intentionally do not clear this hook-side state. + physical_keys_ = pressed; + + std::vector releases; + for (int key = 1; key < 256; ++key) { + if (!pressed[key]) continue; + // Generic modifier VKs alias their left/right variants. Release each + // side once; use the generic VK only if Windows reports no specific side. + if ((key == VK_SHIFT && (pressed[VK_LSHIFT] || pressed[VK_RSHIFT])) || + (key == VK_CONTROL && (pressed[VK_LCONTROL] || pressed[VK_RCONTROL])) || + (key == VK_MENU && (pressed[VK_LMENU] || pressed[VK_RMENU]))) + continue; + INPUT input{}; + input.type = INPUT_MOUSE; + input.mi.dwExtraInfo = kInjectedMouseInputMarker; + switch (key) { + // GetAsyncKeyState reports physical mouse buttons, as do these flags, + // including when Windows swaps the primary and secondary buttons. + case VK_LBUTTON: + input.mi.dwFlags = MOUSEEVENTF_LEFTUP; + break; + case VK_RBUTTON: + input.mi.dwFlags = MOUSEEVENTF_RIGHTUP; + break; + case VK_MBUTTON: + input.mi.dwFlags = MOUSEEVENTF_MIDDLEUP; + break; + case VK_XBUTTON1: + case VK_XBUTTON2: + input.mi.dwFlags = MOUSEEVENTF_XUP; + input.mi.mouseData = key == VK_XBUTTON1 ? XBUTTON1 : XBUTTON2; + break; + default: { + input = {}; + input.type = INPUT_KEYBOARD; + input.ki.wVk = static_cast(key); + const UINT scan = MapVirtualKeyW(key, MAPVK_VK_TO_VSC_EX); + input.ki.wScan = static_cast(scan & 0xff); + input.ki.dwFlags = KEYEVENTF_KEYUP; + if ((scan & 0xff00) == 0xe000) + input.ki.dwFlags |= KEYEVENTF_EXTENDEDKEY; + input.ki.dwExtraInfo = kInjectedKeyboardInputMarker; + break; + } + } + LOG_INFO("Privacy input handoff: releasing held input vk=0x{:02X}", key); + releases.push_back(input); + } + if (!releases.empty()) { + const UINT count = static_cast(releases.size()); + SetLastError(ERROR_SUCCESS); + const UINT sent = SendInput(count, releases.data(), sizeof(INPUT)); + if (sent != count) { + Fault("Release held privacy inputs failed (sent " + std::to_string(sent) + + "/" + std::to_string(count) + ")", + GetLastError()); + return false; + } + } + + // SendInput acknowledges insertion, not delivery. Pump this hook thread + // until the tagged releases have passed through and Windows reports no + // pressed inputs. The caller cannot publish input_blocked/ready earlier. + const auto deadline = GetTickCount64() + 1000; + for (;;) { + if (!PumpMessages()) { + Fault("Privacy input handoff cancelled", ERROR_CANCELLED); + return false; + } + int held = 0; + for (int key = 1; key < 256; ++key) { + if (GetAsyncKeyState(key) & 0x8000) { + held = key; + break; + } + } + if (!held) { + LOG_INFO("Privacy input handoff complete: released {} held inputs", + releases.size()); + return true; + } + if (GetTickCount64() >= deadline) { + Fault("Privacy input release timed out, still pressed vk=" + + std::to_string(held), + ERROR_TIMEOUT); + return false; + } + if (MsgWaitForMultipleObjectsEx(1, &stop_, 10, QS_ALLINPUT, + MWMO_INPUTAVAILABLE) == WAIT_FAILED) { + Fault("Wait for privacy input releases failed", GetLastError()); + return false; + } + } +} + +void PrivacyInputGuard::Run() { + current_ = this; + running_.store(true); + try { + keyboard_ = SetWindowsHookExW(WH_KEYBOARD_LL, KeyboardHook, + GetModuleHandleW(nullptr), 0); + if (!keyboard_) + Fault("Install privacy keyboard hook failed", GetLastError()); + if (keyboard_) { + mouse_ = + SetWindowsHookExW(WH_MOUSE_LL, MouseHook, GetModuleHandleW(nullptr), 0); + if (!mouse_) Fault("Install privacy mouse hook failed", GetLastError()); + } + healthy_.store(keyboard_ && mouse_ && ReleasePressedInputs()); + last_pump_.store(GetTickCount64()); + SetEvent(ready_); + auto last_refresh = GetTickCount64(); + LOG_INFO("Privacy input message thread started: thread={}", + GetCurrentThreadId()); + while (keyboard_ && mouse_ && + WaitForSingleObject(stop_, 0) == WAIT_TIMEOUT) { + if (!PumpMessages()) break; + const auto now = GetTickCount64(); + if (healthy_.load() && now - last_refresh >= 1000) { + last_refresh = now; + // Install replacements before retiring old hooks, with no input gap. + if (Renew(keyboard_, WH_KEYBOARD_LL, KeyboardHook)) + Renew(mouse_, WH_MOUSE_LL, MouseHook); + } + if (MsgWaitForMultipleObjectsEx(1, &stop_, 100, QS_ALLINPUT, + MWMO_INPUTAVAILABLE) == WAIT_FAILED) { + Fault("Wait for privacy input messages failed", GetLastError()); + break; + } + } + } catch (const std::exception& e) { + Fault(std::string("Privacy input thread failed: ") + e.what(), + ERROR_GEN_FAILURE); + SetEvent(ready_); + } + healthy_.store(false); + auto unhook = [this](HHOOK hook) { + if (hook && !UnhookWindowsHookEx(hook) && + GetLastError() != ERROR_INVALID_HOOK_HANDLE) + Fault("Release privacy input hook failed", GetLastError()); + }; + unhook(keyboard_); + unhook(mouse_); + for (auto hook : retired_) unhook(hook); + keyboard_ = mouse_ = nullptr; + retired_.clear(); + physical_keys_.fill(false); + current_ = nullptr; + running_.store(false); + PostMessageW(owner_, WM_NULL, 0, 0); + // Windows also removes hooks installed by a thread when that thread exits. + LOG_INFO("Privacy input message thread stopped"); +} + +LRESULT CALLBACK PrivacyInputGuard::KeyboardHook(int code, WPARAM wp, + LPARAM lp) { + auto self = current_; + if (code < 0 || !self) return CallNextHookEx(nullptr, code, wp, lp); + const auto& key = *reinterpret_cast(lp); + if ((key.flags & LLKHF_INJECTED) && + key.dwExtraInfo == kInjectedKeyboardInputMarker) + return CallNextHookEx(nullptr, code, wp, lp); + if (!(key.flags & LLKHF_INJECTED) && key.vkCode < 256) { + self->physical_keys_[key.vkCode] = !(key.flags & LLKHF_UP); + const auto& keys = self->physical_keys_; + if (key.vkCode == VK_F12 && !(key.flags & LLKHF_UP) && + (keys[VK_LCONTROL] || keys[VK_RCONTROL]) && + (keys[VK_LMENU] || keys[VK_RMENU]) && + (keys[VK_LSHIFT] || keys[VK_RSHIFT])) + PostMessageW(self->owner_, self->exit_message_, 0, 0); + } + return 1; +} + +LRESULT CALLBACK PrivacyInputGuard::MouseHook(int code, WPARAM wp, LPARAM lp) { + auto self = current_; + if (code < 0 || !self) return CallNextHookEx(nullptr, code, wp, lp); + const auto& mouse = *reinterpret_cast(lp); + if ((mouse.flags & LLMHF_INJECTED) && + mouse.dwExtraInfo == kInjectedMouseInputMarker) + return CallNextHookEx(nullptr, code, wp, lp); + return 1; +} + +} // namespace crossdesk diff --git a/apps/desktop/src/platform/windows/privacy/privacy_input_guard.h b/apps/desktop/src/platform/windows/privacy/privacy_input_guard.h new file mode 100644 index 00000000..7912db1b --- /dev/null +++ b/apps/desktop/src/platform/windows/privacy/privacy_input_guard.h @@ -0,0 +1,59 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-09-10 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _PRIVACY_INPUT_GUARD_H_ +#define _PRIVACY_INPUT_GUARD_H_ + +#include + +#include +#include +#include +#include +#include +#include + +namespace crossdesk { + +// The owner controls lifecycle; the dedicated message thread owns LL hooks. +// Rendering, capture verification and session queries never run on this thread. +class PrivacyInputGuard { + public: + ~PrivacyInputGuard(); + bool Start(HWND owner, UINT exit_message, std::string& error); + void Stop(); + bool Healthy(std::string& error) const; + bool stopped() const { return !thread_.joinable(); } + + private: + void Run(); + bool ReleasePressedInputs(); + bool PumpMessages(); + void Fault(const std::string& operation, DWORD code); + bool Renew(HHOOK& hook, int kind, HOOKPROC procedure); + static LRESULT CALLBACK KeyboardHook(int code, WPARAM wp, LPARAM lp); + static LRESULT CALLBACK MouseHook(int code, WPARAM wp, LPARAM lp); + + std::thread thread_; + HANDLE stop_ = nullptr; + HANDLE ready_ = nullptr; + HWND owner_ = nullptr; + UINT exit_message_ = 0; + std::atomic healthy_{false}; + std::atomic running_{false}; + std::atomic last_pump_{0}; + mutable std::mutex error_mutex_; + std::string error_; + HHOOK keyboard_ = nullptr; + HHOOK mouse_ = nullptr; + std::vector retired_; + std::array physical_keys_{}; + inline static thread_local PrivacyInputGuard* current_ = nullptr; +}; + +} // namespace crossdesk + +#endif // _PRIVACY_INPUT_GUARD_H_ diff --git a/apps/desktop/src/platform/windows/privacy/privacy_probe_renderer.cpp b/apps/desktop/src/platform/windows/privacy/privacy_probe_renderer.cpp new file mode 100644 index 00000000..f1575303 --- /dev/null +++ b/apps/desktop/src/platform/windows/privacy/privacy_probe_renderer.cpp @@ -0,0 +1,142 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-09-11 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#include "privacy_probe_renderer.h" + +#include + +#include "privacy_probe_pattern.h" + +namespace crossdesk { +namespace { +struct Canvas { + HDC dc = CreateCompatibleDC(nullptr); + HFONT font = nullptr; + HGDIOBJ old_font = nullptr; + HBITMAP bitmap = nullptr; + HGDIOBJ old_bitmap = nullptr; + ~Canvas() { + if (old_bitmap) SelectObject(dc, old_bitmap); + if (old_font) SelectObject(dc, old_font); + if (bitmap) DeleteObject(bitmap); + if (font) DeleteObject(font); + if (dc) DeleteDC(dc); + } +}; +bool Failed(const char* operation, std::string& error) { + error = std::string(operation) + " (Windows error " + + std::to_string(GetLastError()) + ")"; + return false; +} +} // namespace + +bool RenderPrivacyProbe(const std::string& title, UINT dpi, int maximum_width, + int maximum_height, uint32_t value, + PrivacyProbeImage& image, std::string& error) { + using Pattern = PrivacyProbePattern; + const int length = + MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, title.data(), + static_cast(title.size()), nullptr, 0); + if (length <= 0) return Failed("Decode privacy verification text", error); + std::wstring text(length, L'\0'); + if (!MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, title.data(), + static_cast(title.size()), text.data(), length)) + return Failed("Decode privacy verification text", error); + const int available_text_width = + maximum_width - Pattern::kTextLeft - Pattern::kPadding; + if (available_text_width < 80 || + maximum_height < Pattern::kSide + 2 * Pattern::kPadding) { + error = "Display is too small for the privacy verification panel"; + return false; + } + Canvas canvas; + if (!canvas.dc) + return Failed("Create privacy verification drawing context", error); + canvas.font = CreateFontW( + -MulDiv(28, static_cast(dpi), 96), 0, 0, 0, FW_SEMIBOLD, FALSE, + FALSE, FALSE, DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, + ANTIALIASED_QUALITY, DEFAULT_PITCH, L"Microsoft YaHei UI"); + if (!canvas.font) return Failed("Create privacy verification font", error); + canvas.old_font = SelectObject(canvas.dc, canvas.font); + if (!canvas.old_font || canvas.old_font == HGDI_ERROR) { + canvas.old_font = nullptr; + return Failed("Select privacy verification font", error); + } + SIZE extent{}; + if (!GetTextExtentPoint32W(canvas.dc, text.data(), length, &extent)) + return Failed("Measure privacy verification text", error); + const int text_width = + (std::min)(available_text_width, + (std::min)(static_cast(extent.cx) + 4, + MulDiv(520, static_cast(dpi), 96))); + RECT text_bounds{0, 0, text_width, 0}; + constexpr UINT text_flags = DT_LEFT | DT_WORDBREAK | DT_NOPREFIX; + if (!DrawTextW(canvas.dc, text.data(), length, &text_bounds, + text_flags | DT_CALCRECT)) + return Failed("Lay out privacy verification text", error); + const int width = Pattern::kTextLeft + text_width + Pattern::kPadding; + const int height = + (std::max)(Pattern::kSide, static_cast(text_bounds.bottom)) + + 2 * Pattern::kPadding; + if (height > maximum_height || text_bounds.right > text_width) { + error = + "Display is too small for localized privacy verification text at this " + "DPI"; + return false; + } + BITMAPINFO info{}; + info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + info.bmiHeader.biWidth = width; + info.bmiHeader.biHeight = -height; + info.bmiHeader.biPlanes = 1; + info.bmiHeader.biBitCount = 32; + info.bmiHeader.biCompression = BI_RGB; + void* bits = nullptr; + canvas.bitmap = + CreateDIBSection(canvas.dc, &info, DIB_RGB_COLORS, &bits, nullptr, 0); + if (!canvas.bitmap || !bits) + return Failed("Create privacy verification image", error); + canvas.old_bitmap = SelectObject(canvas.dc, canvas.bitmap); + if (!canvas.old_bitmap || canvas.old_bitmap == HGDI_ERROR) { + canvas.old_bitmap = nullptr; + return Failed("Select privacy verification image", error); + } + auto pixels = static_cast(bits); + std::fill(pixels, pixels + static_cast(width) * height, 0xFFF4F6F9u); + for (int y = 0; y < height; ++y) + for (int x = 0; x < width; ++x) + if (x == 0 || y == 0 || x == width - 1 || y == height - 1) + pixels[static_cast(y) * width + x] = 0xFFD0D6E0u; + SetBkMode(canvas.dc, TRANSPARENT); + SetTextColor(canvas.dc, RGB(34, 46, 64)); + RECT text_rect{Pattern::kTextLeft, (height - text_bounds.bottom) / 2, + width - Pattern::kPadding, (height + text_bounds.bottom) / 2}; + // Explicit clipping makes font fallback/overhang unable to reach the code. + IntersectClipRect(canvas.dc, text_rect.left, text_rect.top, text_rect.right, + text_rect.bottom); + if (!DrawTextW(canvas.dc, text.data(), length, &text_rect, text_flags)) + return Failed("Draw privacy verification text", error); + if (!GdiFlush()) return Failed("Flush privacy verification text", error); + for (size_t index = 0; index < static_cast(width) * height; ++index) + pixels[index] |= 0xFF000000u; + for (int row = 0; row < Pattern::kRows; ++row) + for (int column = 0; column < Pattern::kColumns; ++column) { + const uint32_t color = + Pattern::White(value, column, row) ? 0xFFFFFFFFu : 0xFF000000u; + for (int y = 0; y < Pattern::kCell; ++y) + for (int x = 0; x < Pattern::kCell; ++x) + pixels[static_cast(Pattern::kPadding + row * Pattern::kCell + + y) * + width + + Pattern::kPadding + column * Pattern::kCell + x] = color; + } + image.width = width; + image.height = height; + image.pixels.assign(pixels, pixels + static_cast(width) * height); + return true; +} + +} // namespace crossdesk diff --git a/apps/desktop/src/platform/windows/privacy/privacy_probe_renderer.h b/apps/desktop/src/platform/windows/privacy/privacy_probe_renderer.h new file mode 100644 index 00000000..46f9706e --- /dev/null +++ b/apps/desktop/src/platform/windows/privacy/privacy_probe_renderer.h @@ -0,0 +1,32 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-09-11 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _PRIVACY_PROBE_RENDERER_H_ +#define _PRIVACY_PROBE_RENDERER_H_ + +#include + +#include +#include +#include + +namespace crossdesk { + +struct PrivacyProbeImage { + int width = 0; + int height = 0; + std::vector pixels; +}; + +// Produces an opaque BGRA panel. The localized text and the machine-readable +// square occupy disjoint rectangles. Caller runs on the owning window thread. +bool RenderPrivacyProbe(const std::string& title, UINT dpi, int maximum_width, + int maximum_height, uint32_t value, + PrivacyProbeImage& image, std::string& error); + +} // namespace crossdesk + +#endif // _PRIVACY_PROBE_RENDERER_H_ diff --git a/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_dxgi.cpp b/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_dxgi.cpp index a7061cbf..c896d115 100644 --- a/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_dxgi.cpp +++ b/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_dxgi.cpp @@ -1,11 +1,12 @@ #include "screen_capturer_dxgi.h" +#include + #include #include #include #include -#include #include "libyuv.h" #include "rd_log.h" @@ -218,11 +219,13 @@ void ScreenCapturerDxgi::EnumerateDisplays() { mi.rcMonitor.left, mi.rcMonitor.top, mi.rcMonitor.right, mi.rcMonitor.bottom); // primary first - if (is_primary) + if (is_primary) { display_info_list_.insert(display_info_list_.begin(), info); - else + outputs_.insert(outputs_.begin(), output); + } else { display_info_list_.push_back(info); - outputs_.push_back(output); + outputs_.push_back(output); + } } } } @@ -246,6 +249,11 @@ bool ScreenCapturerDxgi::CreateDuplicationForMonitor(int monitor_index) { } staging_.Reset(); + DXGI_OUTDUPL_DESC desc{}; + duplication_->GetDesc(&desc); + rotation_ = desc.Rotation; + LOG_INFO("DXGI: duplication ready, monitor={}, rotation={}", monitor_index, + static_cast(rotation_)); return true; } @@ -276,6 +284,7 @@ bool ScreenCapturerDxgi::RecreateDuplicationForCurrentMonitor() { } void ScreenCapturerDxgi::ReleaseDuplication() { + ++duplication_generation_; staging_.Reset(); if (duplication_) { duplication_->ReleaseFrame(); @@ -284,16 +293,27 @@ void ScreenCapturerDxgi::ReleaseDuplication() { } void ScreenCapturerDxgi::CaptureLoop() { - const int timeout_ms = 33; + const int timeout_ms = (std::max)(1, 1000 / (std::max)(1, fps_)); + bool cached_frame_valid = false; + int cached_monitor = -1; + uint64_t cached_generation = 0; + std::vector rotated_frame; auto last_duplication_retry = std::chrono::steady_clock::now() - std::chrono::milliseconds(1000); while (running_) { if (paused_) { + cached_frame_valid = false; std::this_thread::sleep_for(std::chrono::milliseconds(10)); continue; } + // Duplication/staging textures belong to one monitor. Do not release or + // replace them in SwitchTo while AcquireNextFrame/Map is using them. + std::unique_lock capture_lock(switch_mutex_); + if (paused_ || !running_) continue; if (!duplication_) { + cached_frame_valid = false; + capture_lock.unlock(); const auto now = std::chrono::steady_clock::now(); if (now - last_duplication_retry >= std::chrono::milliseconds(500)) { last_duplication_retry = now; @@ -303,15 +323,32 @@ void ScreenCapturerDxgi::CaptureLoop() { continue; } + const int frame_monitor = monitor_index_.load(); DXGI_OUTDUPL_FRAME_INFO frame_info{}; Microsoft::WRL::ComPtr desktop_resource; HRESULT hr = duplication_->AcquireNextFrame( timeout_ms, &frame_info, desktop_resource.GetAddressOf()); if (hr == DXGI_ERROR_WAIT_TIMEOUT) { + // DXGI explicitly reports an unchanged desktop. Re-emit only a frame + // successfully acquired for this monitor, so a static screen still has + // a delivery heartbeat after temporary privacy probes are removed. + if (cached_frame_valid && cached_generation == duplication_generation_ && + cached_monitor == monitor_index_.load() && callback_ && nv12_frame_) { + const auto stream_id = MakeDisplayStreamId(cached_monitor); + capture_lock.unlock(); + callback_(nv12_frame_, nv12_width_ * nv12_height_ * 3 / 2, nv12_width_, + nv12_height_, stream_id.c_str(), nullptr); + } continue; } + // Never replay a cached image after an acquisition/conversion failure or + // a backend rebuild. Normal error/reset handling must pause privacy. + cached_frame_valid = false; if (FAILED(hr)) { LOG_ERROR("DXGI: AcquireNextFrame failed, hr={}", (int)hr); + capture_lock.unlock(); + if (callback_) + callback_(nullptr, ScreenCapturer::kBackendReset, 0, 0, "", nullptr); RecreateDuplicationForCurrentMonitor(); continue; } @@ -356,9 +393,42 @@ void ScreenCapturerDxgi::CaptureLoop() { continue; } + auto pixels = static_cast(mapped.pData); + int stride = static_cast(mapped.RowPitch); int logical_width = static_cast(src_desc.Width); + int logical_height = static_cast(src_desc.Height); + libyuv::RotationMode rotation = libyuv::kRotate0; + switch (rotation_) { + case DXGI_MODE_ROTATION_ROTATE90: + rotation = libyuv::kRotate90; + break; + case DXGI_MODE_ROTATION_ROTATE180: + rotation = libyuv::kRotate180; + break; + case DXGI_MODE_ROTATION_ROTATE270: + rotation = libyuv::kRotate270; + break; + default: + break; + } + if (rotation != libyuv::kRotate0) { + const bool swap_axes = rotation != libyuv::kRotate180; + const int rotated_width = swap_axes ? logical_height : logical_width; + rotated_frame.resize(static_cast(logical_width) * logical_height * + 4); + if (libyuv::ARGBRotate(pixels, stride, rotated_frame.data(), + rotated_width * 4, logical_width, logical_height, + rotation) != 0) { + d3d_context_->Unmap(staging_.Get(), 0); + duplication_->ReleaseFrame(); + continue; + } + pixels = rotated_frame.data(); + stride = rotated_width * 4; + if (swap_axes) std::swap(logical_width, logical_height); + } int even_width = logical_width & ~1; - int even_height = static_cast(src_desc.Height) & ~1; + int even_height = logical_height & ~1; if (even_width <= 0 || even_height <= 0) { d3d_context_->Unmap(staging_.Get(), 0); duplication_->ReleaseFrame(); @@ -374,25 +444,29 @@ void ScreenCapturerDxgi::CaptureLoop() { nv12_height_ = even_height; } - libyuv::ARGBToNV12(static_cast(mapped.pData), - static_cast(mapped.RowPitch), nv12_frame_, - even_width, nv12_frame_ + even_width * even_height, - even_width, even_width, even_height); + const int converted = + libyuv::ARGBToNV12(pixels, stride, nv12_frame_, even_width, + nv12_frame_ + even_width * even_height, even_width, + even_width, even_height); - if (callback_) { - int idx = monitor_index_.load(); + cached_generation = duplication_generation_; + d3d_context_->Unmap(staging_.Get(), 0); + duplication_->ReleaseFrame(); + capture_lock.unlock(); + + if (converted == 0 && frame_monitor == monitor_index_.load() && callback_) { + int idx = frame_monitor; if (idx >= 0 && idx < static_cast(display_info_list_.size())) { const std::string stream_id = MakeDisplayStreamId(idx); callback_(nv12_frame_, nv12_size, even_width, even_height, stream_id.c_str(), nullptr); + cached_monitor = idx; + cached_frame_valid = true; } else { LOG_ERROR("DXGI: CaptureLoop invalid monitor_index {} (list size {})", idx, display_info_list_.size()); } } - - d3d_context_->Unmap(staging_.Get(), 0); - duplication_->ReleaseFrame(); } } diff --git a/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_dxgi.h b/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_dxgi.h index 6db23c1e..1bce1103 100644 --- a/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_dxgi.h +++ b/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_dxgi.h @@ -63,6 +63,8 @@ class ScreenCapturerDxgi : public ScreenCapturer { Microsoft::WRL::ComPtr d3d_context_; Microsoft::WRL::ComPtr duplication_; Microsoft::WRL::ComPtr staging_; + DXGI_MODE_ROTATION rotation_ = DXGI_MODE_ROTATION_IDENTITY; + uint64_t duplication_generation_ = 0; std::atomic running_{false}; std::atomic paused_{false}; diff --git a/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_wgc.cpp b/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_wgc.cpp index 71d966a2..89426830 100644 --- a/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_wgc.cpp +++ b/apps/desktop/src/platform/windows/screen_capturer/screen_capturer_wgc.cpp @@ -351,6 +351,10 @@ int ScreenCapturerWgc::ResetToInitialMonitor() { } void ScreenCapturerWgc::OnFrame(const WgcSession::wgc_session_frame& frame, int id) { + if (!frame.data && frame.width == 0 && frame.height == 0) { + if (on_data_) on_data_(nullptr, ScreenCapturer::kBackendReset, 0, 0, "", nullptr); + return; + } if (!running_ || !on_data_) { return; } 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 0f503e86..9d1eb44c 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 @@ -127,6 +127,13 @@ class WgcPluginCapturer final : public ScreenCapturer { DestroyFn destroy_fn_ = nullptr; }; +const char* CaptureBackendName(const ScreenCapturer* capturer) { + if (dynamic_cast(capturer)) return "DXGI"; + if (dynamic_cast(capturer)) return "WGC"; + if (dynamic_cast(capturer)) return "GDI"; + return "unavailable"; +} + std::string BuildSecureCaptureCommand(int left, int top, int width, int height, bool show_cursor, const std::string& stage, @@ -330,7 +337,13 @@ bool QuerySecureDesktopHelperFrame( ScreenCapturerWin::ScreenCapturerWin() {} ScreenCapturerWin::~ScreenCapturerWin() { Destroy(); } +void ScreenCapturerWin::NotifyPrivacyCapture(bool running) { + if (privacy_) + privacy_->CaptureChanged(CaptureBackendName(impl_.get()), running); +} + int ScreenCapturerWin::Init(const int fps, cb_desktop_data cb) { + NotifyPrivacyCapture(false); fps_ = fps; cb_orig_ = cb; native_output_logged_.store(false, std::memory_order_relaxed); @@ -344,12 +357,17 @@ int ScreenCapturerWin::Init(const int fps, cb_desktop_data cb) { cb_ = [this](unsigned char* data, int size, int w, int h, const char* reported_stream_id, const MiniRtcNativeVideoFrame* native_frame) { + if (size == ScreenCapturer::kBackendReset) { + if (privacy_) privacy_->CaptureInterrupted(); + return; + } if (secure_desktop_capture_active_.load(std::memory_order_relaxed)) { return; } const char* raw_stream_id = reported_stream_id ? reported_stream_id : ""; std::string mapped_stream_id; + DisplayInfo privacy_display{"", 0, 0, 0, 0}; { std::lock_guard lock(alias_mutex_); auto it = stream_id_alias_.find(raw_stream_id); @@ -363,6 +381,21 @@ int ScreenCapturerWin::Init(const int fps, cb_desktop_data cb) { mapped_stream_id = ResolveDisplayStreamId( mapped_stream_id.c_str(), canonical_displays_.size(), monitor_index_.load(std::memory_order_relaxed)); + for (size_t index = 0; index < canonical_displays_.size(); ++index) { + if (mapped_stream_id == MakeDisplayStreamId(index)) { + privacy_display = canonical_displays_[index]; + break; + } + } + } + if (privacy_) { + if (privacy_->Engaged() && !IsWindowsPrivacyDesktopAvailable()) { + privacy_->Fail("Secure or unavailable desktop; privacy cannot cover system security UI. Remote operation paused."); + return; + } + if (!privacy_->ObserveFrame(data, size > 0 ? static_cast(size) : 0, + w, h, privacy_display.left, privacy_display.top, + privacy_display.width, privacy_display.height)) return; } if (mapped_stream_id.empty()) { if (!invalid_stream_id_logged_.exchange(true, @@ -393,42 +426,23 @@ int ScreenCapturerWin::Init(const int fps, cb_desktop_data cb) { native_frame); }; + impl_.reset(); int ret = -1; - - impl_ = WgcPluginCapturer::Create(); - impl_is_wgc_plugin_ = (impl_ != nullptr); - ret = impl_ ? impl_->Init(fps_, cb_) : -1; - if (ret == 0) { - LOG_INFO("Windows capturer: using WGC plugin"); - BuildCanonicalFromImpl(); - monitor_index_.store(0, std::memory_order_relaxed); - initial_monitor_index_ = 0; - return 0; - } - - LOG_WARN("Windows capturer: WGC plugin init failed (ret={}), try DXGI", ret); - impl_.reset(); - impl_is_wgc_plugin_ = false; - - impl_ = std::make_unique(); - impl_is_wgc_plugin_ = false; - ret = impl_->Init(fps_, cb_); - if (ret == 0) { - LOG_INFO("Windows capturer: using DXGI Desktop Duplication"); - BuildCanonicalFromImpl(); - monitor_index_.store(0, std::memory_order_relaxed); - initial_monitor_index_ = 0; - return 0; - } - - LOG_WARN("Windows capturer: DXGI init failed (ret={}), fallback to GDI", ret); - impl_.reset(); - - impl_ = std::make_unique(); - impl_is_wgc_plugin_ = false; - ret = impl_->Init(fps_, cb_); - if (ret == 0) { - LOG_INFO("Windows capturer: using GDI BitBlt"); + auto try_init = [&](std::unique_ptr candidate) { + ret = candidate ? candidate->Init(fps_, cb_) : -1; + if (ret != 0) { + LOG_WARN("Windows capturer: {} init failed (ret={})", + CaptureBackendName(candidate.get()), ret); + return false; + } + impl_ = std::move(candidate); + return true; + }; + // Short-circuiting also keeps the unused WGC plugin unloaded. + if (try_init(std::make_unique()) || + try_init(WgcPluginCapturer::Create()) || + try_init(std::make_unique())) { + LOG_INFO("Windows capturer: using {}", CaptureBackendName(impl_.get())); BuildCanonicalFromImpl(); monitor_index_.store(0, std::memory_order_relaxed); initial_monitor_index_ = 0; @@ -436,7 +450,6 @@ int ScreenCapturerWin::Init(const int fps, cb_desktop_data cb) { } LOG_ERROR("Windows capturer: all implementations failed, ret={}", ret); - impl_.reset(); return -1; } @@ -446,7 +459,6 @@ int ScreenCapturerWin::Destroy() { if (impl_) { impl_->Destroy(); impl_.reset(); - impl_is_wgc_plugin_ = false; } { std::lock_guard lock(alias_mutex_); @@ -459,8 +471,15 @@ int ScreenCapturerWin::Destroy() { void ScreenCapturerWin::EmitCapturedFrame( unsigned char* data, int size, int width, int height, - const char* stream_id, const MiniRtcNativeVideoFrame* native_frame) { - if (!cb_orig_) { + const char* stream_id, const MiniRtcNativeVideoFrame* native_frame, + bool from_secure_desktop) { + // A helper IPC already in flight when enable began must not bypass the + // verified ordinary-desktop callback when it completes later. + if (from_secure_desktop && privacy_ && privacy_->Engaged()) { + privacy_->Fail("Secure desktop helper frame arrived during privacy; remote operation paused"); + return; + } + if (!cb_orig_ || (privacy_ && !privacy_->RemoteAllowed())) { return; } @@ -499,11 +518,43 @@ void ScreenCapturerWin::EmitCapturedFrame( owned_frame->Release(); } +void ScreenCapturerWin::RestoreMonitor(int monitor_index) { + RebuildAliasesFromImpl(); + if (monitor_index > 0 && impl_->SwitchTo(monitor_index) != 0) { + monitor_index_.store(0, std::memory_order_relaxed); + } +} + +bool ScreenCapturerWin::TryStartBackend( + std::unique_ptr candidate, int monitor_index) { + if (!candidate) { + LOG_WARN("Windows capturer: WGC plugin unavailable"); + return false; + } + const char* name = CaptureBackendName(candidate.get()); + int ret = candidate->Init(fps_, cb_); + if (ret == 0) + ret = candidate->Start(show_cursor_.load(std::memory_order_relaxed)); + if (ret != 0) { + LOG_WARN("Windows capturer: {} initialization/start failed (ret={})", name, + ret); + candidate->Destroy(); + return false; + } + // Commit only after the replacement is running. Failed candidates release + // their resources without replacing the current backend. + impl_ = std::move(candidate); + RestoreMonitor(monitor_index); + LOG_INFO("Windows capturer: started {}", name); + return true; +} + int ScreenCapturerWin::Start(bool show_cursor) { if (!impl_) return -1; if (running_.load(std::memory_order_relaxed)) { return 0; } + NotifyPrivacyCapture(false); show_cursor_.store(show_cursor, std::memory_order_relaxed); paused_.store(false, std::memory_order_relaxed); @@ -528,37 +579,15 @@ int ScreenCapturerWin::Start(bool show_cursor) { LOG_WARN("Windows capturer: refresh/start failed (ret={}), trying fallback", ret); - auto try_init_start = [&](std::unique_ptr cand) -> bool { - int r = cand->Init(fps_, cb_); - if (r != 0) return false; - int s = cand->Start(show_cursor); - if (s == 0) { - impl_ = std::move(cand); - impl_is_wgc_plugin_ = false; - RebuildAliasesFromImpl(); - if (requested_monitor > 0 && - impl_->SwitchTo(requested_monitor) != 0) { - monitor_index_.store(0, std::memory_order_relaxed); - } - return true; - } - return false; - }; - bool fallback_started = false; - if (impl_is_wgc_plugin_) { - if (try_init_start(std::make_unique())) { - LOG_INFO("Windows capturer: fallback to DXGI"); - fallback_started = true; - } else if (try_init_start(std::make_unique())) { - LOG_INFO("Windows capturer: fallback to GDI"); - fallback_started = true; - } - } else if (dynamic_cast(impl_.get())) { - if (try_init_start(std::make_unique())) { - LOG_INFO("Windows capturer: fallback to GDI"); - fallback_started = true; - } + if (dynamic_cast(impl_.get()) && + TryStartBackend(WgcPluginCapturer::Create(), requested_monitor)) { + fallback_started = true; + } + if (!fallback_started && (dynamic_cast(impl_.get()) || + dynamic_cast(impl_.get()))) { + fallback_started = TryStartBackend(std::make_unique(), + requested_monitor); } if (!fallback_started) { @@ -568,6 +597,7 @@ int ScreenCapturerWin::Start(bool show_cursor) { } running_.store(true, std::memory_order_relaxed); + NotifyPrivacyCapture(true); secure_desktop_capture_active_.store(false, std::memory_order_relaxed); post_secure_desktop_waiting_for_frame_.store(false, std::memory_order_relaxed); @@ -581,6 +611,7 @@ int ScreenCapturerWin::Start(bool show_cursor) { } int ScreenCapturerWin::Stop() { + NotifyPrivacyCapture(false); running_.store(false, std::memory_order_relaxed); secure_desktop_capture_active_.store(false, std::memory_order_relaxed); post_secure_desktop_waiting_for_frame_.store(false, @@ -610,9 +641,12 @@ int ScreenCapturerWin::Resume(int monitor_index) { int ScreenCapturerWin::SwitchTo(int monitor_index) { if (!impl_) return -1; + if (privacy_) privacy_->DisplayChanging(); const int ret = impl_->SwitchTo(monitor_index); if (ret == 0) { monitor_index_.store(monitor_index, std::memory_order_relaxed); + } else if (privacy_) { + privacy_->Fail("Display switch failed; remote operation paused"); } return ret; } @@ -735,53 +769,19 @@ void ScreenCapturerWin::StopSecureCaptureThread() { } bool ScreenCapturerWin::RestartCaptureBackendAfterSecureDesktop() { + NotifyPrivacyCapture(false); if (!impl_ || !running_.load(std::memory_order_relaxed)) { return false; } const bool show_cursor = show_cursor_.load(std::memory_order_relaxed); const int current_monitor = monitor_index_.load(std::memory_order_relaxed); - auto restore_monitor = [&]() { - RebuildAliasesFromImpl(); - if (current_monitor > 0 && impl_->SwitchTo(current_monitor) != 0) { - monitor_index_.store(0, std::memory_order_relaxed); - } - }; - auto try_started_backend = [&](std::unique_ptr cand, - const char* name, - bool is_wgc_plugin) -> bool { - if (!cand) { - return false; - } - const int init_ret = cand->Init(fps_, cb_); - if (init_ret != 0) { - LOG_WARN("Windows capturer: {} init after secure desktop failed (ret={})", - name, init_ret); - return false; - } - const int start_ret = cand->Start(show_cursor); - if (start_ret != 0) { - LOG_WARN( - "Windows capturer: {} start after secure desktop failed (ret={})", - name, start_ret); - cand->Destroy(); - return false; - } - if (impl_) { - impl_->Destroy(); - } - impl_ = std::move(cand); - impl_is_wgc_plugin_ = is_wgc_plugin; - restore_monitor(); - LOG_INFO("Windows capturer: restarted {} after secure desktop", name); - return true; - }; LOG_INFO("Windows capturer: restarting capture backend after secure desktop"); impl_->Stop(); int ret = impl_->Start(show_cursor); if (ret == 0) { - restore_monitor(); + RestoreMonitor(current_monitor); return true; } @@ -795,20 +795,14 @@ bool ScreenCapturerWin::RestartCaptureBackendAfterSecureDesktop() { ret = impl_->Start(show_cursor); } if (ret == 0) { - restore_monitor(); + RestoreMonitor(current_monitor); return true; } - if (impl_is_wgc_plugin_ && - try_started_backend(WgcPluginCapturer::Create(), "WGC plugin", true)) { - return true; - } - if (try_started_backend(std::make_unique(), "DXGI", - false)) { - return true; - } - if (try_started_backend(std::make_unique(), "GDI", - false)) { + if (TryStartBackend(std::make_unique(), + current_monitor) || + TryStartBackend(WgcPluginCapturer::Create(), current_monitor) || + TryStartBackend(std::make_unique(), current_monitor)) { return true; } @@ -1123,6 +1117,14 @@ void ScreenCapturerWin::SecureDesktopCaptureLoop() { std::vector secure_frame; while (running_.load(std::memory_order_relaxed)) { + // A privacy session must NEVER continue using the helper's secure-desktop + // GDI frames. Its HWNDs belong to the ordinary interactive desktop only. + if (privacy_ && privacy_->Engaged()) { + if (!IsWindowsPrivacyDesktopAvailable()) + privacy_->Fail("Secure desktop or lock screen; remote operation paused. Privacy cannot cover Windows security UI."); + std::this_thread::sleep_for(std::chrono::milliseconds(25)); + continue; + } if (paused_.load(std::memory_order_relaxed)) { std::this_thread::sleep_for(std::chrono::milliseconds(10)); continue; @@ -1295,7 +1297,7 @@ void ScreenCapturerWin::SecureDesktopCaptureLoop() { if (frame_delivered && !secure_frame.empty()) { EmitCapturedFrame(secure_frame.data(), static_cast(secure_frame.size()), captured_width, - captured_height, display_name.c_str()); + captured_height, display_name.c_str(), nullptr, true); } if (!frame_delivered && !frame_pending) { 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 69ecf8be..6bdb00af 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 @@ -20,6 +20,7 @@ #include #include "screen_capturer.h" +#include "privacy_controller.h" namespace crossdesk { @@ -43,10 +44,12 @@ class ScreenCapturerWin : public ScreenCapturer { int ResetToInitialMonitor() override; std::vector GetDisplayInfoList() override; + void SetPrivacyController(PrivacyController* privacy) { privacy_ = privacy; } private: std::unique_ptr impl_; - bool impl_is_wgc_plugin_ = false; + PrivacyController* privacy_ = nullptr; + void NotifyPrivacyCapture(bool running); int fps_ = 60; cb_desktop_data cb_; cb_desktop_data cb_orig_; @@ -86,9 +89,13 @@ class ScreenCapturerWin : public ScreenCapturer { void BuildCanonicalFromImpl(); void RebuildAliasesFromImpl(); + void RestoreMonitor(int monitor_index); + bool TryStartBackend(std::unique_ptr candidate, + int monitor_index); void EmitCapturedFrame(unsigned char* data, int size, int width, int height, const char* stream_id, - const MiniRtcNativeVideoFrame* native_frame = nullptr); + const MiniRtcNativeVideoFrame* native_frame = nullptr, + bool from_secure_desktop = false); void StopSecureCaptureThread(); bool RestartCaptureBackendAfterSecureDesktop(); void SecureDesktopCaptureLoop(); diff --git a/apps/desktop/src/platform/windows/screen_capturer/wgc_session_impl.cpp b/apps/desktop/src/platform/windows/screen_capturer/wgc_session_impl.cpp index d73cac1d..77a561d8 100644 --- a/apps/desktop/src/platform/windows/screen_capturer/wgc_session_impl.cpp +++ b/apps/desktop/src/platform/windows/screen_capturer/wgc_session_impl.cpp @@ -339,6 +339,7 @@ void WgcSessionImpl::OnClosed( winrt::Windows::Graphics::Capture::GraphicsCaptureItem const&, winrt::Windows::Foundation::IInspectable const&) { std::lock_guard locker(lock_); + if (observer_) observer_->OnFrame({}, id_); const bool was_running = is_running_; const bool was_paused = is_paused_; try { diff --git a/apps/desktop/src/platform/windows/windows_thread_dpi.h b/apps/desktop/src/platform/windows/windows_thread_dpi.h new file mode 100644 index 00000000..8d27c926 --- /dev/null +++ b/apps/desktop/src/platform/windows/windows_thread_dpi.h @@ -0,0 +1,53 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-09-10 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _WINDOWS_THREAD_DPI_H_ +#define _WINDOWS_THREAD_DPI_H_ + +#include + +namespace crossdesk { + +// Keep newer DPI entry points out of the import table: privacy capability +// checks must still be able to report unsupported on pre-2004 Windows. +class ScopedWindowsPhysicalCoordinates { + public: + using SetContext = DPI_AWARENESS_CONTEXT(WINAPI*)(DPI_AWARENESS_CONTEXT); + ScopedWindowsPhysicalCoordinates() { + setter_ = reinterpret_cast(GetProcAddress( + GetModuleHandleW(L"user32.dll"), "SetThreadDpiAwarenessContext")); + if (setter_) { + previous_ = setter_(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); + // Ordinary remote input retains compatibility with Windows 10 1607. + // The privacy backend itself requires build 19041 before using this. + if (!previous_) + previous_ = setter_(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE); + } + } + ~ScopedWindowsPhysicalCoordinates() { + if (setter_ && previous_) setter_(previous_); + } + ScopedWindowsPhysicalCoordinates(const ScopedWindowsPhysicalCoordinates&) = + delete; + ScopedWindowsPhysicalCoordinates& operator=( + const ScopedWindowsPhysicalCoordinates&) = delete; + bool active() const { return previous_ != nullptr; } + + private: + SetContext setter_ = nullptr; + DPI_AWARENESS_CONTEXT previous_ = nullptr; +}; + +inline UINT PrivacyWindowDpi(HWND hwnd) { + using GetDpi = UINT(WINAPI*)(HWND); + static const auto get_dpi = reinterpret_cast( + GetProcAddress(GetModuleHandleW(L"user32.dll"), "GetDpiForWindow")); + return get_dpi ? get_dpi(hwnd) : 96; +} + +} // namespace crossdesk + +#endif // _WINDOWS_THREAD_DPI_H_ diff --git a/apps/desktop/src/privacy/privacy_backend.h b/apps/desktop/src/privacy/privacy_backend.h new file mode 100644 index 00000000..7d31bd6a --- /dev/null +++ b/apps/desktop/src/privacy/privacy_backend.h @@ -0,0 +1,64 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-09-10 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _PRIVACY_BACKEND_H_ +#define _PRIVACY_BACKEND_H_ + +#include +#include +#include + +namespace crossdesk { + +struct PrivacyScreenText { + std::string verification = "Privacy screen verification"; + std::string unlock_hint = "To turn off the privacy screen, press"; +}; + +struct PrivacyCapabilities { + bool overlay = false; + bool block_input = false; + std::string reason; +}; + +struct PrivacyHealth { + std::string failure; + bool emergency_exit = false; +}; + +// Lifecycle, message pumping and native resource operations run on the +// controller's message thread. Const metadata queries are serialized with +// those operations by the controller mutex and must not call native UI APIs. +// No platform backend is installed on macOS/Linux yet. +class PrivacyBackend { + public: + virtual ~PrivacyBackend() = default; + virtual PrivacyCapabilities Query() = 0; + virtual bool Enable(bool block_input, const PrivacyScreenText& text, + std::string& error) = 0; + virtual void Recover() = 0; + virtual bool IsRecovered() const = 0; + virtual PrivacyHealth Poll(bool force_check = false) = 0; + // Wait on both native window messages and controller commands. + // Wake is the only backend operation callable from another thread. + virtual void WaitForEvents(unsigned timeout_ms) = 0; + virtual void Wake() = 0; + virtual bool PaintChallenge(uint32_t value, const std::string& title, + std::string& error) = 0; + // Remove only the temporary capture probes, keeping all privacy covers up. + // The controller drains queued probe frames before admitting clean video. + virtual bool ClearChallenges(std::string& error) = 0; + virtual bool HasMonitor(int left, int top, int width, int height) const = 0; +}; + +#ifdef _WIN32 +std::unique_ptr CreateWindowsPrivacyBackend(); +bool IsWindowsPrivacyDesktopAvailable(); +#endif + +} // namespace crossdesk + +#endif // _PRIVACY_BACKEND_H_ diff --git a/apps/desktop/src/privacy/privacy_controller.cpp b/apps/desktop/src/privacy/privacy_controller.cpp new file mode 100644 index 00000000..f5aef586 --- /dev/null +++ b/apps/desktop/src/privacy/privacy_controller.cpp @@ -0,0 +1,496 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-09-11 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#include "privacy_controller.h" + +#include +#include + +#include "rd_log.h" + +namespace crossdesk { + +uint64_t PrivacyController::Now() { + return static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count()); +} + +PrivacyController::PrivacyController(Factory factory) { + status_.state = PrivacyState::unsupported; + std::strcpy(status_.reason, + "Privacy screen is not available on this platform"); +#ifdef _WIN32 + if (!factory) factory = CreateWindowsPrivacyBackend; +#endif + thread_ = std::thread([this, factory] { Run(factory); }); +} + +PrivacyController::~PrivacyController() { Shutdown(); } + +void PrivacyController::WakeLocked() { + if (backend_) backend_->Wake(); + wake_.notify_all(); +} + +void PrivacyController::Shutdown() { + { + std::lock_guard lock(mutex_); + quit_ = true; + remote_paused_.store(true); + WakeLocked(); + } + if (thread_.joinable()) thread_.join(); +} + +PrivacyStatus PrivacyController::Snapshot() const { + std::lock_guard lock(mutex_); + return status_; +} + +void PrivacyController::SetText(const PrivacyScreenText& text) { + std::lock_guard lock(mutex_); + text_ = text; +} + +void PrivacyController::SetState(PrivacyState state, + const std::string& reason) { + status_.state = state; + status_.remote_paused = remote_paused_.load(); + ++status_.revision; + std::strncpy(status_.reason, reason.c_str(), sizeof(status_.reason) - 1); + status_.reason[sizeof(status_.reason) - 1] = '\0'; + LOG_INFO( + "Privacy: state={}, overlay={}, input_blocked={}, paused={}, reason={}", + static_cast(state), status_.overlay_active, status_.input_blocked, + status_.remote_paused, reason); +} + +void PrivacyController::Enable(bool block_input) { + std::lock_guard lock(mutex_); + if (quit_) return; + if (status_.state == PrivacyState::on || + status_.state == PrivacyState::starting || + status_.state == PrivacyState::stopping || + status_.state == PrivacyState::failed) { + SetState(status_.state, status_.reason); + return; + } + if (!status_.supported || (block_input && !status_.input_block_supported)) { + SetState(PrivacyState::unsupported, status_.reason); + return; + } + // Failure recovery is explicit: first turn off, then request a fresh enable. + engaged_.store(true); + remote_paused_.store(true); + block_requested_ = block_input; + automatic_enable_ = false; + enable_pending_ = true; + ever_enabled_ = false; + SetState(PrivacyState::starting, + "Creating privacy windows and checking capture"); + WakeLocked(); +} + +void PrivacyController::EnableOnConnection() { + std::lock_guard lock(mutex_); + if (quit_ || status_.state == PrivacyState::on || + status_.state == PrivacyState::starting || + status_.state == PrivacyState::failed) + return; + // Also accept a reconnect while the previous disconnect is still cleaning + // up. Run() keeps the gate closed between recovery and this new enable. + engaged_.store(true); + remote_paused_.store(true); + block_requested_ = true; + automatic_enable_ = true; + capture_start_deadline_ = Now() + 10000; + enable_pending_ = true; + ever_enabled_ = false; + SetState(PrivacyState::starting, + "Connected; automatically enabling privacy and verifying capture"); + WakeLocked(); +} + +void PrivacyController::Disable() { + std::lock_guard lock(mutex_); + if (quit_) return; + if (!engaged_.load() && !status_.overlay_active) { + SetState(status_.state, status_.reason); + return; + } + enable_pending_ = false; + disable_pending_ = true; + remote_paused_.store(true); + SetState(PrivacyState::stopping, "Restoring local desktop and input"); + WakeLocked(); +} + +void PrivacyController::Disconnected() { + std::lock_guard lock(mutex_); + if (quit_ || + (!engaged_.load() && !status_.overlay_active && !remote_paused_.load())) + return; + remote_paused_.store(true); + enable_pending_ = false; + disable_pending_ = true; + SetState(PrivacyState::stopping, + "Last controller disconnected; restoring desktop, input and session " + "state"); + WakeLocked(); +} + +void PrivacyController::FailLocked(const std::string& reason) { + if (!engaged_.load() || status_.state == PrivacyState::failed || + status_.state == PrivacyState::stopping) + return; + remote_paused_.store(true); + enable_pending_ = false; + SetState(PrivacyState::failed, reason); +} + +void PrivacyController::Fail(const std::string& reason) { + std::lock_guard lock(mutex_); + FailLocked(reason); +} + +void PrivacyController::BeginVerification(const std::string& reason) { + remote_paused_.store(true); + verifier_.Reset(true); + verification_phase_ = VerificationPhase::challenge; + clean_frames_ = 0; + last_frame_received_ = 0; + drain_after_recovery_.store(false); + started_ = Now(); + last_issue_ = 0; + frame_monitor_set_ = false; + first_verified_frame_logged_ = false; + SetState(PrivacyState::starting, + !reason.empty() ? reason + : automatic_enable_ && !capture_running_ + ? "Waiting for desktop capture startup" + : "Verifying live desktop through " + capture_backend_); +} + +void PrivacyController::CaptureChanged(const std::string& backend, + bool running) { + std::lock_guard lock(mutex_); + capture_backend_ = backend; + capture_running_ = running; + if (!engaged_.load()) return; + if (ever_enabled_) { + FailLocked( + "Capture backend stopped or rebuilt; remote operation paused. Turn " + "privacy off to recover."); + } else if (status_.state == PrivacyState::starting) { + BeginVerification(); + } +} + +void PrivacyController::DisplayChanging() { + std::lock_guard lock(mutex_); + if (status_.state == PrivacyState::on) BeginVerification(); +} + +void PrivacyController::CaptureInterrupted() { + std::lock_guard lock(mutex_); + if (!engaged_.load()) return; + if (ever_enabled_) { + FailLocked( + "Capture component is rebuilding; remote operation paused. Turn " + "privacy off to recover."); + } else if (status_.state == PrivacyState::starting) { + // The capturer owns recovery and backend selection. Require fresh frames + // from it without letting repeated resets extend verification forever. + const auto attempt_started = started_; + BeginVerification( + "Capture component interrupted; verifying recovered frames"); + if (attempt_started) started_ = attempt_started; + } +} + +bool PrivacyController::ObserveFrame(const uint8_t* y, size_t size, int width, + int height, int left, int top, + int physical_width, int physical_height) { + if (!engaged_.load() && !drain_after_recovery_.load()) return RemoteAllowed(); + std::lock_guard lock(mutex_); + if (!engaged_.load()) { + // Disabling during verification can leave already-captured probe frames + // queued behind HWND destruction. Do not leak them after reporting Off. + if (drain_after_recovery_.load()) { + if (!y || width <= 0 || height <= 0 || + size < static_cast(width) * height) + return false; + if (verifier_.ContainsChallenge(y, size, width, height)) { + clean_frames_ = 0; + return false; + } + if (++clean_frames_ < 2) return false; + drain_after_recovery_.store(false); + verifier_.Reset(); + } + return RemoteAllowed(); + } + if (!backend_ || !status_.overlay_active || + (status_.state != PrivacyState::starting && + status_.state != PrivacyState::on)) + return false; + if (!backend_->HasMonitor(left, top, physical_width, physical_height) || + width != (physical_width & ~1) || height != (physical_height & ~1)) { + if (ever_enabled_) + FailLocked( + "Capture geometry changed or cannot be verified; remote operation " + "paused"); + return false; + } + if (frame_monitor_set_ && (frame_left_ != left || frame_top_ != top)) { + BeginVerification(); + } + frame_left_ = left; + frame_top_ = top; + frame_monitor_set_ = true; + if (!y || size < static_cast(width) * height) { + FailLocked( + "Capture returned an invalid desktop frame; remote operation paused"); + return false; + } + last_frame_received_ = Now(); + if (verification_phase_ == VerificationPhase::challenge) { + verifier_.Observe(y, size, width, height, last_frame_received_); + return false; // Verification pixels are never sent to the encoder. + } + if (verifier_.ContainsChallenge(y, size, width, height)) { + clean_frames_ = 0; + if (verification_phase_ == VerificationPhase::live) { + drain_after_recovery_.store(true); + FailLocked( + "Capture reintroduced verification pixels; remote operation paused"); + } + return false; + } + if (verification_phase_ == VerificationPhase::draining) { + if (clean_frames_ < 2) ++clean_frames_; + return false; // Only the owner thread can publish the verified On state. + } + if (status_.state != PrivacyState::on) return false; + if (!first_verified_frame_logged_) { + first_verified_frame_logged_ = true; + LOG_INFO( + "Privacy: first clean verified desktop frame admitted, backend={}, " + "size={}x{}, origin=({}, {})", + capture_backend_, width, height, left, top); + } + return true; +} + +void PrivacyController::Run(Factory factory) { + std::unique_lock lock(mutex_); + try { + if (factory) backend_ = factory(); + if (backend_) { + const auto caps = backend_->Query(); + last_capability_check_ = Now(); + status_.supported = caps.overlay; + status_.input_block_supported = caps.block_input; + // A connection can arm the gate before this thread has initialized. + if (!engaged_.load()) + SetState(caps.overlay ? PrivacyState::off : PrivacyState::unsupported, + caps.reason); + } + while (!quit_) { + if (backend_ && !engaged_.load() && + Now() - last_capability_check_ >= 2000) { + last_capability_check_ = Now(); + const auto caps = backend_->Query(); + if (status_.supported != caps.overlay || + status_.input_block_supported != caps.block_input) { + status_.supported = caps.overlay; + status_.input_block_supported = caps.block_input; + SetState(caps.overlay ? PrivacyState::off : PrivacyState::unsupported, + caps.reason); + } + } + if (disable_pending_) { + if (backend_) backend_->Recover(); + disable_pending_ = false; + if (backend_ && !backend_->IsRecovered()) { + enable_pending_ = false; + SetState(PrivacyState::failed, + "Could not release every privacy resource; remote operation " + "remains paused"); + continue; + } + status_.overlay_active = status_.input_blocked = false; + const bool drain = challenges_visible_ || + verification_phase_ == VerificationPhase::draining || + drain_after_recovery_.load(); + challenges_visible_ = false; + clean_frames_ = 0; + drain_after_recovery_.store(drain); + if (!drain) verifier_.Reset(); + verification_phase_ = VerificationPhase::live; + if (enable_pending_) { + SetState(PrivacyState::starting, + "Reconnected; automatically restoring privacy protection"); + } else { + ever_enabled_ = false; + automatic_enable_ = false; + block_requested_ = false; + capture_start_deadline_ = 0; + started_ = last_issue_ = 0; + frame_monitor_set_ = false; + first_verified_frame_logged_ = false; + engaged_.store(false); + remote_paused_.store(false); + SetState(PrivacyState::off, "Privacy screen is off"); + } + } + if (enable_pending_) { + enable_pending_ = false; + std::string error; + if (automatic_enable_) { + const auto caps = + backend_ ? backend_->Query() + : PrivacyCapabilities{false, false, + "No privacy backend is installed"}; + status_.supported = caps.overlay; + status_.input_block_supported = caps.block_input; + if (!caps.overlay || !caps.block_input) + FailLocked("Automatic privacy unavailable: " + caps.reason); + } + if (status_.state != PrivacyState::failed) { + if (!capture_running_ && !automatic_enable_) { + FailLocked("No running desktop capture; remote operation paused"); + } else if (!backend_->Enable(block_requested_, text_, error)) { + backend_->Recover(); + status_.overlay_active = !backend_->IsRecovered(); + FailLocked(error); + } else { + status_.overlay_active = true; + status_.input_blocked = block_requested_; + BeginVerification(); + } + } + } + // Pump the native queue even after recovery: the message sink is reused. + const auto health = + backend_ ? backend_->Poll(status_.state == PrivacyState::starting && + verifier_.Verified()) + : PrivacyHealth{}; + if (backend_ && engaged_.load()) { + if (health.emergency_exit) { + remote_paused_.store(true); + enable_pending_ = false; + disable_pending_ = true; + SetState(PrivacyState::stopping, + "Local privacy exit; restoring desktop and input before " + "resuming remote operation"); + continue; + } else if (!health.failure.empty()) { + FailLocked(health.failure); + } + if (status_.overlay_active && + (status_.state == PrivacyState::starting || + status_.state == PrivacyState::on)) { + const auto now = Now(); + if (verification_phase_ == VerificationPhase::challenge && + now - last_issue_ >= 250) { + uint32_t challenge; + do { + challenge = random_(); + } while (challenge == 0 || challenge == UINT32_MAX); + std::string error; + challenges_visible_ = true; + // Record even a partially painted multi-monitor challenge, so + // cleanup can reject its queued frames if another monitor fails. + verifier_.Issue(challenge, now); + if (!backend_->PaintChallenge(challenge, text_.verification, + error)) { + FailLocked(error); + } else { + last_issue_ = now; + } + } + if (status_.state == PrivacyState::starting && + verification_phase_ == VerificationPhase::challenge && + verifier_.Verified() && capture_running_) { + std::string error; + if (!backend_->ClearChallenges(error)) { + FailLocked(error); + } else { + challenges_visible_ = false; + verification_phase_ = VerificationPhase::draining; + clean_frames_ = 0; + started_ = now; + SetState(PrivacyState::starting, + "Capture exclusion verified; waiting for clean desktop " + "frames"); + } + } + if (status_.state == PrivacyState::starting && + verification_phase_ == VerificationPhase::draining && + clean_frames_ >= 2 && capture_running_) { + verification_phase_ = VerificationPhase::live; + ever_enabled_ = true; + automatic_enable_ = false; + remote_paused_.store(false); + SetState(PrivacyState::on, "Privacy screen verified using " + + capture_backend_ + + "; verification windows removed"); + } + if (status_.state == PrivacyState::on && + now - last_frame_received_ > 1500) { + // Live sessions rely on capture delivery and native window/backend + // health; verification pixels are never painted during operation. + FailLocked( + "Capture stopped delivering desktop frames; remote operation " + "paused"); + } else if (status_.state == PrivacyState::starting && + automatic_enable_ && !capture_running_) { + if (now >= capture_start_deadline_) + FailLocked( + "Desktop capture did not start; automatic privacy remains " + "paused"); + } else if (status_.state == PrivacyState::starting && + now - started_ > 3000) { + if (verification_phase_ == VerificationPhase::draining) { + FailLocked( + "Capture did not clear its verification pixels; remote " + "operation paused"); + } else { + FailLocked("Current capture backend " + capture_backend_ + + " did not pass privacy verification; remote operation " + "paused. Turn privacy off to recover."); + } + } + } + } + if (backend_) { + // Keep window notifications and emergency exits responsive. Low-level + // input hooks are pumped independently by PrivacyInputGuard. + const unsigned timeout_ms = engaged_.load() ? 25 : 2000; + lock.unlock(); + backend_->WaitForEvents(timeout_ms); + lock.lock(); + } else { + // Unsupported platforms have no native health or capability polling. + wake_.wait(lock); + } + } + } catch (const std::exception& e) { + if (!lock.owns_lock()) lock.lock(); + remote_paused_.store(true); + SetState(PrivacyState::failed, + std::string("Privacy component failed: ") + e.what()); + } + if (backend_) { + backend_->Recover(); + backend_.reset(); + } +} + +} // namespace crossdesk diff --git a/apps/desktop/src/privacy/privacy_controller.h b/apps/desktop/src/privacy/privacy_controller.h new file mode 100644 index 00000000..3c8eb8f5 --- /dev/null +++ b/apps/desktop/src/privacy/privacy_controller.h @@ -0,0 +1,99 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-09-10 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _PRIVACY_CONTROLLER_H_ +#define _PRIVACY_CONTROLLER_H_ + +#include + +#include +#include +#include +#include +#include +#include + +#include "privacy_backend.h" +#include "privacy_frame_verifier.h" + +namespace crossdesk { + +class PrivacyController { + public: + using Factory = std::function()>; + explicit PrivacyController(Factory factory = {}); + ~PrivacyController(); + PrivacyController(const PrivacyController&) = delete; + PrivacyController& operator=(const PrivacyController&) = delete; + + PrivacyStatus Snapshot() const; + // The cover uses a snapshot of these strings when the next session starts. + void SetText(const PrivacyScreenText& text); + void Enable(bool block_input); + // Arms protection synchronously before an incoming connection is published. + // Unlike a manual request, startup/unavailable capture cannot leave the + // newly connected peer with ordinary unprotected remote access. + void EnableOnConnection(); + void Disable(); + // Called only after the last control session has been removed. Failures keep + // protection while a session exists; ending that session recovers everything + // and must not leave the next connection in a paused/failed state. + void Disconnected(); + void Shutdown(); + void Fail(const std::string& reason); + bool RemoteAllowed() const { return !remote_paused_.load(); } + bool Engaged() const { return engaged_.load(); } + void CaptureChanged(const std::string& backend, bool running); + void CaptureInterrupted(); + void DisplayChanging(); + bool ObserveFrame(const uint8_t* y, size_t size, int width, int height, + int left, int top, int physical_width, int physical_height); + + private: + enum class VerificationPhase { challenge, draining, live }; + void Run(Factory factory); + void WakeLocked(); + void SetState(PrivacyState state, const std::string& reason); + void FailLocked(const std::string& reason); + void BeginVerification(const std::string& reason = {}); + static uint64_t Now(); + + mutable std::mutex mutex_; + std::condition_variable wake_; + std::thread thread_; + std::unique_ptr backend_; + PrivacyStatus status_{}; + PrivacyFrameVerifier verifier_; + PrivacyScreenText text_; + std::mt19937 random_{std::random_device{}()}; + std::atomic remote_paused_{false}; + std::atomic engaged_{false}; + std::atomic drain_after_recovery_{false}; + VerificationPhase verification_phase_ = VerificationPhase::challenge; + bool challenges_visible_ = false; + unsigned clean_frames_ = 0; + uint64_t last_frame_received_ = 0; + bool quit_ = false; + bool enable_pending_ = false; + bool disable_pending_ = false; + bool block_requested_ = false; + bool capture_running_ = false; + bool ever_enabled_ = false; + bool automatic_enable_ = false; + uint64_t capture_start_deadline_ = 0; + std::string capture_backend_; + uint64_t started_ = 0; + uint64_t last_issue_ = 0; + uint64_t last_capability_check_ = 0; + int frame_left_ = 0; + int frame_top_ = 0; + bool frame_monitor_set_ = false; + bool first_verified_frame_logged_ = false; +}; + +} // namespace crossdesk + +#endif // _PRIVACY_CONTROLLER_H_ diff --git a/apps/desktop/src/privacy/privacy_frame_verifier.h b/apps/desktop/src/privacy/privacy_frame_verifier.h new file mode 100644 index 00000000..087c4bc9 --- /dev/null +++ b/apps/desktop/src/privacy/privacy_frame_verifier.h @@ -0,0 +1,98 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-09-10 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _PRIVACY_FRAME_VERIFIER_H_ +#define _PRIVACY_FRAME_VERIFIER_H_ + +#include +#include +#include + +#include "privacy_probe_pattern.h" + +namespace crossdesk { + +// An opaque square code beside localized text, UNDER the privacy cover. +// Used only while transmission is gated during capture validation. Probes are +// then removed, and queued frames containing their tokens must be discarded. +class PrivacyFrameVerifier { + public: + void Reset(bool retain_history = false) { + challenges_.clear(); + if (!retain_history) issued_values_.clear(); + matches_ = 0; + } + void Issue(uint32_t value, uint64_t now) { + challenges_.push_back({value, now, false}); + issued_values_.push_back(value); + // Covers more than the startup/drain deadlines, including backend retries. + while (issued_values_.size() > 128) issued_values_.pop_front(); + // A validation attempt is time-bounded. Keep every issued token so an old + // queued frame cannot escape merely because its token expired for matching. + } + bool Observe(const uint8_t* y, size_t size, int width, int height, + uint64_t now) { + uint32_t value = 0; + if (!Decode(y, size, width, height, value)) return false; + for (auto& challenge : challenges_) { + if (challenge.value != value || now - challenge.issued > 750) continue; + if (!challenge.seen) { + challenge.seen = true; + ++matches_; + } + return true; + } + return false; + } + bool Verified() const { return matches_ >= 2; } + bool ContainsChallenge(const uint8_t* y, size_t size, int width, + int height) const { + uint32_t value = 0; + if (!Decode(y, size, width, height, value)) return false; + for (const auto issued : issued_values_) + if (issued == value) return true; + return false; + } + + private: + static bool Decode(const uint8_t* y, size_t size, int width, int height, + uint32_t& value) { + using Pattern = PrivacyProbePattern; + if (!y || width < Pattern::kCaptureExtent || + height < Pattern::kCaptureExtent || + size < static_cast(width) * height) + return false; + value = 0; + for (int bit = 0; bit < 32; ++bit) { + const int x = Pattern::kOffset + Pattern::kPadding + + (bit % Pattern::kColumns) * Pattern::kCell + + Pattern::kCell / 2; + const int row = Pattern::kOffset + Pattern::kPadding + + (bit / Pattern::kColumns) * Pattern::kCell + + Pattern::kCell / 2; + const auto pixel = y[static_cast(row) * width + x]; + const auto inverse = + y[static_cast(row + 4 * Pattern::kCell) * width + x]; + if ((pixel > 60 && pixel < 190) || (inverse > 60 && inverse < 190) || + (pixel >= 190) == (inverse >= 190)) + return false; + if (pixel >= 190) value |= uint32_t{1} << bit; + } + return true; + } + struct Challenge { + uint32_t value; + uint64_t issued; + bool seen; + }; + std::deque challenges_; + std::deque issued_values_; + unsigned matches_ = 0; +}; + +} // namespace crossdesk + +#endif // _PRIVACY_FRAME_VERIFIER_H_ diff --git a/apps/desktop/src/privacy/privacy_probe_pattern.h b/apps/desktop/src/privacy/privacy_probe_pattern.h new file mode 100644 index 00000000..c6872edc --- /dev/null +++ b/apps/desktop/src/privacy/privacy_probe_pattern.h @@ -0,0 +1,37 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-09-11 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _PRIVACY_PROBE_PATTERN_H_ +#define _PRIVACY_PROBE_PATTERN_H_ + +#include + +namespace crossdesk { + +// Physical-pixel geometry shared by the renderer and raw-frame verifier. +// Text can scale/wrap independently without moving any sampling location. +struct PrivacyProbePattern { + static constexpr int kOffset = 16; + static constexpr int kPadding = 16; + static constexpr int kGap = 24; + static constexpr int kCell = 12; + static constexpr int kColumns = 8; + static constexpr int kRows = 8; + static constexpr int kSide = kColumns * kCell; + static constexpr int kCaptureExtent = kOffset + kPadding + kSide; + static constexpr int kTextLeft = kPadding + kSide + kGap; + + // Top half carries all 32 bits; bottom half carries their complements. + static constexpr bool White(uint32_t value, int column, int row) { + const int index = row * kColumns + column; + const bool bit = (value & (uint32_t{1} << (index % 32))) != 0; + return index < 32 ? bit : !bit; + } +}; + +} // namespace crossdesk + +#endif // _PRIVACY_PROBE_PATTERN_H_ diff --git a/apps/desktop/src/screen_capturer/screen_capturer.h b/apps/desktop/src/screen_capturer/screen_capturer.h index ab650172..9acc9d3a 100644 --- a/apps/desktop/src/screen_capturer/screen_capturer.h +++ b/apps/desktop/src/screen_capturer/screen_capturer.h @@ -16,6 +16,9 @@ namespace crossdesk { class ScreenCapturer { public: + // Windows backends emit a control-only callback BEFORE automatic recovery. + // No video buffer accompanies this notification; wrappers must consume it. + static constexpr int kBackendReset = -1; // |stream_id| is a logical MiniRTC stream ID (DisplayN), not a platform // display name or physical handle. |native_frame| is borrowed for the // duration of the callback; retain its owner before using it asynchronously. diff --git a/apps/desktop/tests/windows_mouse_controller_safety_test.cpp b/apps/desktop/tests/windows_mouse_controller_safety_test.cpp index 07633253..e80b2cd1 100644 --- a/apps/desktop/tests/windows_mouse_controller_safety_test.cpp +++ b/apps/desktop/tests/windows_mouse_controller_safety_test.cpp @@ -56,8 +56,6 @@ int main() { bool ok = true; ok &= ExpectContains("mouse_controller.cpp", mouse_controller, "INPUT ip = {0};"); - ok &= ExpectContains("mouse_controller.cpp", mouse_controller, - "SetCursorPos failed"); ok &= ExpectContains("mouse_controller.cpp", mouse_controller, "SendInput failed for mouse"); return ok ? 0 : 1; diff --git a/apps/desktop/xmake/targets.lua b/apps/desktop/xmake/targets.lua index 05a7b0ce..34126d8e 100644 --- a/apps/desktop/xmake/targets.lua +++ b/apps/desktop/xmake/targets.lua @@ -156,12 +156,40 @@ function setup_targets() add_frameworks("Security", "CoreFoundation") end + target("privacy") + set_kind("object") + add_deps("rd_log", "crossdesk_wire") + add_files("apps/desktop/src/privacy/privacy_controller.cpp") + add_includedirs("apps/desktop/src/privacy", {public = true}) + if is_os("windows") then + add_files("apps/desktop/src/platform/windows/privacy/*.cpp") + add_deps("crossdesk_privacy_cursor_helper") + add_deps("crossdesk_privacy_window", {inherit = false}) + add_includedirs("apps/desktop/src/platform/windows/input") + add_syslinks("Wtsapi32", "Ole32", {public = true}) + end + + if is_os("windows") then + target("crossdesk_privacy_window") + set_kind("shared") + set_runtimes("MT") + add_files("apps/desktop/src/platform/windows/privacy/band_module/main.cpp") + add_files("apps/desktop/src/platform/windows/privacy/band_module/privacy_cover_renderer.cpp") + add_files("apps/desktop/resources/windows/crossdesk_privacy_window.rc") + add_syslinks("user32", "gdi32", "dwmapi") + + target("crossdesk_privacy_cursor_helper") + set_kind("binary") + add_files("apps/desktop/src/platform/windows/privacy/cursor_helper/main.cpp") + end + target("screen_capturer") set_kind("object") add_deps("rd_log", "common", "crossdesk_wire") add_includedirs("apps/desktop/src/screen_capturer", {public = true}) add_includedirs("deps/submodules/minirtc/src/api", {public = true}) if is_os("windows") then + add_deps("privacy") add_packages("libyuv") add_files("apps/desktop/src/screen_capturer/captured_nv12_frame.cpp") add_files("apps/desktop/src/platform/windows/screen_capturer/screen_capturer_dxgi.cpp", @@ -318,7 +346,7 @@ function setup_targets() add_deps("rd_log", "common", "assets", "config_center", "minirtc", "path_manager", "screen_capturer", "speaker_capturer", "device_controller", "thumbnail", "version_checker", "tools", - "crossdesk_wire") + "crossdesk_wire", "privacy") add_files("apps/desktop/src/gui/render.cpp", "apps/desktop/src/gui/application/gui_application.cpp", "apps/desktop/src/gui/rendering/*.cpp", "apps/desktop/src/gui/runtime/*.cpp", diff --git a/libs/wire/include/remote_action.h b/libs/wire/include/remote_action.h index f7281bc3..7a26d35f 100644 --- a/libs/wire/include/remote_action.h +++ b/libs/wire/include/remote_action.h @@ -26,6 +26,8 @@ enum ControlType { service_command = 6, keyboard_state = 7, cursor_state = 8, + privacy_command = 9, + privacy_status = 10, }; enum MouseFlag { @@ -109,6 +111,23 @@ struct ServiceCommand { ServiceCommandFlag flag; }; +enum class PrivacyState { unsupported, off, starting, on, stopping, failed }; +enum class PrivacyCommandFlag { query, enable, disable }; +struct PrivacyCommand { + PrivacyCommandFlag flag; + bool block_local_input; +}; +struct PrivacyStatus { + PrivacyState state; + bool supported; + bool input_block_supported; + bool overlay_active; + bool input_blocked; + bool remote_paused; + uint32_t revision; + char reason[256]; +}; + struct RemoteAction { ControlType type = ControlType::invalid; union { @@ -121,6 +140,8 @@ struct RemoteAction { int d; ServiceStatus ss; ServiceCommand c; + PrivacyCommand pc; + PrivacyStatus ps; }; std::string to_json() const; @@ -136,4 +157,4 @@ void FreeRemoteAction(RemoteAction& action); } // namespace crossdesk -#endif \ No newline at end of file +#endif diff --git a/libs/wire/src/remote_action.cpp b/libs/wire/src/remote_action.cpp index 3876123a..ba8b48ba 100644 --- a/libs/wire/src/remote_action.cpp +++ b/libs/wire/src/remote_action.cpp @@ -106,6 +106,19 @@ std::string RemoteAction::ToJson(const RemoteAction& action) { case ControlType::service_command: object["service_command"] = {{"flag", action.c.flag}}; break; + case ControlType::privacy_command: + object["privacy_command"] = {{"flag", action.pc.flag}, + {"block_local_input", action.pc.block_local_input}}; + break; + case ControlType::privacy_status: + object["privacy_status"] = {{"state", action.ps.state}, + {"supported", action.ps.supported}, + {"input_block_supported", action.ps.input_block_supported}, + {"overlay_active", action.ps.overlay_active}, + {"input_blocked", action.ps.input_blocked}, + {"remote_paused", action.ps.remote_paused}, + {"revision", action.ps.revision}, {"reason", action.ps.reason}}; + break; case ControlType::host_infomation: { json displays = json::array(); for (std::size_t index = 0; index < action.i.display_num; ++index) { @@ -228,6 +241,33 @@ bool RemoteAction::FromJson(const std::string& json_string, output.c.flag = static_cast( object.at("service_command").at("flag").get()); break; + case ControlType::privacy_command: { + const auto& command = object.at("privacy_command"); + const int flag = command.at("flag").get(); + if (flag < 0 || flag > 2) return false; + output.pc.flag = static_cast(flag); + output.pc.block_local_input = command.at("block_local_input").get(); + break; + } + case ControlType::privacy_status: { + const auto& status = object.at("privacy_status"); + const int state = status.at("state").get(); + const auto reason = status.at("reason").get(); + if (state < 0 || state > 5 || reason.size() >= sizeof(output.ps.reason) || + reason.find('\0') != std::string::npos) return false; + output.ps.state = static_cast(state); + output.ps.supported = status.at("supported").get(); + output.ps.input_block_supported = status.at("input_block_supported").get(); + output.ps.overlay_active = status.at("overlay_active").get(); + output.ps.input_blocked = status.at("input_blocked").get(); + output.ps.remote_paused = status.at("remote_paused").get(); + output.ps.revision = status.at("revision").get(); + std::memcpy(output.ps.reason, reason.c_str(), reason.size() + 1); + // Never render a contradictory success from a malformed peer. + if (output.ps.state == PrivacyState::on && + (!output.ps.supported || !output.ps.overlay_active || output.ps.remote_paused)) return false; + break; + } case ControlType::host_infomation: { ResetHostInfo(output.i); owns_host_info = true;