diff --git a/apps/desktop/src/gui/application/gui_application.cpp b/apps/desktop/src/gui/application/gui_application.cpp index de805235..bce17067 100644 --- a/apps/desktop/src/gui/application/gui_application.cpp +++ b/apps/desktop/src/gui/application/gui_application.cpp @@ -1029,7 +1029,6 @@ void GuiApplication::InitializeSettings() { localization_language_ = static_cast(localization_language_index_); privacy_.SetText({ - localization::privacy_verification[localization_language_index_], localization::privacy_screen_unlock_hint[localization_language_index_]}); } @@ -1237,6 +1236,8 @@ void GuiApplication::ResetSettingsUi() { ui_->main->set_capture_method_visible(true); ui_->main->set_capture_method_index( static_cast(config_center_->GetScreenCaptureMethod())); +#endif +#if defined(_WIN32) || defined(__APPLE__) ui_->main->set_privacy_setting_available(true); #else ui_->main->set_privacy_setting_available(false); @@ -2569,14 +2570,10 @@ void GuiApplication::SyncStreamWindow() { const int language = localization_language_index_; std::string text = !connected || !props->privacy_status_received_ ? localization::privacy_screen[language] - : controls.timeout || controls.stale ? localization::privacy_timeout[language] - : controls.warning ? localization::privacy_paused[language] + : controls.unsupported ? localization::privacy_unsupported[language] : controls.enabled ? localization::privacy_on[language] - : !status.supported ? localization::privacy_unsupported[language] : controls.off ? localization::privacy_off[language] : localization::privacy_screen[language]; - if (connected && controls.warning && status.reason[0] != '\0') - text += std::string("\n") + status.reason; (*ui_->stream)->set_privacy_status_text(UiText(text)); } int remote_cursor_shape = @@ -2822,13 +2819,17 @@ void GuiApplication::SyncServerWindow() { }); } - // Keep the local connection panel out of captured privacy sessions and - // transitions; it is unnecessary while the physical screens are covered. + // macOS covers the local panel with capture-excluded privacy windows. Keep + // the panel alive so remote controllers can still see and operate it. + bool show_controller_window = has_connected_controller; +#if !defined(__APPLE__) + // Preserve the existing panel behavior on other platforms. 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; + show_controller_window = show_controller_window && !privacy_covering; +#endif if (show_controller_window && !ui_->server) { ui_->server.emplace(ui::ServerWindow::create()); RegisterFontAwesome((*ui_->server)->window()); @@ -2998,7 +2999,6 @@ void GuiApplication::SaveSettingsFromUi() { 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_)); @@ -3026,6 +3026,8 @@ void GuiApplication::SaveSettingsFromUi() { } main->set_capture_method_index( static_cast(config_center_->GetScreenCaptureMethod())); +#endif +#if defined(_WIN32) || defined(__APPLE__) if (config_center_->SetPrivacyScreen(main->get_privacy_on_connect_enabled()) != 0) { main->set_privacy_on_connect_enabled( @@ -3365,7 +3367,7 @@ void GuiApplication::Cleanup() { #if _WIN32 && CROSSDESK_PORTABLE JoinPortableWindowsServiceInstallThread(); #endif - privacy_.Fail("Application exiting; remote operation paused"); + privacy_.Disable(); clipboard_.Shutdown(); keyboard_.ForceReleasePressedKeys(); keyboard_.ReleaseAllRemotePressedKeys("application_exit"); diff --git a/apps/desktop/src/gui/assets/localization/localization_data.h b/apps/desktop/src/gui/assets/localization/localization_data.h index 392865af..82e9dc2d 100644 --- a/apps/desktop/src/gui/assets/localization/localization_data.h +++ b/apps/desktop/src/gui/assets/localization/localization_data.h @@ -22,12 +22,10 @@ struct TranslationRow { #define CROSSDESK_LOCALIZATION_ALL(X) \ X(privacy_screen, u8"隐私屏", "Privacy screen", u8"Приватный экран") \ X(privacy_enable, u8"开启隐私屏", "Enable privacy screen", u8"Включить приватный экран") \ - X(privacy_disable, u8"关闭隐私屏 / 恢复操作", "Turn privacy off / recover", u8"Выключить / восстановить") \ + X(privacy_disable, u8"关闭隐私屏", "Disable privacy screen", u8"Выключить приватный экран") \ X(privacy_on, u8"隐私屏:已开启", "Privacy screen: on", u8"Приватный экран: включён") \ X(privacy_off, u8"隐私屏:已关闭", "Privacy screen: off", u8"Приватный экран: выключен") \ - X(privacy_unsupported, u8"隐私屏:被控端暂不支持", "Privacy screen: unavailable on this host", 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. Click the privacy screen button to turn it off and recover.", u8"Приватность не готова; нажмите кнопку приватного экрана для выключения и восстановления управления.") \ + X(privacy_unsupported, u8"隐私屏:不可用", "Privacy screen: unavailable", 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", \ @@ -250,8 +248,6 @@ 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"Чтобы отключить приватный экран, нажмите") \ 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 8d506627..2b9ad77a 100644 --- a/apps/desktop/src/gui/features/devices/session_device_manager.cpp +++ b/apps/desktop/src/gui/features/devices/session_device_manager.cpp @@ -32,7 +32,7 @@ SessionDeviceManager::SessionDeviceManager(GuiRuntime &owner) SpeakerCapturerFactory().Create()); }, [this](unsigned char *data, size_t size, const char *) { - if (!owner_.peer_ || !owner_.privacy_.RemoteAllowed()) return; + if (!owner_.peer_) return; MiniRtcAudioFrame frame{}; frame.data = reinterpret_cast(data); frame.size = size; @@ -79,9 +79,9 @@ int SessionDeviceManager::InitializeScreenCapturer() { screen_capturer_ = static_cast(screen_capturer_factory_->Create()); } + screen_capturer_->SetPrivacyController(&owner_.privacy_); #ifdef _WIN32 if (auto windows = dynamic_cast(screen_capturer_)) { - windows->SetPrivacyController(&owner_.privacy_); windows->SetCaptureMethod(owner_.config_center_->GetScreenCaptureMethod()); } #endif @@ -102,7 +102,6 @@ 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; @@ -165,9 +164,6 @@ 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; @@ -530,7 +526,6 @@ 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/input/keyboard_controller.cpp b/apps/desktop/src/gui/features/input/keyboard_controller.cpp index 0720ccb4..8b2ca70c 100644 --- a/apps/desktop/src/gui/features/input/keyboard_controller.cpp +++ b/apps/desktop/src/gui/features/input/keyboard_controller.cpp @@ -212,13 +212,11 @@ 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_.privacy_.Engaged() && !IsWindowsPrivacyDesktopAvailable()) { - owner_.privacy_.Fail("Secure desktop or locked session; remote key injection paused"); - return false; + owner_.privacy_.Fail("Secure desktop or locked session; privacy screen will turn off"); } - if (!owner_.privacy_.Engaged() && owner_.local_service_status_received_ && + if (owner_.local_service_status_received_ && IsSecureDesktopInteractionRequired(owner_.local_interactive_stage_)) { const std::string response = SendCrossDeskSecureDesktopKeyInput( key_code, is_down, scan_code, extended, 1000); @@ -252,7 +250,6 @@ bool KeyboardController::InjectRemoteKey(int key_code, bool is_down, } #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); } diff --git a/apps/desktop/src/gui/runtime/gui_runtime.h b/apps/desktop/src/gui/runtime/gui_runtime.h index b4261951..09ed85a0 100644 --- a/apps/desktop/src/gui/runtime/gui_runtime.h +++ b/apps/desktop/src/gui/runtime/gui_runtime.h @@ -98,7 +98,6 @@ class GuiRuntime : protected gui_detail::GuiState { 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 62ed24f8..0aaea420 100644 --- a/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp +++ b/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp @@ -88,8 +88,6 @@ 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); @@ -185,11 +183,9 @@ void PeerEventHandler::OnReceiveDataBuffer( 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; + runtime->privacy_.Fail("Secure desktop or lock screen; privacy screen will turn off"); } #endif - if (!runtime->privacy_.RemoteAllowed()) return; } if (remote_action.type == ControlType::service_status) { if (auto props = runtime->FindRemoteSession(remote_id)) { @@ -200,8 +196,7 @@ 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; + runtime->privacy_.Fail("Security shortcut requested; privacy screen will turn off"); } #if _WIN32 if (remote_action.c.flag == ServiceCommandFlag::send_sas) { @@ -306,7 +301,7 @@ void PeerEventHandler::OnReceiveDataBuffer( std::chrono::steady_clock::now(); } #if _WIN32 - if (!runtime->privacy_.Engaged() && runtime->local_service_status_received_ && + if (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 c98e3e2e..f8a33b69 100644 --- a/apps/desktop/src/gui/runtime/peer_event_handler.cpp +++ b/apps/desktop/src/gui/runtime/peer_event_handler.cpp @@ -405,14 +405,14 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status, runtime->show_connection_status_window_ = true; { std::unique_lock lock(runtime->connection_status_mutex_); -#ifdef _WIN32 +#if defined(_WIN32) || defined(__APPLE__) 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. + // Request optional privacy without delaying connection or capture. // 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. diff --git a/apps/desktop/src/gui/runtime/privacy_control_state.h b/apps/desktop/src/gui/runtime/privacy_control_state.h index 6b21d6cc..91181c55 100644 --- a/apps/desktop/src/gui/runtime/privacy_control_state.h +++ b/apps/desktop/src/gui/runtime/privacy_control_state.h @@ -18,38 +18,33 @@ struct PrivacyControlState { bool enabled = false; bool off = false; bool unsupported = false; - bool warning = false; - bool stale = false; - bool timeout = false; bool can_toggle = false; }; // Use the same host-confirmed state for presentation and command admission. -// An engaged or failed screen can still be disabled when its status goes stale. +// Missing confirmation or a failure makes only this toolbar control unavailable. inline PrivacyControlState GetPrivacyControlState( const PrivacyStatus& status, bool received, bool command_pending, uint64_t status_age_ms, uint64_t command_age_ms) { PrivacyControlState result; - result.timeout = command_pending && command_age_ms > 15000; - result.stale = received && status_age_ms > 5000; - const bool request_pending = command_pending && !result.timeout; - const bool fresh = received && !result.stale; + const bool timeout = command_pending && command_age_ms > 15000; + result.unsupported = timeout || + (received && (status_age_ms > 5000 || !status.supported || + status.state == PrivacyState::failed)); + if (result.unsupported) return result; + result.active = status.overlay_active || status.remote_paused || status.state == PrivacyState::on || status.state == PrivacyState::starting || status.state == PrivacyState::stopping || status.state == PrivacyState::failed; // A command in flight only gates repeated clicks; it adds no display state. - result.enabled = fresh && !result.timeout && + result.enabled = received && status.state == PrivacyState::on && status.overlay_active && !status.remote_paused; - result.off = fresh && !result.timeout && + result.off = received && status.state == PrivacyState::off && !result.active; - result.unsupported = received && !status.supported; - result.warning = result.timeout || result.stale || - (received && status.state == PrivacyState::failed); - result.can_toggle = !request_pending && - (result.active || (fresh && status.supported)); + result.can_toggle = !command_pending && (result.active || received); return result; } diff --git a/apps/desktop/src/gui/runtime/privacy_runtime.cpp b/apps/desktop/src/gui/runtime/privacy_runtime.cpp index 3da6148c..0083eb13 100644 --- a/apps/desktop/src/gui/runtime/privacy_runtime.cpp +++ b/apps/desktop/src/gui/runtime/privacy_runtime.cpp @@ -44,20 +44,6 @@ void GuiRuntime::HandlePrivacy() { 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_ && diff --git a/apps/desktop/src/platform/macos/gui/runtime/cursor_state_provider_mac.mm b/apps/desktop/src/platform/macos/gui/runtime/cursor_state_provider_mac.mm index cbf9c46f..739aa7ff 100644 --- a/apps/desktop/src/platform/macos/gui/runtime/cursor_state_provider_mac.mm +++ b/apps/desktop/src/platform/macos/gui/runtime/cursor_state_provider_mac.mm @@ -11,6 +11,7 @@ #include #include "runtime/cursor_position.h" +#include "../../privacy/privacy_cursor_mac.h" namespace crossdesk { namespace { @@ -161,7 +162,9 @@ bool CursorStateProvider::Sample(const std::vector& displays, #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wdeprecated-declarations" NSCursor* cursor = NSCursor.currentSystemCursor; - const bool visible = CGCursorIsVisible(); + const bool privacy_hidden = IsMacPrivacyCursorHidden(); + const bool visible = CGCursorIsVisible() || privacy_hidden; + if (!cursor && privacy_hidden) cursor = NSCursor.arrowCursor; #pragma clang diagnostic pop state->seq = 0; diff --git a/apps/desktop/src/platform/macos/input/keyboard/keyboard_capturer.cpp b/apps/desktop/src/platform/macos/input/keyboard/keyboard_capturer.cpp index ae9acf6c..f1a05fc6 100644 --- a/apps/desktop/src/platform/macos/input/keyboard/keyboard_capturer.cpp +++ b/apps/desktop/src/platform/macos/input/keyboard/keyboard_capturer.cpp @@ -4,6 +4,7 @@ #include #include "keyboard_converter.h" +#include "../mac_injected_event.h" #include "rd_log.h" namespace crossdesk { @@ -11,7 +12,6 @@ namespace crossdesk { static OnKeyAction g_on_key_action = nullptr; static void* g_user_ptr = nullptr; static std::unordered_map g_unmapped_keycode_to_vk; -constexpr int64_t kCrossDeskInjectedKeyboardEvent = 0x43524f5353444553; static int VkCodeFromUnicode(UniChar ch) { if (ch >= 'a' && ch <= 'z') { @@ -106,7 +106,7 @@ CGEventRef eventCallback(CGEventTapProxy proxy, CGEventType type, CGEventRef event, void* userInfo) { (void)proxy; if (CGEventGetIntegerValueField(event, kCGEventSourceUserData) == - kCrossDeskInjectedKeyboardEvent) { + kCrossDeskInjectedEvent) { return event; } if (!g_on_key_action) { @@ -309,7 +309,7 @@ int PlatformKeyboardCapturer::SendKeyboardCommand(int key_code, bool is_down, CGEventSetFlags(event, ToCGEventFlags(injected_flags)); CGEventSetIntegerValueField(event, kCGEventSourceUserData, - kCrossDeskInjectedKeyboardEvent); + kCrossDeskInjectedEvent); CGEventPost(kCGHIDEventTap, event); CFRelease(event); @@ -323,7 +323,7 @@ int PlatformKeyboardCapturer::SendKeyboardCommand(int key_code, bool is_down, return -1; } CGEventSetIntegerValueField(fn_release_event, kCGEventSourceUserData, - kCrossDeskInjectedKeyboardEvent); + kCrossDeskInjectedEvent); CGEventPost(kCGHIDEventTap, fn_release_event); CFRelease(fn_release_event); } diff --git a/apps/desktop/src/platform/macos/input/mac_injected_event.h b/apps/desktop/src/platform/macos/input/mac_injected_event.h new file mode 100644 index 00000000..e0f58565 --- /dev/null +++ b/apps/desktop/src/platform/macos/input/mac_injected_event.h @@ -0,0 +1,17 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-09-13 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _MAC_INJECTED_EVENT_H_ +#define _MAC_INJECTED_EVENT_H_ + +#include + +namespace crossdesk { +// Shared by the keyboard/mouse injectors and the privacy input tap. +inline constexpr int64_t kCrossDeskInjectedEvent = 0x43524f5353444553; +} + +#endif \ No newline at end of file diff --git a/apps/desktop/src/platform/macos/input/mouse/mouse_controller.cpp b/apps/desktop/src/platform/macos/input/mouse/mouse_controller.cpp index 97b03e4f..9bc0007c 100644 --- a/apps/desktop/src/platform/macos/input/mouse/mouse_controller.cpp +++ b/apps/desktop/src/platform/macos/input/mouse/mouse_controller.cpp @@ -1,4 +1,5 @@ #include "mouse_controller.h" +#include "../mac_injected_event.h" #include @@ -210,6 +211,8 @@ int PlatformMouseController::SendMouseCommand(RemoteAction remote_action, } if (mouse_event) { + CGEventSetIntegerValueField(mouse_event, kCGEventSourceUserData, + kCrossDeskInjectedEvent); CGEventPost(kCGHIDEventTap, mouse_event); CFRelease(mouse_event); } diff --git a/apps/desktop/src/platform/macos/privacy/privacy_backend_mac.mm b/apps/desktop/src/platform/macos/privacy/privacy_backend_mac.mm new file mode 100644 index 00000000..7ae47d78 --- /dev/null +++ b/apps/desktop/src/platform/macos/privacy/privacy_backend_mac.mm @@ -0,0 +1,400 @@ +/* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. */ + +#import +#import + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "privacy_backend.h" +#include "privacy_capture_state.h" +#include "privacy_cover_view.h" +#include "privacy_cursor_mac.h" +#include "rd_log.h" +#include "../input/mac_injected_event.h" + +// These windows never take keyboard focus. Remote clicks pass through them; +// physical input is intercepted independently on the controller's run loop. +@interface CrossDeskPrivacyPanel : NSPanel +@end +@implementation CrossDeskPrivacyPanel +- (BOOL)canBecomeKeyWindow { return NO; } +- (BOOL)canBecomeMainWindow { return NO; } +@end + +namespace crossdesk { +namespace { +using Clock = std::chrono::steady_clock; +std::mutex tasks_mutex; +std::deque> tasks; +std::mutex capture_mutex; +MacPrivacyCaptureState capture_state; + +void OnMain(std::function task) { + { + std::lock_guard lock(tasks_mutex); + tasks.push_back(std::move(task)); + } + dispatch_async(dispatch_get_main_queue(), ^{ FlushMacPrivacyTasks(); }); +} + +void PublishCovers(std::vector windows, uint32_t cursor_window = 0) { + { + std::lock_guard lock(capture_mutex); + capture_state.excluded_windows = std::move(windows); + capture_state.cursor_window = cursor_window; + ++capture_state.revision; + } + dispatch_async(dispatch_get_main_queue(), ^{ + [NSNotificationCenter.defaultCenter postNotificationName:@"CrossDeskPrivacyCoversChanged" + object:nil]; + }); +} + +struct Monitor { + CGDirectDisplayID id = 0; + CGRect bounds{}; // CoreGraphics desktop coordinates (points). + NSRect frame{}; // AppKit desktop coordinates (bottom left). + CrossDeskPrivacyPanel* __strong cover = nil; +}; +struct WindowState { + std::mutex mutex; + std::vector monitors; + bool recovered = true; + bool recovering = false; + bool covers_shown = false; + bool check_queued = false; + MacPrivacyCursor cursor; + std::string failure; + Clock::time_point checked = Clock::now(); +}; + +CrossDeskPrivacyPanel* MakePanel(NSRect frame, NSInteger level) { + auto* panel = [[CrossDeskPrivacyPanel alloc] + initWithContentRect:frame + styleMask:NSWindowStyleMaskBorderless | NSWindowStyleMaskNonactivatingPanel + backing:NSBackingStoreBuffered defer:NO]; + panel.releasedWhenClosed = NO; + panel.hidesOnDeactivate = NO; + panel.opaque = YES; + panel.backgroundColor = NSColor.blackColor; + panel.hasShadow = NO; + panel.ignoresMouseEvents = YES; + panel.level = level; + panel.animationBehavior = NSWindowAnimationBehaviorNone; + panel.collectionBehavior = NSWindowCollectionBehaviorCanJoinAllSpaces | + NSWindowCollectionBehaviorFullScreenAuxiliary | + NSWindowCollectionBehaviorStationary | + NSWindowCollectionBehaviorIgnoresCycle; + // Exclusion is explicit in ScreenCaptureKit. SharingNone alone does not + // exclude windows on recent macOS releases, and can hide them from discovery. + panel.sharingType = NSWindowSharingReadOnly; + return panel; +} + +bool IsSystemCursorPlane(NSDictionary* info, CGRect bounds) { + const auto layer = [info[(__bridge NSString*)kCGWindowLayer] intValue]; + // WindowServer's cursor plane also carries small recording indicators. + // Validate both its geometry and executable identity before ignoring it. + if (layer != CGWindowLevelForKey(kCGCursorWindowLevelKey) || + bounds.size.width <= 0 || bounds.size.height <= 0 || + bounds.size.width > 512 || bounds.size.height > 512) + return false; + char executable[PROC_PIDPATHINFO_MAXSIZE]{}; + const auto pid = [info[(__bridge NSString*)kCGWindowOwnerPID] intValue]; + static const std::string system_executable = [] { + char resolved[PROC_PIDPATHINFO_MAXSIZE]{}; + return realpath("/System/Library/PrivateFrameworks/SkyLight.framework/Resources/WindowServer", + resolved) ? std::string(resolved) : std::string(); + }(); + return proc_pidpath(pid, executable, sizeof(executable)) > 0 && + !system_executable.empty() && system_executable == executable; +} + +std::string CoverageFailure(const std::vector& monitors) { + uint32_t count = 0; + if (CGGetActiveDisplayList(0, nullptr, &count) != kCGErrorSuccess || !count) + return "No active macOS desktop; privacy screen unavailable"; + std::vector displays(count); + if (CGGetActiveDisplayList(count, displays.data(), &count) != kCGErrorSuccess || + count != monitors.size()) + return "Display topology changed; turn privacy off to recover"; + for (const auto& m : monitors) { + if (std::find(displays.begin(), displays.end(), m.id) == displays.end() || + !CGRectEqualToRect(CGDisplayBounds(m.id), m.bounds) || + !m.cover.visible || !m.cover.isOnActiveSpace || + !NSEqualRects(m.cover.frame, m.frame) || m.cover.alphaValue != 1.0) + return "Privacy cover or display geometry changed; privacy screen unavailable"; + } + NSDictionary* session = CFBridgingRelease(CGSessionCopyCurrentDictionary()); + if (!session || ![session[(__bridge NSString*)kCGSessionOnConsoleKey] boolValue]) + return "macOS session is not on console; privacy screen unavailable"; + + NSArray* windows = CFBridgingRelease(CGWindowListCopyWindowInfo( + kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements, + kCGNullWindowID)); + if (!windows) return "Unable to verify macOS privacy window order"; + for (const auto& m : monitors) { + bool found = false; + for (NSDictionary* info in windows) { + const auto window = [info[(__bridge NSString*)kCGWindowNumber] unsignedIntValue]; + if (window == m.cover.windowNumber) { found = true; break; } + if ([info[(__bridge NSString*)kCGWindowAlpha] doubleValue] <= 0) continue; + CGRect bounds{}; + if (!CGRectMakeWithDictionaryRepresentation( + (__bridge CFDictionaryRef)info[(__bridge NSString*)kCGWindowBounds], &bounds)) + return "Unable to verify a window above the privacy cover"; + if (CGRectIntersectsRect(bounds, m.bounds)) { + if (IsSystemCursorPlane(info, bounds)) continue; + LOG_WARN("macOS privacy overlap: window={}, pid={}, layer={}, alpha={}, " + "bounds=({}, {}, {}, {}), cover={}, cover_level={}", + window, + [info[(__bridge NSString*)kCGWindowOwnerPID] intValue], + [info[(__bridge NSString*)kCGWindowLayer] intValue], + [info[(__bridge NSString*)kCGWindowAlpha] doubleValue], + bounds.origin.x, bounds.origin.y, bounds.size.width, bounds.size.height, + m.cover.windowNumber, m.cover.level); + return "A system or application window overlaps the privacy cover; privacy screen unavailable"; + } + } + if (!found) return "Privacy cover disappeared; privacy screen unavailable"; + } + return {}; +} + +class MacPrivacyBackend final : public PrivacyBackend { + public: + MacPrivacyBackend() : state_(std::make_shared()) { + loop_ = CFRunLoopGetCurrent(); + CFRetain(loop_); + CFRunLoopSourceContext context{}; + wake_source_ = CFRunLoopSourceCreate(nullptr, 0, &context); + CFRunLoopAddSource(loop_, wake_source_, kCFRunLoopDefaultMode); + } + ~MacPrivacyBackend() override { + Recover(); + CFRunLoopRemoveSource(loop_, wake_source_, kCFRunLoopDefaultMode); + CFRelease(wake_source_); + CFRelease(loop_); + } + PrivacyCapabilities Query() override { + if (@available(macOS 14.0, *)) { + if (!CGPreflightScreenCaptureAccess()) + return {false, false, "Grant Screen Recording permission to use privacy screen"}; + if (!AXIsProcessTrusted()) + return {false, false, "Grant Accessibility permission to protect local input and enable local recovery"}; + if (!MacPrivacyCursor::Supported()) + return {false, false, "Background cursor control unavailable on this macOS version"}; + return {true, true, "macOS privacy screen available (ScreenCaptureKit)"}; + } + return {false, false, "Privacy screen requires macOS 14 or newer"}; + } + bool Enable(bool block_input, const PrivacyScreenText& text, std::string& error) override { + const auto caps = Query(); + if (!caps.overlay) { error = caps.reason; return false; } + block_input_ = block_input; + emergency_ = false; + tap_failed_ = false; + CGEventMask mask = CGEventMaskBit(kCGEventKeyDown) | CGEventMaskBit(kCGEventKeyUp) | + CGEventMaskBit(kCGEventFlagsChanged) | CGEventMaskBit(kCGEventLeftMouseDown) | + CGEventMaskBit(kCGEventLeftMouseUp) | CGEventMaskBit(kCGEventRightMouseDown) | + CGEventMaskBit(kCGEventRightMouseUp) | CGEventMaskBit(kCGEventMouseMoved) | + CGEventMaskBit(kCGEventLeftMouseDragged) | CGEventMaskBit(kCGEventRightMouseDragged) | + CGEventMaskBit(kCGEventOtherMouseDown) | CGEventMaskBit(kCGEventOtherMouseUp) | + CGEventMaskBit(kCGEventOtherMouseDragged) | CGEventMaskBit(kCGEventScrollWheel) | + CGEventMaskBit(14); // NX_SYSDEFINED: media/brightness keys. + tap_ = CGEventTapCreate(kCGHIDEventTap, kCGHeadInsertEventTap, + kCGEventTapOptionDefault, mask, Input, this); + if (!tap_) { error = "Cannot install macOS privacy input tap"; return false; } + tap_source_ = CFMachPortCreateRunLoopSource(nullptr, tap_, 0); + if (!tap_source_) { error = "Cannot create macOS privacy input run loop"; return false; } + CFRunLoopAddSource(loop_, tap_source_, kCFRunLoopDefaultMode); + CGEventTapEnable(tap_, true); + { + std::lock_guard lock(state_->mutex); + state_->recovered = false; + state_->failure.clear(); + state_->checked = Clock::now(); + } + auto state = state_; + OnMain([state, text] { + @autoreleasepool { + std::lock_guard lock(state->mutex); + if (!state->cursor.Prepare()) { + state->failure = "Cannot prepare the privacy cursor"; + return; + } + std::vector covers; + for (NSScreen* screen in NSScreen.screens) { + Monitor m; + m.id = [screen.deviceDescription[@"NSScreenNumber"] unsignedIntValue]; + m.bounds = CGDisplayBounds(m.id); + m.frame = screen.frame; + NSView* content = CreateMacPrivacyCoverView( + NSMakeRect(0, 0, m.frame.size.width, m.frame.size.height), + [NSString stringWithUTF8String:text.unlock_hint.c_str()]); + if (!content) { + state->failure = "Could not load the CrossDesk privacy screen logo"; + break; + } + m.cover = MakePanel(m.frame, CGWindowLevelForKey(kCGScreenSaverWindowLevelKey) + 1); + m.cover.contentView = content; + // Register transparent windows first. Make them opaque only after + // SCK has acknowledged exclusion, avoiding a black remote frame. + m.cover.alphaValue = 0; + [m.cover orderFrontRegardless]; + covers.push_back(static_cast(m.cover.windowNumber)); + state->monitors.push_back(std::move(m)); + } + PublishCovers(std::move(covers), state->cursor.WindowId()); + if (state->monitors.empty()) state->failure = "No active macOS desktop"; + state->checked = Clock::now(); + } + }); + return true; + } + void Recover() override { + if (tap_) { CGEventTapEnable(tap_, false); CFMachPortInvalidate(tap_); } + if (tap_source_) { + CFRunLoopRemoveSource(loop_, tap_source_, kCFRunLoopDefaultMode); + CFRelease(tap_source_); tap_source_ = nullptr; + } + if (tap_) { CFRelease(tap_); tap_ = nullptr; } + { + std::lock_guard lock(state_->mutex); + if (state_->recovered || state_->recovering) return; + state_->recovering = true; + } + auto state = state_; + OnMain([state] { + std::lock_guard lock(state->mutex); + for (auto& m : state->monitors) { + [m.cover orderOut:nil]; [m.cover close]; m.cover = nil; + } + state->monitors.clear(); + state->covers_shown = false; + state->cursor.Restore(); + PublishCovers({}); + state->failure.clear(); + state->recovering = false; + state->recovered = true; + }); + } + bool IsRecovered() const override { + std::lock_guard lock(state_->mutex); + return state_->recovered && !tap_; + } + bool RecoveryPending() const override { + std::lock_guard lock(state_->mutex); + return state_->recovering; + } + PrivacyHealth Poll() override { + if (emergency_) { emergency_ = false; return {{}, true}; } + if (tap_ && (tap_failed_ || !CGEventTapIsEnabled(tap_) || !AXIsProcessTrusted())) + return {"Local input protection interrupted; privacy screen unavailable", false}; + auto state = state_; + std::lock_guard lock(state->mutex); + if (state->recovered || state->recovering) return {}; + if (!state->failure.empty()) return {state->failure, false}; + const auto age = Clock::now() - state->checked; + if (age > std::chrono::seconds(1)) + return {"macOS privacy window checks stopped responding; privacy screen unavailable", false}; + if (!state->check_queued && age > std::chrono::milliseconds(100)) { + state->check_queued = true; + OnMain([state] { + std::lock_guard lock(state->mutex); + if (!state->recovered && !state->recovering && state->failure.empty()) { + const auto capture = GetMacPrivacyCaptureState(); + if (!state->covers_shown && !state->monitors.empty() && + capture.revision == capture.applied_revision) { + if (!state->cursor.Hide()) { + state->failure = "Cannot hide the local privacy cursor"; + state->checked = Clock::now(); + state->check_queued = false; + return; + } + for (auto& m : state->monitors) { + m.cover.alphaValue = 1; + [m.cover displayIfNeeded]; + [m.cover orderFrontRegardless]; + } + state->covers_shown = true; + } + if (state->covers_shown) { + state->failure = state->cursor.Healthy() ? CoverageFailure(state->monitors) + : "Cannot keep the local privacy cursor hidden"; + } + } + state->checked = Clock::now(); + state->check_queued = false; + }); + } + const auto capture = GetMacPrivacyCaptureState(); + return {{}, false, state->covers_shown && + capture.revision == capture.applied_revision}; + } + void WaitForEvents(unsigned timeout_ms) override { + @autoreleasepool { + CFRunLoopRunInMode(kCFRunLoopDefaultMode, timeout_ms / 1000.0, true); + } + } + void Wake() override { CFRunLoopSourceSignal(wake_source_); CFRunLoopWakeUp(loop_); } + private: + static CGEventRef Input(CGEventTapProxy, CGEventType type, CGEventRef event, void* context) { + auto* self = static_cast(context); + if (type == kCGEventTapDisabledByTimeout || type == kCGEventTapDisabledByUserInput) { + self->tap_failed_ = true; + CGEventTapEnable(self->tap_, true); // Keep the local recovery chord usable. + return event; + } + if (!event) return event; + if (CGEventGetIntegerValueField(event, kCGEventSourceUserData) == kCrossDeskInjectedEvent && + CGEventGetIntegerValueField(event, kCGEventSourceUnixProcessID) == getpid()) return event; + const auto flags = CGEventGetFlags(event); + const auto chord = kCGEventFlagMaskControl | kCGEventFlagMaskAlternate | kCGEventFlagMaskShift; + if (type == kCGEventKeyDown && (flags & chord) == chord && + CGEventGetIntegerValueField(event, kCGKeyboardEventKeycode) == 53) { + self->emergency_ = true; + return nullptr; + } + return self->block_input_ ? nullptr : event; + } + std::shared_ptr state_; + CFRunLoopRef loop_ = nullptr; + CFRunLoopSourceRef wake_source_ = nullptr; + CFMachPortRef tap_ = nullptr; + CFRunLoopSourceRef tap_source_ = nullptr; + bool block_input_ = false; + bool emergency_ = false; + bool tap_failed_ = false; +}; +} // namespace + +void FlushMacPrivacyTasks() { + if (![NSThread isMainThread]) return; + std::deque> pending; + { + std::lock_guard lock(tasks_mutex); + pending.swap(tasks); + } + @autoreleasepool { for (auto& task : pending) task(); } +} +MacPrivacyCaptureState GetMacPrivacyCaptureState() { + std::lock_guard lock(capture_mutex); + return capture_state; +} +void AcknowledgeMacPrivacyCapture(uint64_t revision) { + std::lock_guard lock(capture_mutex); + if (revision == capture_state.revision) capture_state.applied_revision = revision; +} +std::unique_ptr CreateMacPrivacyBackend() { + return std::make_unique(); +} +} // namespace crossdesk diff --git a/apps/desktop/src/platform/macos/privacy/privacy_capture_state.h b/apps/desktop/src/platform/macos/privacy/privacy_capture_state.h new file mode 100644 index 00000000..8ef906bf --- /dev/null +++ b/apps/desktop/src/platform/macos/privacy/privacy_capture_state.h @@ -0,0 +1,23 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-09-13 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ +#ifndef _PRIVACY_CAPTURE_STATE_H_ +#define _PRIVACY_CAPTURE_STATE_H_ + +#include +#include + +namespace crossdesk { +struct MacPrivacyCaptureState { + uint64_t revision = 0; + uint64_t applied_revision = 0; + std::vector excluded_windows; + uint32_t cursor_window = 0; +}; +MacPrivacyCaptureState GetMacPrivacyCaptureState(); +void AcknowledgeMacPrivacyCapture(uint64_t revision); +} + +#endif diff --git a/apps/desktop/src/platform/macos/privacy/privacy_cover_view.h b/apps/desktop/src/platform/macos/privacy/privacy_cover_view.h new file mode 100644 index 00000000..f2508f87 --- /dev/null +++ b/apps/desktop/src/platform/macos/privacy/privacy_cover_view.h @@ -0,0 +1,17 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-09-13 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _PRIVACY_COVER_VIEW_H_ +#define _PRIVACY_COVER_VIEW_H_ + +#import + +namespace crossdesk { +// Returns nil if the CrossDesk icon resource cannot be loaded. +NSView* CreateMacPrivacyCoverView(NSRect frame, NSString* unlock_hint); +} + +#endif \ No newline at end of file diff --git a/apps/desktop/src/platform/macos/privacy/privacy_cover_view.mm b/apps/desktop/src/platform/macos/privacy/privacy_cover_view.mm new file mode 100644 index 00000000..69ef457f --- /dev/null +++ b/apps/desktop/src/platform/macos/privacy/privacy_cover_view.mm @@ -0,0 +1,106 @@ +/* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. */ + +#include "privacy_cover_view.h" + +#include +#include + +namespace { +NSImage* LoadPrivacyLogo() { + // The macOS installer currently packages the icon as "crossedesk.icns". + for (NSString* name in @[@"crossdesk", @"crossedesk"]) { + NSString* path = [NSBundle.mainBundle pathForResource:name ofType:@"icns"]; + if (path) { + NSImage* image = [[NSImage alloc] initWithContentsOfFile:path]; + if (image.valid) return image; + } + } + // Support unbundled development builds, including launches from outside + // the repository. No source-tree path is embedded in the executable. + NSString* resource = @"apps/desktop/resources/macos/crossdesk.icns"; + NSString* directory = NSBundle.mainBundle.executablePath.stringByDeletingLastPathComponent; + NSMutableArray* paths = [NSMutableArray arrayWithObject: + [NSFileManager.defaultManager.currentDirectoryPath stringByAppendingPathComponent:resource]]; + for (int depth = 0; directory.length && depth <= 4; ++depth) { + [paths addObject:[directory stringByAppendingPathComponent:resource]]; + directory = directory.stringByDeletingLastPathComponent; + } + for (NSString* path in paths) { + NSImage* image = [[NSImage alloc] initWithContentsOfFile:path.stringByStandardizingPath]; + if (image.valid) return image; + } + return nil; +} + +NSAttributedString* Text(NSString* value, CGFloat size, NSFontWeight weight, NSColor* color) { + NSMutableParagraphStyle* paragraph = [[NSMutableParagraphStyle alloc] init]; + paragraph.lineBreakMode = NSLineBreakByWordWrapping; + return [[NSAttributedString alloc] initWithString:value attributes:@{ + NSFontAttributeName: [NSFont systemFontOfSize:size weight:weight], + NSForegroundColorAttributeName: color, + NSParagraphStyleAttributeName: paragraph}]; +} +constexpr NSStringDrawingOptions kTextOptions = + NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading; + +CGFloat TextHeight(NSAttributedString* text, CGFloat width) { + return std::ceil([text boundingRectWithSize:NSMakeSize(width, CGFLOAT_MAX) + options:kTextOptions].size.height); +} +} + +@interface CrossDeskPrivacyCoverView : NSView +@property(nonatomic, strong) NSImage* logo; +@property(nonatomic, copy) NSString* unlockHint; +@end + +@implementation CrossDeskPrivacyCoverView +- (BOOL)isOpaque { return YES; } +- (BOOL)isFlipped { return YES; } +- (void)drawRect:(NSRect)dirtyRect { + [NSColor.blackColor setFill]; + NSRectFill(self.bounds); + const CGFloat width = NSWidth(self.bounds), height = NSHeight(self.bounds); + if (width <= 0 || height <= 0) return; + // Match the Windows cover's sizes, colors and bottom-left alignment. + // AppKit coordinates are points; Retina backing scale is applied by AppKit. + const CGFloat scale = std::min({CGFloat(1), width / 640, height / 200}); + const CGFloat margin = 32 * scale, text_width = 398 * scale; + NSColor* foreground = [NSColor colorWithSRGBRed:245.0 / 255 green:247.0 / 255 + blue:250.0 / 255 alpha:1]; + NSColor* muted = [NSColor colorWithSRGBRed:181.0 / 255 green:190.0 / 255 + blue:204.0 / 255 alpha:1]; + NSAttributedString* brand = Text(@"CrossDesk", 14 * scale, NSFontWeightSemibold, foreground); + NSAttributedString* hint = Text(self.unlockHint, 16 * scale, NSFontWeightRegular, muted); + NSAttributedString* shortcut = Text(@"Control + Option + Shift + Esc", 24 * scale, + NSFontWeightSemibold, foreground); + const CGFloat logo_size = std::ceil(brand.size.width); + const CGFloat brand_height = TextHeight(brand, logo_size); + const CGFloat hint_height = TextHeight(hint, text_width); + const CGFloat shortcut_height = TextHeight(shortcut, text_width); + const CGFloat bottom = height - margin; + const CGFloat logo_top = bottom - brand_height - 6 * scale - logo_size; + [self.logo drawInRect:NSMakeRect(margin, logo_top, logo_size, logo_size) + fromRect:NSZeroRect operation:NSCompositingOperationSourceOver + fraction:1 respectFlipped:YES hints:@{NSImageHintInterpolation: @(NSImageInterpolationHigh)}]; + [brand drawWithRect:NSMakeRect(margin, bottom - brand_height, logo_size, brand_height) + options:kTextOptions]; + const CGFloat text_left = margin + logo_size + 24 * scale; + [hint drawWithRect:NSMakeRect(text_left, bottom - shortcut_height - 12 * scale - hint_height, + text_width, hint_height) options:kTextOptions]; + [shortcut drawWithRect:NSMakeRect(text_left, bottom - shortcut_height, + text_width, shortcut_height) options:kTextOptions]; +} +@end + +namespace crossdesk { +NSView* CreateMacPrivacyCoverView(NSRect frame, NSString* unlock_hint) { + NSImage* logo = LoadPrivacyLogo(); + if (!logo) return nil; + auto* view = [[CrossDeskPrivacyCoverView alloc] initWithFrame:frame]; + view.logo = logo; + view.unlockHint = unlock_hint ?: @"To turn off the privacy screen, press"; + view.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; + return view; +} +} diff --git a/apps/desktop/src/platform/macos/privacy/privacy_cursor_mac.h b/apps/desktop/src/platform/macos/privacy/privacy_cursor_mac.h new file mode 100644 index 00000000..7846b7bd --- /dev/null +++ b/apps/desktop/src/platform/macos/privacy/privacy_cursor_mac.h @@ -0,0 +1,35 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-09-13 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _PRIVACY_CURSOR_MAC_H_ +#define _PRIVACY_CURSOR_MAC_H_ + +#include +#include + +namespace crossdesk { +// Main-thread resource. Hides only for the lifetime of a local privacy cover; +// does not take focus, move the pointer, or suppress remote input. +class MacPrivacyCursor { + public: + MacPrivacyCursor(); + ~MacPrivacyCursor(); + static bool Supported(); + bool Prepare(); + bool Hide(); + bool Healthy() const; + uint32_t WindowId() const; + void Restore(); + private: + struct Impl; + std::unique_ptr impl_; +}; + +// Local hiding must not be reported as an invisible cursor to controllers. +bool IsMacPrivacyCursorHidden(); +} + +#endif \ No newline at end of file diff --git a/apps/desktop/src/platform/macos/privacy/privacy_cursor_mac.mm b/apps/desktop/src/platform/macos/privacy/privacy_cursor_mac.mm new file mode 100644 index 00000000..f26b550b --- /dev/null +++ b/apps/desktop/src/platform/macos/privacy/privacy_cursor_mac.mm @@ -0,0 +1,157 @@ +/* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. */ +#include "privacy_cursor_mac.h" + +#import +#import +#include +#include + +@interface CrossDeskPrivacyCursorPanel : NSPanel +@end +@implementation CrossDeskPrivacyCursorPanel +- (BOOL)canBecomeKeyWindow { return NO; } +- (BOOL)canBecomeMainWindow { return NO; } +@end + +namespace crossdesk { +namespace { +std::atomic hidden_by_privacy{false}; +struct CursorApi { + using Connection = int (*)(); + using Copy = CGError (*)(int, int, CFStringRef, CFTypeRef*); + using Set = CGError (*)(int, int, CFStringRef, CFTypeRef); + Connection connection = reinterpret_cast(dlsym(RTLD_DEFAULT, "CGSMainConnectionID")); + Copy copy = reinterpret_cast(dlsym(RTLD_DEFAULT, "CGSCopyConnectionProperty")); + Set set = reinterpret_cast(dlsym(RTLD_DEFAULT, "CGSSetConnectionProperty")); +}; +const CursorApi& Api() { static const CursorApi api; return api; } +CFStringRef const kBackgroundCursor = CFSTR("SetsCursorInBackground"); +} + +struct MacPrivacyCursor::Impl { + int connection = 0; + bool previous_background = false; + bool background_changed = false; + unsigned hide_count = 0; + unsigned visible_ticks = 0; + bool healthy = true; + NSTimer* __strong timer = nil; + CrossDeskPrivacyCursorPanel* __strong panel = nil; + NSImageView* __strong view = nil; + + void UpdateImage() { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + NSCursor* cursor = NSCursor.currentSystemCursor ?: NSCursor.arrowCursor; +#pragma clang diagnostic pop + NSImage* image = cursor.image; + if (!image || image.size.width <= 0 || image.size.height <= 0) return; + const NSPoint point = NSEvent.mouseLocation; + const NSSize size = image.size; + [panel setFrame:NSMakeRect(point.x - cursor.hotSpot.x, + point.y - size.height + cursor.hotSpot.y, size.width, size.height) display:NO]; + view.image = image; + [panel displayIfNeeded]; + } + + bool KeepHidden() { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + if (hide_count && !CGCursorIsVisible()) { visible_ticks = 0; return true; } +#pragma clang diagnostic pop + if (++visible_ticks > 6 || + CGDisplayHideCursor(kCGDirectMainDisplay) != kCGErrorSuccess) return false; + ++hide_count; + return true; + } +}; + +MacPrivacyCursor::MacPrivacyCursor() : impl_(std::make_unique()) {} +MacPrivacyCursor::~MacPrivacyCursor() { Restore(); } +bool MacPrivacyCursor::Supported() { + return Api().connection && Api().copy && Api().set; +} +bool MacPrivacyCursor::Prepare() { + if (impl_->panel) return true; + if (!Supported()) return false; + // A cursor sprite below the local cover is captured only for clients that + // request an embedded cursor. Desktop clients exclude it and use metadata. + impl_->panel = [[CrossDeskPrivacyCursorPanel alloc] initWithContentRect:NSMakeRect(0, 0, 32, 32) + styleMask:NSWindowStyleMaskBorderless | NSWindowStyleMaskNonactivatingPanel + backing:NSBackingStoreBuffered defer:NO]; + auto* panel = impl_->panel; + panel.releasedWhenClosed = NO; + panel.hidesOnDeactivate = NO; + panel.opaque = NO; + panel.backgroundColor = NSColor.clearColor; + panel.hasShadow = NO; + panel.ignoresMouseEvents = YES; + panel.level = CGWindowLevelForKey(kCGScreenSaverWindowLevelKey); + panel.animationBehavior = NSWindowAnimationBehaviorNone; + panel.collectionBehavior = NSWindowCollectionBehaviorCanJoinAllSpaces | + NSWindowCollectionBehaviorFullScreenAuxiliary | NSWindowCollectionBehaviorStationary | + NSWindowCollectionBehaviorIgnoresCycle; + panel.sharingType = NSWindowSharingReadOnly; + impl_->view = [[NSImageView alloc] initWithFrame:panel.contentView.bounds]; + impl_->view.imageScaling = NSImageScaleNone; + impl_->view.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; + panel.contentView = impl_->view; + panel.alphaValue = 0; + impl_->UpdateImage(); + [panel orderFrontRegardless]; + return panel != nil && panel.windowNumber > 0; +} +bool MacPrivacyCursor::Hide() { + if (impl_->timer) return true; + if (!Prepare()) return false; + impl_->connection = Api().connection(); + impl_->previous_background = false; + CFTypeRef previous = nullptr; + if (Api().copy(impl_->connection, impl_->connection, kBackgroundCursor, &previous) == kCGErrorSuccess && previous) + impl_->previous_background = CFEqual(previous, kCFBooleanTrue); + if (previous) CFRelease(previous); + // This connection-scoped WindowServer property is also used by Deskflow. + // Resolve it dynamically: an unavailable API disables optional privacy only. + if (Api().set(impl_->connection, impl_->connection, kBackgroundCursor, kCFBooleanTrue) != kCGErrorSuccess) + return false; + impl_->background_changed = true; + impl_->healthy = true; + impl_->visible_ticks = 0; + hidden_by_privacy = true; + if (!impl_->KeepHidden()) { Restore(); return false; } + impl_->UpdateImage(); + impl_->panel.alphaValue = 1; + auto* state = impl_.get(); + impl_->timer = [NSTimer timerWithTimeInterval:1.0 / 60 repeats:YES block:^(NSTimer*) { + if (state->healthy) state->healthy = state->KeepHidden(); + state->UpdateImage(); + }]; + [[NSRunLoop mainRunLoop] addTimer:impl_->timer forMode:NSRunLoopCommonModes]; + return true; +} +bool MacPrivacyCursor::Healthy() const { return impl_->timer && impl_->healthy; } +uint32_t MacPrivacyCursor::WindowId() const { + return impl_->panel ? static_cast(impl_->panel.windowNumber) : 0; +} +void MacPrivacyCursor::Restore() { + [impl_->timer invalidate]; + impl_->timer = nil; + [impl_->panel orderOut:nil]; + [impl_->panel close]; + impl_->panel = nil; + impl_->view = nil; + // Balance only our own successful hide calls, including re-hides after + // WindowServer changes cursor ownership when a remote click activates an app. + while (impl_->hide_count) { + CGDisplayShowCursor(kCGDirectMainDisplay); + --impl_->hide_count; + } + hidden_by_privacy = false; + if (impl_->background_changed) { + Api().set(impl_->connection, impl_->connection, kBackgroundCursor, + impl_->previous_background ? kCFBooleanTrue : kCFBooleanFalse); + impl_->background_changed = false; + } +} +bool IsMacPrivacyCursorHidden() { return hidden_by_privacy.load(); } +} diff --git a/apps/desktop/src/platform/macos/screen_capturer/screen_capturer_sck.cpp b/apps/desktop/src/platform/macos/screen_capturer/screen_capturer_sck.cpp index 7775d5b2..8da061bf 100644 --- a/apps/desktop/src/platform/macos/screen_capturer/screen_capturer_sck.cpp +++ b/apps/desktop/src/platform/macos/screen_capturer/screen_capturer_sck.cpp @@ -7,6 +7,12 @@ namespace crossdesk { ScreenCapturerSck::ScreenCapturerSck() {} ScreenCapturerSck::~ScreenCapturerSck() {} +void ScreenCapturerSck::SetPrivacyController(PrivacyController* privacy) { + privacy_ = privacy; + if (screen_capturer_sck_impl_) + screen_capturer_sck_impl_->SetPrivacyController(privacy); +} + int ScreenCapturerSck::Init(const int fps, cb_desktop_data cb) { if (cb) { on_data_ = cb; @@ -16,6 +22,7 @@ int ScreenCapturerSck::Init(const int fps, cb_desktop_data cb) { } screen_capturer_sck_impl_ = CreateScreenCapturerSck(); + screen_capturer_sck_impl_->SetPrivacyController(privacy_); const int ret = screen_capturer_sck_impl_->Init(fps, on_data_); if (ret != 0) { screen_capturer_sck_impl_.reset(); diff --git a/apps/desktop/src/platform/macos/screen_capturer/screen_capturer_sck.h b/apps/desktop/src/platform/macos/screen_capturer/screen_capturer_sck.h index 53493eaa..0d923eaf 100644 --- a/apps/desktop/src/platform/macos/screen_capturer/screen_capturer_sck.h +++ b/apps/desktop/src/platform/macos/screen_capturer/screen_capturer_sck.h @@ -25,6 +25,7 @@ class ScreenCapturerSck : public ScreenCapturer { public: int Init(const int fps, cb_desktop_data cb) override; + void SetPrivacyController(PrivacyController* privacy) override; int Destroy() override; int Start(bool show_cursor) override; int Stop() override; @@ -57,6 +58,7 @@ class ScreenCapturerSck : public ScreenCapturer { private: std::unique_ptr screen_capturer_sck_impl_; + PrivacyController* privacy_ = nullptr; }; } // namespace crossdesk -#endif \ No newline at end of file +#endif diff --git a/apps/desktop/src/platform/macos/screen_capturer/screen_capturer_sck_impl.mm b/apps/desktop/src/platform/macos/screen_capturer/screen_capturer_sck_impl.mm index e5de3401..5e147433 100644 --- a/apps/desktop/src/platform/macos/screen_capturer/screen_capturer_sck_impl.mm +++ b/apps/desktop/src/platform/macos/screen_capturer/screen_capturer_sck_impl.mm @@ -26,6 +26,9 @@ #include "display_info.h" #include #include "rd_log.h" +#include "privacy_controller.h" +#include "../privacy/privacy_capture_state.h" +#include using namespace crossdesk; @@ -110,13 +113,18 @@ API_AVAILABLE(macos(14.0)) - (instancetype)initWithCapturer:(ScreenCapturerSckImpl *)capturer; -- (void)onShareableContentCreated:(SCShareableContent *)content; +- (void)onShareableContentCreated:(SCShareableContent *)content generation:(uint64_t)generation + revision:(uint64_t)revision; // Called just before the capturer is destroyed. This avoids a dangling pointer, // and prevents any new calls into a deleted capturer. If any method-call on the // capturer is currently running on a different thread, this blocks until it // completes. - (void)releaseCapturer; +- (void)privacyCoversChanged:(NSNotification*)notification; +- (void)configurationFinished:(SCStream*)stream generation:(uint64_t)generation + revision:(uint64_t)revision complete:(BOOL)complete + configuration:(SCStreamConfiguration*)configuration error:(NSError*)error; @end class API_AVAILABLE(macos(14.0)) ScreenCapturerSckImpl : public ScreenCapturer { @@ -129,6 +137,7 @@ class API_AVAILABLE(macos(14.0)) ScreenCapturerSckImpl : public ScreenCapturer { public: int Init(const int fps, cb_desktop_data cb) override; + void SetPrivacyController(PrivacyController* privacy) override { privacy_ = privacy; } int Start(bool show_cursor) override; @@ -156,11 +165,30 @@ class API_AVAILABLE(macos(14.0)) ScreenCapturerSckImpl : public ScreenCapturer { bool capture_requested_ = false; bool invalid_stream_id_logged_ = false; bool native_output_logged_ = false; + PrivacyController* privacy_ = nullptr; + uint64_t configuration_generation_ = 0; + bool configuration_in_flight_ = false; + bool reconfigure_pending_ = false; + SCStreamConfiguration* __strong applied_configuration_ = nil; public: // Called by SckHelper when shareable content is returned by ScreenCaptureKit. `content` will be // nil if an error occurred. May run on an arbitrary thread. - void OnShareableContentCreated(SCShareableContent *content); + void OnShareableContentCreated(SCShareableContent *content, uint64_t generation, + uint64_t revision); + void ConfigurationFinished(SCStream* source, uint64_t generation, uint64_t revision, + bool complete, SCStreamConfiguration* configuration, + NSError* error); + void RetryPrivacyFilter(); + void PrivacyCoversChanged() { StartOrReconfigureCapturer(); } + void CaptureStopped(SCStream* source, NSError* error) { + { + std::lock_guard lock(lock_); + if (!capture_requested_ || source != stream_) return; + } + LOG_ERROR("ScreenCaptureKit stream stopped: {}", NSErrorToString(error)); + if (privacy_) privacy_->Fail("ScreenCaptureKit stream stopped"); + } // Called by SckHelper to notify of a newly captured frame. May run on an arbitrary thread. // void OnNewIOSurface(IOSurfaceRef io_surface, CFDictionaryRef attachment); void OnNewCVPixelBuffer(CVPixelBufferRef pixelBuffer, CFDictionaryRef attachment); @@ -389,6 +417,7 @@ int ScreenCapturerSckImpl::Start(bool show_cursor) { capture_requested_ = true; invalid_stream_id_logged_ = false; } + if (privacy_) privacy_->CaptureChanged(true); StartOrReconfigureCapturer(); return 0; } @@ -446,6 +475,7 @@ int ScreenCapturerSckImpl::ResetToInitialMonitor() { } int ScreenCapturerSckImpl::Destroy() { + if (privacy_) privacy_->CaptureChanged(false); SckHelper *helper_to_release = nil; { std::lock_guard lock(lock_); @@ -455,6 +485,9 @@ int ScreenCapturerSckImpl::Destroy() { stream_ = nil; } capture_requested_ = false; + configuration_in_flight_ = reconfigure_pending_ = false; + applied_configuration_ = nil; + ++configuration_generation_; current_display_ = 0; current_stream_id_.clear(); permanent_error_ = false; @@ -469,8 +502,12 @@ int ScreenCapturerSckImpl::Destroy() { } int ScreenCapturerSckImpl::Stop() { + if (privacy_) privacy_->CaptureChanged(false); std::lock_guard lock(lock_); capture_requested_ = false; + configuration_in_flight_ = reconfigure_pending_ = false; + applied_configuration_ = nil; + ++configuration_generation_; if (stream_) { LOG_INFO("Stopping stream"); [stream_ stopCaptureWithCompletionHandler:nil]; @@ -481,24 +518,36 @@ int ScreenCapturerSckImpl::Stop() { return 0; } -void ScreenCapturerSckImpl::OnShareableContentCreated(SCShareableContent *content) { +void ScreenCapturerSckImpl::OnShareableContentCreated(SCShareableContent *content, + uint64_t generation, uint64_t revision) { { std::lock_guard lock(lock_); - if (!capture_requested_) { + if (!capture_requested_ || generation != configuration_generation_) { LOG_INFO("Ignoring stale ScreenCaptureKit display refresh after stop"); return; } } + // The window list belongs to the privacy revision present when the request + // was issued. A newer cover must be enumerated by a new request. + if (GetMacPrivacyCaptureState().revision != revision) { + StartOrReconfigureCapturer(); + return; + } + if (!content) { LOG_ERROR("getShareableContent failed"); - permanent_error_ = true; + if (privacy_) privacy_->Fail("Could not refresh ScreenCaptureKit privacy content"); + std::lock_guard lock(lock_); + if (!stream_) permanent_error_ = true; return; } if (!content.displays || content.displays.count == 0) { LOG_ERROR("getShareableContent returned no displays"); - permanent_error_ = true; + if (privacy_) privacy_->Fail("No shareable displays available for privacy"); + std::lock_guard lock(lock_); + if (!stream_) permanent_error_ = true; return; } @@ -506,7 +555,7 @@ void ScreenCapturerSckImpl::OnShareableContentCreated(SCShareableContent *conten bool show_cursor = false; { std::lock_guard lock(lock_); - if (!capture_requested_) return; + if (!capture_requested_ || generation != configuration_generation_) return; int logical_index = current_monitor_index_; if (logical_index < 0 || @@ -576,8 +625,40 @@ void ScreenCapturerSckImpl::OnShareableContentCreated(SCShareableContent *conten return; } + const auto privacy_capture = GetMacPrivacyCaptureState(); + // The cursor sprite is included only when this stream embeds the cursor. + // Other controllers receive the unchanged cursor metadata separately. + auto excluded_ids = privacy_capture.excluded_windows; + if (!show_cursor && privacy_capture.cursor_window) + excluded_ids.push_back(privacy_capture.cursor_window); + NSMutableArray* excluded = [NSMutableArray array]; + for (SCWindow* window in content.windows) { + if (std::find(excluded_ids.begin(), excluded_ids.end(), window.windowID) != excluded_ids.end()) + [excluded addObject:window]; + } + // Newly registered transparent covers may need another enumeration. Never + // acknowledge a partial exclusion or remove an active cover's filter. + const bool privacy_filter_complete = excluded.count == excluded_ids.size(); + LOG_INFO("macOS privacy filter: generation={}, revision={}, requested={}, excluded={}", + generation, privacy_capture.revision, excluded_ids.size(), + excluded.count); + if (!privacy_filter_complete) { + bool have_stream; + { std::lock_guard lock(lock_); have_stream = stream_ != nil; } + if (privacy_capture.applied_revision == privacy_capture.revision) { + if (privacy_) privacy_->Fail("ScreenCaptureKit could not enumerate every privacy cover"); + return; // Preserve the existing exclusion until recovery removes covers. + } + if (have_stream) { + RetryPrivacyFilter(); + return; // Transparent covers are still being registered by WindowServer. + } + // Start ordinary capture even if a newly created, still-transparent cover + // is not enumerable yet. Retry exclusion after start; never acknowledge it. + [excluded removeAllObjects]; + } SCContentFilter *filter = [[SCContentFilter alloc] initWithDisplay:captured_display - excludingWindows:@[]]; + excludingWindows:excluded]; if (!filter) { LOG_ERROR("Failed to create SCContentFilter"); permanent_error_ = true; @@ -598,13 +679,39 @@ void ScreenCapturerSckImpl::OnShareableContentCreated(SCShareableContent *conten config.captureResolution = SCCaptureResolutionAutomatic; config.minimumFrameInterval = CMTimeMake(1, fps_); - std::lock_guard lock(lock_); - if (!capture_requested_) return; + std::unique_lock lock(lock_); + if (!capture_requested_ || generation != configuration_generation_) return; + if (configuration_in_flight_) { reconfigure_pending_ = true; return; } + if (GetMacPrivacyCaptureState().revision != revision) { + lock.unlock(); + RetryPrivacyFilter(); + return; + } + configuration_in_flight_ = true; if (stream_) { - LOG_INFO("Updating stream configuration"); - [stream_ updateContentFilter:filter completionHandler:nil]; - [stream_ updateConfiguration:config completionHandler:nil]; + SckHelper* helper = helper_; + SCStream* source = stream_; + auto finished = ^(NSError* error) { + [helper configurationFinished:source generation:generation revision:revision + complete:privacy_filter_complete configuration:config error:error]; + }; + // Apply configuration before its filter and acknowledge the final update. + // Privacy-only toggles do not need a configuration change. + const bool same_config = applied_configuration_ && + applied_configuration_.width == config.width && + applied_configuration_.height == config.height && + applied_configuration_.showsCursor == config.showsCursor && + applied_configuration_.pixelFormat == config.pixelFormat && + CMTimeCompare(applied_configuration_.minimumFrameInterval, config.minimumFrameInterval) == 0; + if (same_config) { + [source updateContentFilter:filter completionHandler:finished]; + } else { + [source updateConfiguration:config completionHandler:^(NSError* error) { + if (error) finished(error); + else [source updateContentFilter:filter completionHandler:finished]; + }]; + } } else { stream_ = [[SCStream alloc] initWithFilter:filter configuration:config delegate:helper_]; @@ -618,28 +725,62 @@ void ScreenCapturerSckImpl::OnShareableContentCreated(SCShareableContent *conten error:&add_stream_output_error]; if (!add_stream_output_result) { + configuration_in_flight_ = false; stream_ = nil; LOG_ERROR("addStreamOutput failed: {}", NSErrorToString(add_stream_output_error)); permanent_error_ = true; return; } + SckHelper* helper = helper_; + SCStream* source = stream_; auto handler = ^(NSError *error) { - if (error) { - // It should be safe to access `this` here, because the C++ destructor - // calls stopCaptureWithCompletionHandler on the stream, which cancels - // this handler. - permanent_error_ = true; - LOG_ERROR("startCaptureWithCompletionHandler failed: {}", NSErrorToString(error)); - } else { - LOG_INFO("Capture started"); - } + [helper configurationFinished:source generation:generation revision:revision + complete:privacy_filter_complete configuration:config error:error]; }; [stream_ startCaptureWithCompletionHandler:handler]; } } +void ScreenCapturerSckImpl::RetryPrivacyFilter() { + SckHelper* helper; + { + std::lock_guard lock(lock_); + if (!capture_requested_) return; + helper = helper_; + } + // The retained helper safely ignores this callback after Destroy(). + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 50 * NSEC_PER_MSEC), + dispatch_get_main_queue(), ^{ [helper privacyCoversChanged:nil]; }); +} + +void ScreenCapturerSckImpl::ConfigurationFinished(SCStream* source, uint64_t generation, + uint64_t revision, bool complete, + SCStreamConfiguration* configuration, + NSError* error) { + bool refresh = false; + { + std::lock_guard lock(lock_); + if (!capture_requested_ || source != stream_) return; + configuration_in_flight_ = false; + const auto current = GetMacPrivacyCaptureState(); + refresh = reconfigure_pending_ || current.revision != revision; + reconfigure_pending_ = false; + if (!error) { + applied_configuration_ = configuration; + if (complete && !refresh && generation == configuration_generation_) + AcknowledgeMacPrivacyCapture(revision); + refresh = refresh || !complete; + } + LOG_INFO("macOS privacy filter completed: generation={}, revision={}, complete={}, refresh={}, error={}", + generation, revision, complete, refresh, NSErrorToString(error)); + } + if (error && privacy_) + privacy_->Fail("ScreenCaptureKit privacy filter: " + NSErrorToString(error)); + if (refresh) RetryPrivacyFilter(); +} + void ScreenCapturerSckImpl::OnNewCVPixelBuffer(CVPixelBufferRef pixelBuffer, CFDictionaryRef attachment) { (void)attachment; @@ -727,7 +868,18 @@ void ScreenCapturerSckImpl::StartOrReconfigureCapturer() { } } - SckHelper *local_helper = helper_; + SckHelper *local_helper; + uint64_t generation; + uint64_t revision; + { + std::lock_guard lock(lock_); + if (!capture_requested_) return; + if (configuration_in_flight_) { reconfigure_pending_ = true; return; } + reconfigure_pending_ = false; + local_helper = helper_; + generation = ++configuration_generation_; + revision = GetMacPrivacyCaptureState().revision; + } if (!local_helper) { LOG_ERROR("Cannot reconfigure capturer: helper is null"); return; @@ -736,12 +888,14 @@ void ScreenCapturerSckImpl::StartOrReconfigureCapturer() { auto handler = ^(SCShareableContent *content, NSError *error) { if (error) { LOG_ERROR("getShareableContent failed: {}", NSErrorToString(error)); - [local_helper onShareableContentCreated:nil]; + [local_helper onShareableContentCreated:nil generation:generation revision:revision]; return; } - [local_helper onShareableContentCreated:content]; + [local_helper onShareableContentCreated:content generation:generation revision:revision]; }; - [SCShareableContent getShareableContentWithCompletionHandler:handler]; + [SCShareableContent getShareableContentExcludingDesktopWindows:NO + onScreenWindowsOnly:NO + completionHandler:handler]; } @implementation SckHelper { @@ -755,14 +909,28 @@ void ScreenCapturerSckImpl::StartOrReconfigureCapturer() { self = [super init]; if (self) { _capturer = capturer; + [NSNotificationCenter.defaultCenter addObserver:self selector:@selector(privacyCoversChanged:) + name:@"CrossDeskPrivacyCoversChanged" object:nil]; } return self; } -- (void)onShareableContentCreated:(SCShareableContent *)content { +- (void)privacyCoversChanged:(NSNotification*)notification { + std::lock_guard lock(_capturer_lock); + if (_capturer) _capturer->PrivacyCoversChanged(); +} +- (void)configurationFinished:(SCStream*)stream generation:(uint64_t)generation + revision:(uint64_t)revision complete:(BOOL)complete + configuration:(SCStreamConfiguration*)configuration error:(NSError*)error { + std::lock_guard lock(_capturer_lock); + if (_capturer) + _capturer->ConfigurationFinished(stream, generation, revision, complete, configuration, error); +} +- (void)onShareableContentCreated:(SCShareableContent *)content generation:(uint64_t)generation + revision:(uint64_t)revision { std::lock_guard lock(_capturer_lock); if (_capturer) { - _capturer->OnShareableContentCreated(content); + _capturer->OnShareableContentCreated(content, generation, revision); } else { LOG_ERROR("Invalid capturer"); } @@ -797,10 +965,16 @@ void ScreenCapturerSckImpl::StartOrReconfigureCapturer() { } - (void)releaseCapturer { + [NSNotificationCenter.defaultCenter removeObserver:self]; std::lock_guard lock(_capturer_lock); _capturer = nullptr; } +- (void)stream:(SCStream*)stream didStopWithError:(NSError*)error { + std::lock_guard lock(_capturer_lock); + if (_capturer) _capturer->CaptureStopped(stream, error); +} + @end std::unique_ptr ScreenCapturerSck::CreateScreenCapturerSck() { 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 e28c2203..caf14954 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 @@ -115,8 +115,7 @@ 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; + privacy_.Fail("Security shortcut requested; privacy screen will turn off"); } const std::string response = QueryCrossDeskService("sas", kWindowsServiceSasTimeoutMs); diff --git a/apps/desktop/src/platform/windows/privacy/privacy_backend_win.cpp b/apps/desktop/src/platform/windows/privacy/privacy_backend_win.cpp index a0d38114..810e33e8 100644 --- a/apps/desktop/src/platform/windows/privacy/privacy_backend_win.cpp +++ b/apps/desktop/src/platform/windows/privacy/privacy_backend_win.cpp @@ -11,7 +11,6 @@ #include #include -#include #include #include "../windows_thread_dpi.h" @@ -19,8 +18,6 @@ #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 "privacy_window_visibility.h" #include "rd_log.h" @@ -118,7 +115,7 @@ class WindowsPrivacyBackend final : public PrivacyBackend { if (!PrivacyBandGuard::Available(high_band_error)) return {false, false, high_band_error}; return {true, true, - "High-band privacy; live capture exclusion will be verified"}; + "High-band privacy screen available"}; } bool Enable(bool block_input, const PrivacyScreenText& text, @@ -169,19 +166,9 @@ class WindowsPrivacyBackend final : public PrivacyBackend { 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()); + if (band_guard_.stopped()) windows_.clear(); input_guard_.Stop(); block_input_ = false; if (hotkey_) { @@ -231,12 +218,12 @@ class WindowsPrivacyBackend final : public PrivacyBackend { health_error_ = Error("MsgWaitForMultipleObjectsEx privacy thread"); } - PrivacyHealth Poll(bool force_check = false) override { + PrivacyHealth Poll() 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"; + "Privacy message thread is exiting"; TranslateMessage(&message); DispatchMessageW(&message); } @@ -251,7 +238,7 @@ class WindowsPrivacyBackend final : public PrivacyBackend { 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_ && + if (!layout_changed_ && GetTickCount64() - last_check_ < 100) { if (coverage_dirty_ && last_health_.failure.empty()) { coverage_dirty_ = false; @@ -273,8 +260,8 @@ class WindowsPrivacyBackend final : public PrivacyBackend { 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.", + "Secure desktop, lock screen or unavailable input desktop; " + "privacy screen will turn off", false}; } std::string cursor_error; @@ -283,8 +270,7 @@ class WindowsPrivacyBackend final : public PrivacyBackend { if (FAILED(SHQueryUserNotificationState(&shell_state)) || shell_state == QUNS_RUNNING_D3D_FULL_SCREEN) { return { - "Exclusive fullscreen or unknown display presentation; remote " - "operation paused", + "Exclusive fullscreen or unknown display presentation", false}; } MonitorEnumeration monitors; @@ -304,16 +290,14 @@ class WindowsPrivacyBackend final : public PrivacyBackend { }); 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. + // Retain old covers until the monitor check completes, then release + // privacy so capture can refresh its display mapping independently. std::string error; if (!ReconcileMonitors(error)) health_error_ = error; else health_error_ = - "Display layout/DPI changed; coverage rebuilt, remote operation " - "paused. Turn privacy off and reconnect to refresh display " - "mapping."; + "Display layout/DPI changed; privacy screen will turn off"; } if (!health_error_.empty()) return {health_error_, false}; BOOL composed = FALSE; @@ -349,8 +333,7 @@ class WindowsPrivacyBackend final : public PrivacyBackend { sizeof(cloaked))) || cloaked) { return { - "Privacy window visibility, geometry or capture affinity was lost; " - "remote operation paused", + "Privacy window visibility, geometry or capture affinity was lost", false}; } } @@ -369,7 +352,7 @@ class WindowsPrivacyBackend final : public PrivacyBackend { 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; + return above == own.cover; })) continue; RECT bounds{}, intersection{}; @@ -390,8 +373,7 @@ class WindowsPrivacyBackend final : public PrivacyBackend { now - last_obstruction_log_ >= 1000) { LOG_WARN( "Potential privacy cover overlap: class={}, process={}, " - "band={}, bounds=({}, {})-({}, {}), exstyle=0x{:x}; " - "remote operation paused", + "band={}, bounds=({}, {})-({}, {}), exstyle=0x{:x}", window_class, process_id, above_band, bounds.left, bounds.top, bounds.right, bounds.bottom, GetWindowLongPtrW(above, GWL_EXSTYLE)); @@ -399,10 +381,9 @@ class WindowsPrivacyBackend final : public PrivacyBackend { last_obstruction_log_ = now; } // HWND order cannot prove compositor order, but this window may - // display content. Retain protection and require explicit recovery. + // display content. Report failure so optional privacy is released. return { - "A visible window may overlap privacy coverage; " - "remote operation paused. Turn privacy off to recover.", + "A visible window may overlap privacy coverage", false}; } } @@ -410,100 +391,11 @@ class WindowsPrivacyBackend final : public PrivacyBackend { 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 @@ -537,22 +429,6 @@ class WindowsPrivacyBackend final : public PrivacyBackend { 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, @@ -576,26 +452,18 @@ class WindowsPrivacyBackend final : public PrivacyBackend { error = "High-band broker has not covered every physical monitor"; return false; } - windows_.push_back({rect, reinterpret_cast(cover->hwnd), nullptr}); + windows_.push_back({rect, reinterpret_cast(cover->hwnd)}); LOG_INFO( "Privacy high-band cover ready: bounds=({}, {})-({}, {}), " "affinity=0x11, alpha=255", rect.left, rect.top, rect.right, rect.bottom); } - 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; - } + // Cover windows are owned and released by the broker. + windows_.erase(std::remove_if(windows_.begin(), windows_.end(), + [&](const auto& window) { + return std::none_of(monitors.rects.begin(), monitors.rects.end(), + [&](const RECT& rect) { return SameRect(window.rect, rect); }); + }), windows_.end()); return true; } @@ -619,14 +487,6 @@ class WindowsPrivacyBackend final : public PrivacyBackend { } 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; @@ -641,7 +501,7 @@ class WindowsPrivacyBackend final : public PrivacyBackend { hwnd == self->sink_ || GetAncestor(hwnd, GA_ROOT) != hwnd) return; for (const auto& w : self->windows_) - if (hwnd == w.cover || hwnd == w.probe) return; + if (hwnd == w.cover) 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( diff --git a/apps/desktop/src/platform/windows/privacy/privacy_cursor_guard.cpp b/apps/desktop/src/platform/windows/privacy/privacy_cursor_guard.cpp index 6da5bbd6..feb387c0 100644 --- a/apps/desktop/src/platform/windows/privacy/privacy_cursor_guard.cpp +++ b/apps/desktop/src/platform/windows/privacy/privacy_cursor_guard.cpp @@ -135,7 +135,7 @@ bool PrivacyCursorGuard::Healthy(std::string& error) { if (restore_pending_ || (process_ && !helper_failure_reported_)) RestoreAfterFailure(error); helper_failure_reported_ = true; - error = "System cursor helper exited; remote operation paused"; + error = "System cursor helper exited; privacy screen will turn off"; return false; } diff --git a/apps/desktop/src/platform/windows/privacy/privacy_input_guard.h b/apps/desktop/src/platform/windows/privacy/privacy_input_guard.h index 7912db1b..9e2b46f6 100644 --- a/apps/desktop/src/platform/windows/privacy/privacy_input_guard.h +++ b/apps/desktop/src/platform/windows/privacy/privacy_input_guard.h @@ -19,7 +19,7 @@ namespace crossdesk { // The owner controls lifecycle; the dedicated message thread owns LL hooks. -// Rendering, capture verification and session queries never run on this thread. +// Rendering and session queries never run on this thread. class PrivacyInputGuard { public: ~PrivacyInputGuard(); diff --git a/apps/desktop/src/platform/windows/privacy/privacy_probe_renderer.cpp b/apps/desktop/src/platform/windows/privacy/privacy_probe_renderer.cpp deleted file mode 100644 index f1575303..00000000 --- a/apps/desktop/src/platform/windows/privacy/privacy_probe_renderer.cpp +++ /dev/null @@ -1,142 +0,0 @@ -/* - * @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 deleted file mode 100644 index 46f9706e..00000000 --- a/apps/desktop/src/platform/windows/privacy/privacy_probe_renderer.h +++ /dev/null @@ -1,32 +0,0 @@ -/* - * @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 c896d115..f31eea30 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 @@ -330,8 +330,7 @@ void ScreenCapturerDxgi::CaptureLoop() { 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. + // successfully acquired for this monitor to keep delivering static frames. if (cached_frame_valid && cached_generation == duplication_generation_ && cached_monitor == monitor_index_.load() && callback_ && nv12_frame_) { const auto stream_id = MakeDisplayStreamId(cached_monitor); @@ -342,7 +341,7 @@ void ScreenCapturerDxgi::CaptureLoop() { continue; } // Never replay a cached image after an acquisition/conversion failure or - // a backend rebuild. Normal error/reset handling must pause privacy. + // a backend rebuild. Normal error/reset handling releases optional privacy. cached_frame_valid = false; if (FAILED(hr)) { LOG_ERROR("DXGI: AcquireNextFrame failed, hr={}", (int)hr); 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 b6639612..e727324d 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 @@ -339,7 +339,7 @@ ScreenCapturerWin::~ScreenCapturerWin() { Destroy(); } void ScreenCapturerWin::NotifyPrivacyCapture(bool running) { if (privacy_) - privacy_->CaptureChanged(CaptureBackendName(impl_.get()), running); + privacy_->CaptureChanged(running); } int ScreenCapturerWin::Init(const int fps, cb_desktop_data cb) { @@ -358,7 +358,7 @@ int ScreenCapturerWin::Init(const int fps, cb_desktop_data cb) { const char* reported_stream_id, const MiniRtcNativeVideoFrame* native_frame) { if (size == ScreenCapturer::kBackendReset) { - if (privacy_) privacy_->CaptureInterrupted(); + if (privacy_) privacy_->Fail("Capture restarted; privacy screen will turn off"); return; } if (secure_desktop_capture_active_.load(std::memory_order_relaxed)) { @@ -367,7 +367,6 @@ int ScreenCapturerWin::Init(const int fps, cb_desktop_data cb) { 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); @@ -381,21 +380,9 @@ 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 (privacy_ && privacy_->Engaged() && !IsWindowsPrivacyDesktopAvailable()) { + privacy_->Fail("Secure or unavailable desktop; privacy screen will turn off"); } if (mapped_stream_id.empty()) { if (!invalid_stream_id_logged_.exchange(true, @@ -477,13 +464,12 @@ void ScreenCapturerWin::EmitCapturedFrame( unsigned char* data, int size, int width, int height, 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. + // Secure-desktop capture takes priority; remove privacy without dropping + // the helper frame or delaying the remote session. if (from_secure_desktop && privacy_ && privacy_->Engaged()) { - privacy_->Fail("Secure desktop helper frame arrived during privacy; remote operation paused"); - return; + privacy_->Fail("Secure desktop helper frame arrived; privacy screen will turn off"); } - if (!cb_orig_ || (privacy_ && !privacy_->RemoteAllowed())) { + if (!cb_orig_) { return; } @@ -650,12 +636,11 @@ 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"); + privacy_->Fail("Display switch failed; privacy screen will turn off"); } return ret; } @@ -1128,13 +1113,11 @@ 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. + // Privacy covers belong to the ordinary desktop. Drop privacy on a + // secure desktop, while continuing the normal helper capture path. 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; + privacy_->Fail("Secure desktop or lock screen; privacy screen will turn off. Privacy cannot cover Windows security UI."); } if (paused_.load(std::memory_order_relaxed)) { std::this_thread::sleep_for(std::chrono::milliseconds(10)); 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 9d7226bd..ee916f69 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 @@ -45,7 +45,7 @@ class ScreenCapturerWin : public ScreenCapturer { int ResetToInitialMonitor() override; std::vector GetDisplayInfoList() override; - void SetPrivacyController(PrivacyController* privacy) { privacy_ = privacy; } + void SetPrivacyController(PrivacyController* privacy) override { privacy_ = privacy; } // Set before Init(), while capture is stopped. void SetCaptureMethod(ScreenCaptureMethod method) { capture_method_ = method; } diff --git a/apps/desktop/src/privacy/privacy_backend.h b/apps/desktop/src/privacy/privacy_backend.h index 7d31bd6a..3865cecb 100644 --- a/apps/desktop/src/privacy/privacy_backend.h +++ b/apps/desktop/src/privacy/privacy_backend.h @@ -7,14 +7,12 @@ #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"; }; @@ -27,12 +25,13 @@ struct PrivacyCapabilities { struct PrivacyHealth { std::string failure; bool emergency_exit = false; + bool ready = true; }; // 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. +// AppKit work is queued to the main thread without waiting under this mutex. class PrivacyBackend { public: virtual ~PrivacyBackend() = default; @@ -41,23 +40,23 @@ class PrivacyBackend { std::string& error) = 0; virtual void Recover() = 0; virtual bool IsRecovered() const = 0; - virtual PrivacyHealth Poll(bool force_check = false) = 0; + virtual bool RecoveryPending() const { return false; } + virtual PrivacyHealth Poll() = 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 +#ifdef __APPLE__ +std::unique_ptr CreateMacPrivacyBackend(); +// Flush queued AppKit cleanup after joining the controller at app shutdown. +void FlushMacPrivacyTasks(); +#endif } // namespace crossdesk diff --git a/apps/desktop/src/privacy/privacy_controller.cpp b/apps/desktop/src/privacy/privacy_controller.cpp index f5aef586..09474c4c 100644 --- a/apps/desktop/src/privacy/privacy_controller.cpp +++ b/apps/desktop/src/privacy/privacy_controller.cpp @@ -1,496 +1,202 @@ -/* - * @Author: DI JUNKUN - * @Date: 2026-09-11 - * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. - */ - +/* 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()); + return 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"); + std::strcpy(status_.reason, "Privacy screen is not available on this platform"); #ifdef _WIN32 if (!factory) factory = CreateWindowsPrivacyBackend; +#elif defined(__APPLE__) + if (!factory) factory = CreateMacPrivacyBackend; #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(); +#ifdef __APPLE__ + FlushMacPrivacyTasks(); +#endif } - 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) { +void PrivacyController::SetState(PrivacyState state, const std::string& reason) { status_.state = state; - status_.remote_paused = remote_paused_.load(); + status_.remote_paused = false; // Wire compatibility; privacy never pauses a session. ++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); + LOG_INFO("Privacy: state={}, overlay={}, input_blocked={}, reason={}", + static_cast(state), status_.overlay_active, status_.input_blocked, 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 (quit_ || engaged_.load() || disable_pending_) return; if (!status_.supported || (block_input && !status_.input_block_supported)) { - SetState(PrivacyState::unsupported, status_.reason); + SetState(PrivacyState::unsupported, "Privacy screen is unavailable"); 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"); + failure_.clear(); + started_ = Now(); + SetState(PrivacyState::starting, "Enabling privacy screen"); 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. + if (quit_ || (engaged_.load() && !disable_pending_)) return; + // A reconnect may arrive before asynchronous AppKit recovery has completed. 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"); + failure_.clear(); + started_ = Now(); + SetState(PrivacyState::starting, "Enabling privacy on connection"); 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; + failure_.clear(); 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); +void PrivacyController::Disconnected() { Disable(); } +void PrivacyController::FailLocked(const std::string& reason) { + if (!engaged_.load() || disable_pending_) return; + failure_ = reason; enable_pending_ = false; disable_pending_ = true; - SetState(PrivacyState::stopping, - "Last controller disconnected; restoring desktop, input and session " - "state"); + SetState(PrivacyState::failed, reason); 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) { +void PrivacyController::CaptureChanged(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(); - } + if (!running) FailLocked("Capture changed; privacy screen is being turned off"); + WakeLocked(); } - -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) { + if (last_capability_check_ == 0 || + (!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); - } + const auto caps = backend_ ? backend_->Query() : PrivacyCapabilities{}; + const bool changed = status_.supported != caps.overlay || + status_.input_block_supported != caps.block_input; + status_.supported = caps.overlay; + status_.input_block_supported = caps.block_input; + if (!engaged_.load() && (changed || status_.revision == 0)) + SetState(caps.overlay ? PrivacyState::off : PrivacyState::unsupported, caps.reason); } if (disable_pending_) { if (backend_) backend_->Recover(); + if (backend_ && backend_->RecoveryPending()) { + lock.unlock(); + backend_->WaitForEvents(25); + lock.lock(); + continue; + } 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"); + disable_pending_ = true; // Retry native cleanup without affecting the session. + SetState(PrivacyState::failed, "Could not release every privacy resource"); } 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"); + status_.overlay_active = status_.input_blocked = false; + if (!enable_pending_) { + engaged_.store(false); + SetState(failure_.empty() ? PrivacyState::off : PrivacyState::failed, + failure_.empty() ? "Privacy screen is off" : failure_); + } } } 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(); + const auto caps = backend_ ? backend_->Query() : PrivacyCapabilities{}; + status_.supported = caps.overlay; + status_.input_block_supported = caps.block_input; + if (!caps.overlay || (block_requested_ && !caps.block_input)) { + FailLocked("Privacy screen unavailable: " + caps.reason); + } else if (!capture_running_) { + if (!automatic_enable_ || Now() - started_ > 10000) + FailLocked("No desktop capture available for privacy screen"); + } else { + enable_pending_ = false; + std::string error; + if (!backend_->Enable(block_requested_, text_, error)) { FailLocked(error); } else { status_.overlay_active = true; status_.input_blocked = block_requested_; - BeginVerification(); + started_ = Now(); } } } - // 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()) { + const auto health = backend_ ? backend_->Poll() : PrivacyHealth{}; + if (engaged_.load() && !disable_pending_) { if (health.emergency_exit) { - remote_paused_.store(true); + failure_.clear(); enable_pending_ = false; disable_pending_ = true; - SetState(PrivacyState::stopping, - "Local privacy exit; restoring desktop and input before " - "resuming remote operation"); - continue; + SetState(PrivacyState::stopping, "Local shortcut: restoring desktop and input"); } 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."); - } - } + } else if (!enable_pending_ && status_.state == PrivacyState::starting) { + if (health.ready) + SetState(PrivacyState::on, "Privacy screen is on"); + else if (Now() - started_ > 3000) + FailLocked("Privacy screen did not become ready"); } } 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; + const unsigned timeout = engaged_.load() || disable_pending_ ? 25 : 2000; lock.unlock(); - backend_->WaitForEvents(timeout_ms); + backend_->WaitForEvents(timeout); lock.lock(); } else { - // Unsupported platforms have no native health or capability polling. - wake_.wait(lock); + wake_.wait(lock, [this] { return quit_ || enable_pending_ || disable_pending_; }); } } } 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(); + 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 index 3c8eb8f5..3ad1a6d7 100644 --- a/apps/desktop/src/privacy/privacy_controller.h +++ b/apps/desktop/src/privacy/privacy_controller.h @@ -1,26 +1,19 @@ -/* - * @Author: DI JUNKUN - * @Date: 2026-09-10 - * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. - */ - +/* 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 { +// Privacy is optional: it must never gate desktop video, audio or remote input. +// A failed backend is recovered automatically while the session keeps running. class PrivacyController { public: using Factory = std::function()>; @@ -28,72 +21,39 @@ class PrivacyController { ~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); + void CaptureChanged(bool running); 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_; + bool capture_running_ = false; + std::string failure_; 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_ +#endif diff --git a/apps/desktop/src/privacy/privacy_frame_verifier.h b/apps/desktop/src/privacy/privacy_frame_verifier.h deleted file mode 100644 index 087c4bc9..00000000 --- a/apps/desktop/src/privacy/privacy_frame_verifier.h +++ /dev/null @@ -1,98 +0,0 @@ -/* - * @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 deleted file mode 100644 index c6872edc..00000000 --- a/apps/desktop/src/privacy/privacy_probe_pattern.h +++ /dev/null @@ -1,37 +0,0 @@ -/* - * @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 9acc9d3a..8b2da871 100644 --- a/apps/desktop/src/screen_capturer/screen_capturer.h +++ b/apps/desktop/src/screen_capturer/screen_capturer.h @@ -14,6 +14,8 @@ namespace crossdesk { +class PrivacyController; + class ScreenCapturer { public: // Windows backends emit a control-only callback BEFORE automatic recovery. @@ -32,6 +34,7 @@ class ScreenCapturer { public: virtual int Init(const int fps, cb_desktop_data cb) = 0; + virtual void SetPrivacyController(PrivacyController*) {} virtual int Destroy() = 0; virtual int Start(bool show_cursor) = 0; virtual int Stop() = 0; diff --git a/apps/desktop/xmake/targets.lua b/apps/desktop/xmake/targets.lua index 9c282a8d..7307cec0 100644 --- a/apps/desktop/xmake/targets.lua +++ b/apps/desktop/xmake/targets.lua @@ -167,6 +167,10 @@ function setup_targets() add_deps("crossdesk_privacy_window", {inherit = false}) add_includedirs("apps/desktop/src/platform/windows/input") add_syslinks("Wtsapi32", "Ole32", {public = true}) + elseif is_os("macosx") then + add_files("apps/desktop/src/platform/macos/privacy/*.mm") + add_mxxflags("-fobjc-arc") + add_frameworks("AppKit", "ApplicationServices", {public = true}) end if is_os("windows") then @@ -199,6 +203,8 @@ function setup_targets() add_includedirs("apps/desktop/src/platform/windows/screen_capturer", "apps/desktop/src/platform/windows/service") elseif is_os("macosx") then + add_deps("privacy") + add_mxxflags("-fobjc-arc") add_files("apps/desktop/src/platform/macos/screen_capturer/*.cpp", "apps/desktop/src/platform/macos/screen_capturer/*.mm") add_includedirs("apps/desktop/src/platform/macos/screen_capturer")