mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-20 19:14:39 +08:00
Compare commits
7
Commits
56ebfdf8e7
...
ba2afc982b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba2afc982b | ||
|
|
c6635fbdc8 | ||
|
|
3c403f6162 | ||
|
|
b54d32ef41 | ||
|
|
28e9626e0d | ||
|
|
a6e325f190 | ||
|
|
23bf2ff8b8 |
@@ -271,7 +271,7 @@ jobs:
|
||||
|
||||
- name: Build CrossDesk
|
||||
run: |
|
||||
xmake f --target_minver=${MACOSX_DEPLOYMENT_TARGET} --CROSSDESK_VERSION=${VERSION_NUM} --USE_CUDA=false -y
|
||||
xmake f --target_minver=${MACOSX_DEPLOYMENT_TARGET} --CROSSDESK_VERSION=${VERSION_NUM} --USE_CUDA=false -vyD
|
||||
xmake b -vy crossdesk
|
||||
|
||||
- name: Package CrossDesk app
|
||||
|
||||
@@ -161,7 +161,7 @@ Architecture: $DEBIAN_ARCH
|
||||
Maintainer: $MAINTAINER
|
||||
Description: $DESCRIPTION
|
||||
Depends: libc6 (>= 2.31), libstdc++6 (>= 10), libx11-6, libxext6,
|
||||
libxrender1, libxft2, libxrandr2, libxfixes3, libxcursor1, libxcb1, libxcb-randr0,
|
||||
libxrender1, libxft2, libxrandr2, libxfixes3, libxcursor1, libxi6, libxcb1, libxcb-randr0,
|
||||
libxcb-xtest0, libxcb-xinerama0, libxcb-shape0, libxcb-xkb1,
|
||||
libxcb-xfixes0, libxv1, libxtst6, $ALSA_RUNTIME_DEP, libsndio7.0,
|
||||
libxcb-shm0, libpulse0, libdrm2, libdbus-1-3, libgl1
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
#include "path_manager.h"
|
||||
#include "render.h"
|
||||
|
||||
#ifdef __linux__
|
||||
#include "platform/linux/privacy/privacy_guard.h"
|
||||
#endif
|
||||
|
||||
#ifdef _WIN32
|
||||
namespace {
|
||||
|
||||
@@ -175,6 +179,14 @@ int HandleServiceCliCommand(const std::string& command) {
|
||||
#endif
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
#ifdef __linux__
|
||||
// Run before GUI, configuration, logging or daemon initialization. The
|
||||
// guard owns native privacy resources and restores them if this app exits.
|
||||
if (argc == 2 &&
|
||||
std::strcmp(argv[1], crossdesk::kLinuxPrivacyGuardArgument) == 0) {
|
||||
return crossdesk::RunLinuxPrivacyGuard();
|
||||
}
|
||||
#endif
|
||||
#ifdef _WIN32
|
||||
if (argc == 2 &&
|
||||
std::strcmp(argv[1], crossdesk::kSlintRendererProbeArgument) == 0) {
|
||||
@@ -233,4 +245,4 @@ int main(int argc, char* argv[]) {
|
||||
crossdesk::Render render;
|
||||
render.Run();
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,8 +450,18 @@ ConfigCenter::VIDEO_ENCODE_FORMAT ConfigCenter::GetVideoEncodeFormat() const {
|
||||
return video_encode_format_;
|
||||
}
|
||||
|
||||
bool ConfigCenter::IsHardwareVideoCodecAvailable() {
|
||||
#if (((defined(_WIN32) || defined(__linux__)) && !defined(__aarch64__) && \
|
||||
!defined(__arm__) && USE_CUDA) || \
|
||||
defined(__APPLE__))
|
||||
return true;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool ConfigCenter::IsHardwareVideoCodec() const {
|
||||
return hardware_video_codec_;
|
||||
return IsHardwareVideoCodecAvailable() && hardware_video_codec_;
|
||||
}
|
||||
|
||||
ConfigCenter::TURN_MODE ConfigCenter::GetTurnMode() const {
|
||||
|
||||
@@ -65,6 +65,8 @@ class ConfigCenter {
|
||||
ScreenCaptureMethod GetScreenCaptureMethod() const;
|
||||
VIDEO_ADAPTATION_POLICY GetVideoAdaptationPolicy() const;
|
||||
VIDEO_ENCODE_FORMAT GetVideoEncodeFormat() const;
|
||||
// Whether this build includes a hardware video codec backend.
|
||||
static bool IsHardwareVideoCodecAvailable();
|
||||
bool IsHardwareVideoCodec() const;
|
||||
TURN_MODE GetTurnMode() const;
|
||||
bool IsEnableTurn() const;
|
||||
|
||||
@@ -1029,7 +1029,6 @@ void GuiApplication::InitializeSettings() {
|
||||
localization_language_ =
|
||||
static_cast<ConfigCenter::LANGUAGE>(localization_language_index_);
|
||||
privacy_.SetText({
|
||||
localization::privacy_verification[localization_language_index_],
|
||||
localization::privacy_screen_unlock_hint[localization_language_index_]});
|
||||
}
|
||||
|
||||
@@ -1225,7 +1224,11 @@ void GuiApplication::ResetSettingsUi() {
|
||||
ui_->main->set_adaptation_policy_index(
|
||||
video_adaptation_policy_button_value_);
|
||||
ui_->main->set_codec_index(video_encode_format_button_value_);
|
||||
ui_->main->set_hardware_codec_enabled(enable_hardware_video_codec_);
|
||||
const bool hardware_codec_available =
|
||||
ConfigCenter::IsHardwareVideoCodecAvailable();
|
||||
ui_->main->set_hardware_codec_available(hardware_codec_available);
|
||||
ui_->main->set_hardware_codec_enabled(hardware_codec_available &&
|
||||
enable_hardware_video_codec_);
|
||||
ui_->main->set_turn_enabled(enable_turn_);
|
||||
ui_->main->set_srtp_enabled(enable_srtp_);
|
||||
ui_->main->set_self_hosted_enabled(enable_self_hosted_);
|
||||
@@ -1237,6 +1240,8 @@ void GuiApplication::ResetSettingsUi() {
|
||||
ui_->main->set_capture_method_visible(true);
|
||||
ui_->main->set_capture_method_index(
|
||||
static_cast<int>(config_center_->GetScreenCaptureMethod()));
|
||||
#endif
|
||||
#if defined(_WIN32) || defined(__APPLE__) || defined(__linux__)
|
||||
ui_->main->set_privacy_setting_available(true);
|
||||
#else
|
||||
ui_->main->set_privacy_setting_available(false);
|
||||
@@ -2150,14 +2155,6 @@ void GuiApplication::SyncMainWindow() {
|
||||
ui_->main->set_release_name(UiText(release_name_));
|
||||
ui_->main->set_release_date(UiText(release_date_));
|
||||
ui_->main->set_settings_session_active(HasActiveSession());
|
||||
#if (((defined(_WIN32) || defined(__linux__)) && !defined(__aarch64__) && \
|
||||
!defined(__arm__) && USE_CUDA) || \
|
||||
defined(__APPLE__))
|
||||
ui_->main->set_hardware_codec_available(true);
|
||||
#else
|
||||
ui_->main->set_hardware_codec_available(false);
|
||||
#endif
|
||||
|
||||
if (show_offline_warning_window_) {
|
||||
ui_->main->set_offline_warning(UiText(offline_warning_text_));
|
||||
show_offline_warning_window_ = false;
|
||||
@@ -2569,14 +2566,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 +2815,18 @@ void GuiApplication::SyncServerWindow() {
|
||||
});
|
||||
}
|
||||
|
||||
// Keep the local connection panel out of captured privacy sessions and
|
||||
// transitions; it is unnecessary while the physical screens are covered.
|
||||
// Privacy protects local display output while preserving capture.
|
||||
// Keep the panel alive throughout privacy transitions so remote
|
||||
// controllers can still see and operate it without resetting its UI state.
|
||||
bool show_controller_window = has_connected_controller;
|
||||
#if !defined(_WIN32) && !defined(__APPLE__) && !defined(__linux__)
|
||||
// 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());
|
||||
@@ -2986,7 +2984,8 @@ void GuiApplication::SaveSettingsFromUi() {
|
||||
video_adaptation_policy_button_value_ =
|
||||
std::clamp(main->get_adaptation_policy_index(), 0, 2);
|
||||
video_encode_format_button_value_ = std::clamp(main->get_codec_index(), 0, 1);
|
||||
enable_hardware_video_codec_ = main->get_hardware_codec_enabled();
|
||||
enable_hardware_video_codec_ = ConfigCenter::IsHardwareVideoCodecAvailable() &&
|
||||
main->get_hardware_codec_enabled();
|
||||
enable_turn_ = main->get_turn_enabled();
|
||||
enable_srtp_ = main->get_srtp_enabled();
|
||||
enable_self_hosted_ = main->get_self_hosted_enabled();
|
||||
@@ -2998,7 +2997,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<ConfigCenter::VIDEO_QUALITY>(video_quality_button_value_));
|
||||
@@ -3026,6 +3024,8 @@ void GuiApplication::SaveSettingsFromUi() {
|
||||
}
|
||||
main->set_capture_method_index(
|
||||
static_cast<int>(config_center_->GetScreenCaptureMethod()));
|
||||
#endif
|
||||
#if defined(_WIN32) || defined(__APPLE__) || defined(__linux__)
|
||||
if (config_center_->SetPrivacyScreen(main->get_privacy_on_connect_enabled()) !=
|
||||
0) {
|
||||
main->set_privacy_on_connect_enabled(
|
||||
@@ -3365,7 +3365,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");
|
||||
|
||||
@@ -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"Чтобы отключить приватный экран, нажмите") \
|
||||
|
||||
@@ -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<const char *>(data);
|
||||
frame.size = size;
|
||||
@@ -79,9 +79,9 @@ int SessionDeviceManager::InitializeScreenCapturer() {
|
||||
screen_capturer_ =
|
||||
static_cast<ScreenCapturer *>(screen_capturer_factory_->Create());
|
||||
}
|
||||
screen_capturer_->SetPrivacyController(&owner_.privacy_);
|
||||
#ifdef _WIN32
|
||||
if (auto windows = dynamic_cast<ScreenCapturerWin*>(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<const char *>(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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -98,7 +98,6 @@ class GuiRuntime : protected gui_detail::GuiState {
|
||||
std::deque<std::pair<std::string, PrivacyCommand>> 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_;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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__) || defined(__linux__)
|
||||
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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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_ &&
|
||||
|
||||
@@ -47,6 +47,11 @@ export global StreamStrings {
|
||||
in-out property <string> stats-connection-mode: "Mode";
|
||||
}
|
||||
|
||||
struct ControlBarTooltip {
|
||||
text: string,
|
||||
anchor-x: length,
|
||||
}
|
||||
|
||||
// The legacy ImGui control bar uses 24px buttons with Font Awesome rendered
|
||||
// at half of the 32px atlas size. Keep this local to the stream window: the
|
||||
// shared IconButton is intentionally larger and is used by the other windows.
|
||||
@@ -61,6 +66,10 @@ component ControlBarButton inherits Rectangle {
|
||||
in property <bool> muted: false;
|
||||
in property <length> icon-size: 16px;
|
||||
out property <bool> has-hover: touch.has-hover;
|
||||
out property <ControlBarTooltip> tooltip-info: {
|
||||
text: root.visible && touch.has-hover && !touch.pressed ? root.tooltip : "",
|
||||
anchor-x: root.x + root.width / 2,
|
||||
};
|
||||
callback clicked;
|
||||
|
||||
width: 24px;
|
||||
@@ -190,13 +199,25 @@ export component StreamWindow inherits Window {
|
||||
private property <bool> display-menu-animation-enabled: false;
|
||||
private property <float> display-menu-progress: 0.0;
|
||||
private property <bool> shortcut-menu-open: false;
|
||||
private property <bool> privacy-tooltip-visible: false;
|
||||
private property <bool> privacy-hovered: root.control-expanded
|
||||
&& privacy-button.has-hover && !root.control-dragging;
|
||||
private property <length> privacy-tooltip-width: min(320px, video.width);
|
||||
private property <length> privacy-tooltip-x: min(max(0px,
|
||||
control.x + (root.control-docked-left ? 105px : 138px)),
|
||||
video.width - root.privacy-tooltip-width);
|
||||
private property <bool> control-tooltip-visible: false;
|
||||
private property <ControlBarTooltip> hovered-control: {
|
||||
if root.window-active && !root.control-dragging
|
||||
&& !root.display-menu-open && !root.shortcut-menu-open {
|
||||
if collapse-button.has-hover { return collapse-button.tooltip-info; }
|
||||
if root.control-expanded {
|
||||
if display-button.has-hover { return display-button.tooltip-info; }
|
||||
if shortcut-button.has-hover { return shortcut-button.tooltip-info; }
|
||||
if mouse-button.has-hover { return mouse-button.tooltip-info; }
|
||||
if privacy-button.has-hover { return privacy-button.tooltip-info; }
|
||||
if audio-button.has-hover { return audio-button.tooltip-info; }
|
||||
if file-button.has-hover { return file-button.tooltip-info; }
|
||||
if stats-button.has-hover { return stats-button.tooltip-info; }
|
||||
if fullscreen-button.has-hover { return fullscreen-button.tooltip-info; }
|
||||
if close-button.has-hover { return close-button.tooltip-info; }
|
||||
}
|
||||
}
|
||||
return { text: "", anchor-x: 0px };
|
||||
}
|
||||
// A popup can remove itself from the scene in its clicked callback before
|
||||
// the video input layer observes the same pointer release. Remember that
|
||||
// the press belonged to local UI so that release cannot reach the peer.
|
||||
@@ -236,11 +257,18 @@ export component StreamWindow inherits Window {
|
||||
}
|
||||
}
|
||||
|
||||
changed privacy-hovered => { root.privacy-tooltip-visible = false; }
|
||||
privacy-tooltip-timer := Timer {
|
||||
changed hovered-control => {
|
||||
root.control-tooltip-visible = false;
|
||||
control-tooltip-timer.running = root.hovered-control.text != "";
|
||||
if root.hovered-control.text != "" { control-tooltip-timer.restart(); }
|
||||
}
|
||||
control-tooltip-timer := Timer {
|
||||
interval: 450ms;
|
||||
running: root.privacy-hovered && !root.privacy-tooltip-visible;
|
||||
triggered => { root.privacy-tooltip-visible = true; }
|
||||
running: false;
|
||||
triggered => {
|
||||
self.running = false;
|
||||
root.control-tooltip-visible = true;
|
||||
}
|
||||
}
|
||||
|
||||
is-local-control-area(pointer-x, pointer-y) => {
|
||||
@@ -685,7 +713,8 @@ export component StreamWindow inherits Window {
|
||||
background: ImGuiLineStyle.control-separator;
|
||||
}
|
||||
|
||||
if root.control-expanded: display-button := ControlBarButton {
|
||||
display-button := ControlBarButton {
|
||||
visible: root.control-expanded;
|
||||
x: root.control-docked-left ? 9px : 42px; y: 7px;
|
||||
icon: FontAwesomeIcons.display;
|
||||
badge: root.selected-display + 1;
|
||||
@@ -701,7 +730,8 @@ export component StreamWindow inherits Window {
|
||||
}
|
||||
}
|
||||
}
|
||||
if root.control-expanded: shortcut-button := ControlBarButton {
|
||||
shortcut-button := ControlBarButton {
|
||||
visible: root.control-expanded;
|
||||
x: root.control-docked-left ? 41px : 74px; y: 7px;
|
||||
icon: FontAwesomeIcons.keyboard; tooltip: StreamStrings.send-shortcut;
|
||||
clicked => {
|
||||
@@ -712,36 +742,42 @@ export component StreamWindow inherits Window {
|
||||
root.display-menu-open = false;
|
||||
}
|
||||
}
|
||||
if root.control-expanded: mouse-button := ControlBarButton {
|
||||
mouse-button := ControlBarButton {
|
||||
visible: root.control-expanded;
|
||||
x: root.control-docked-left ? 73px : 106px; y: 7px;
|
||||
icon: FontAwesomeIcons.computer-mouse; slashed: !root.mouse-control-enabled;
|
||||
tooltip: root.mouse-control-enabled ? StreamStrings.release-mouse : StreamStrings.control-mouse;
|
||||
clicked => { root.toggle-mouse-control(); }
|
||||
}
|
||||
if root.control-expanded: audio-button := ControlBarButton {
|
||||
audio-button := ControlBarButton {
|
||||
visible: root.control-expanded;
|
||||
x: root.control-docked-left ? 137px : 170px; y: 7px;
|
||||
icon: FontAwesomeIcons.volume-high; slashed: !root.audio-enabled;
|
||||
tooltip: root.audio-enabled ? StreamStrings.mute : StreamStrings.audio;
|
||||
clicked => { root.toggle-audio(); }
|
||||
}
|
||||
if root.control-expanded: file-button := ControlBarButton {
|
||||
file-button := ControlBarButton {
|
||||
visible: root.control-expanded;
|
||||
x: root.control-docked-left ? 169px : 202px; y: 7px;
|
||||
icon: FontAwesomeIcons.folder-open; tooltip: StreamStrings.select-file;
|
||||
clicked => { root.select-file(); }
|
||||
}
|
||||
if root.control-expanded: stats-button := ControlBarButton {
|
||||
stats-button := ControlBarButton {
|
||||
visible: root.control-expanded;
|
||||
x: root.control-docked-left ? 201px : 234px; y: 7px;
|
||||
icon: FontAwesomeIcons.signal; selected: root.stats-visible;
|
||||
tooltip: root.stats-visible ? StreamStrings.hide-stats : StreamStrings.show-stats;
|
||||
clicked => { root.toggle-stats(); }
|
||||
}
|
||||
if root.control-expanded: fullscreen-button := ControlBarButton {
|
||||
fullscreen-button := ControlBarButton {
|
||||
visible: root.control-expanded;
|
||||
x: root.control-docked-left ? 233px : 266px; y: 7px;
|
||||
icon: root.fullscreen-enabled ? FontAwesomeIcons.compress : FontAwesomeIcons.expand;
|
||||
tooltip: root.fullscreen-enabled ? StreamStrings.exit-fullscreen : StreamStrings.fullscreen;
|
||||
clicked => { root.toggle-fullscreen(); }
|
||||
}
|
||||
if root.control-expanded: close-button := ControlBarButton {
|
||||
close-button := ControlBarButton {
|
||||
visible: root.control-expanded;
|
||||
x: root.control-docked-left ? 265px : 298px; y: 7px;
|
||||
icon: FontAwesomeIcons.xmark; tooltip: StreamStrings.disconnect;
|
||||
clicked => { root.disconnect(); }
|
||||
@@ -769,7 +805,9 @@ export component StreamWindow inherits Window {
|
||||
slashed: root.privacy-off && !root.privacy-unsupported;
|
||||
muted: root.privacy-unsupported;
|
||||
action-enabled: root.privacy-can-toggle;
|
||||
tooltip: root.privacy-status-text;
|
||||
tooltip: root.privacy-status-text
|
||||
+ (root.privacy-can-toggle ? "\n" + (root.privacy-active
|
||||
? StreamStrings.privacy-disable : StreamStrings.privacy-enable) : "");
|
||||
clicked => { root.toggle-privacy(); }
|
||||
}
|
||||
|
||||
@@ -1001,18 +1039,25 @@ export component StreamWindow inherits Window {
|
||||
}
|
||||
}
|
||||
|
||||
if root.privacy-hovered && root.privacy-tooltip-visible: Rectangle {
|
||||
x: root.privacy-tooltip-x;
|
||||
width: root.privacy-tooltip-width;
|
||||
height: privacy-tip.preferred-height + 16px;
|
||||
y: control.y + control.height + self.height <= video.height
|
||||
? control.y + control.height : max(0px, control.y - self.height);
|
||||
// Measure unwrapped text so short hints stay compact. Render outside
|
||||
// the clipped control bar, capping longer hints before wrapping.
|
||||
control-tooltip-width-probe := Text {
|
||||
visible: false;
|
||||
text: root.hovered-control.text;
|
||||
font-size: ImGuiFontStyle.body;
|
||||
}
|
||||
if root.hovered-control.text != "" && root.control-tooltip-visible: Rectangle {
|
||||
width: min(240px, video.width - 8px,
|
||||
control-tooltip-width-probe.preferred-width + 16px);
|
||||
x: min(max(4px, control.x + root.hovered-control.anchor-x - self.width / 2),
|
||||
video.width - self.width - 4px);
|
||||
height: control-tip.preferred-height + 12px;
|
||||
y: control.y + control.height + self.height + 8px <= video.height
|
||||
? control.y + control.height + 4px : max(4px, control.y - self.height - 4px);
|
||||
background: #30343bf5; border-radius: 5px; z: 12;
|
||||
privacy-tip := Text {
|
||||
x: 8px; y: 8px; width: parent.width - 16px;
|
||||
text: root.privacy-status-text
|
||||
+ (root.privacy-can-toggle ? "\n" + (root.privacy-active
|
||||
? StreamStrings.privacy-disable : StreamStrings.privacy-enable) : "");
|
||||
control-tip := Text {
|
||||
x: 8px; y: 6px; width: parent.width - 16px;
|
||||
text: root.hovered-control.text;
|
||||
color: white; font-size: ImGuiFontStyle.body; wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-13
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _PRIVACY_BACKEND_LINUX_H_
|
||||
#define _PRIVACY_BACKEND_LINUX_H_
|
||||
|
||||
#include <poll.h>
|
||||
#include <spawn.h>
|
||||
#include <sys/eventfd.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cerrno>
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
#include <thread>
|
||||
|
||||
#include "privacy_backend.h"
|
||||
#include "privacy_guard.h"
|
||||
|
||||
extern char** environ;
|
||||
|
||||
namespace crossdesk {
|
||||
namespace {
|
||||
using Clock = std::chrono::steady_clock;
|
||||
|
||||
class LinuxPrivacyBackend final : public PrivacyBackend {
|
||||
public:
|
||||
LinuxPrivacyBackend() : wake_(eventfd(0, EFD_CLOEXEC | EFD_NONBLOCK)) {}
|
||||
~LinuxPrivacyBackend() override {
|
||||
// EOF also recovers after a crash, SIGKILL, or a normal shutdown. Never
|
||||
// terminate the guard: it must finish restoring the saved display ramps.
|
||||
if (socket_ >= 0) close(socket_);
|
||||
if (wake_ >= 0) close(wake_);
|
||||
if (child_ > 0) {
|
||||
const pid_t child = child_;
|
||||
std::thread([child] {
|
||||
while (waitpid(child, nullptr, 0) < 0 && errno == EINTR) {
|
||||
}
|
||||
}).detach();
|
||||
}
|
||||
}
|
||||
|
||||
PrivacyCapabilities Query() override {
|
||||
if (wake_ < 0)
|
||||
return {false, false, "Cannot create Linux privacy wake event"};
|
||||
if (!StartGuard()) return {false, false, failure_};
|
||||
if (!Send(LinuxPrivacyCommand::query)) return {false, false, failure_};
|
||||
// The first query also serves automatic privacy on the first connection.
|
||||
// Bound process startup so an unavailable helper cannot stall the GUI.
|
||||
if (!discovered_) {
|
||||
pollfd fd{socket_, POLLIN, 0};
|
||||
poll(&fd, 1, 1000);
|
||||
}
|
||||
ReadReplies();
|
||||
return {reply_.supported, reply_.input_supported,
|
||||
reply_.capability_reason[0] ? reply_.capability_reason
|
||||
: "Checking Linux privacy support"};
|
||||
}
|
||||
|
||||
bool Enable(bool block_input, const PrivacyScreenText&,
|
||||
std::string& error) override {
|
||||
failure_.clear();
|
||||
emergency_ = false;
|
||||
if (!Send(block_input ? LinuxPrivacyCommand::enable_blocking
|
||||
: LinuxPrivacyCommand::enable)) {
|
||||
error = failure_;
|
||||
return false;
|
||||
}
|
||||
engaged_ = true;
|
||||
++generation_;
|
||||
last_reply_ = Clock::now();
|
||||
recovered_ = false;
|
||||
reply_.active = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
void Recover() override {
|
||||
ReadReplies();
|
||||
if (recovered_) {
|
||||
failure_.clear();
|
||||
return;
|
||||
}
|
||||
if (recovering_) return;
|
||||
if (Send(LinuxPrivacyCommand::recover)) recovering_ = true;
|
||||
}
|
||||
bool IsRecovered() const override { return recovered_; }
|
||||
bool RecoveryPending() const override { return recovering_; }
|
||||
|
||||
PrivacyHealth Poll() override {
|
||||
ReadReplies();
|
||||
if (engaged_ && Clock::now() - last_reply_ > std::chrono::seconds(1))
|
||||
failure_ = "Linux privacy guard stopped responding";
|
||||
if (engaged_ &&
|
||||
Clock::now() - last_heartbeat_ >= std::chrono::milliseconds(100)) {
|
||||
Send(LinuxPrivacyCommand::heartbeat);
|
||||
last_heartbeat_ = Clock::now();
|
||||
}
|
||||
const bool emergency = emergency_;
|
||||
emergency_ = false;
|
||||
return {failure_, emergency, reply_.active};
|
||||
}
|
||||
|
||||
void WaitForEvents(unsigned timeout_ms) override {
|
||||
pollfd fds[] = {{wake_, POLLIN, 0}, {socket_, POLLIN, 0}};
|
||||
poll(fds, 2, static_cast<int>(timeout_ms));
|
||||
uint64_t value;
|
||||
if (fds[0].revents & POLLIN) {
|
||||
while (read(wake_, &value, sizeof(value)) == sizeof(value)) {
|
||||
}
|
||||
}
|
||||
}
|
||||
void Wake() override {
|
||||
const uint64_t value = 1;
|
||||
if (wake_ >= 0) {
|
||||
const auto ignored = write(wake_, &value, sizeof(value));
|
||||
(void)ignored;
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
bool StartGuard() {
|
||||
if (socket_ >= 0) return true;
|
||||
if (child_ > 0) {
|
||||
const auto result = waitpid(child_, nullptr, WNOHANG);
|
||||
if (result == 0) return false;
|
||||
child_ = -1;
|
||||
}
|
||||
int sockets[2];
|
||||
if (socketpair(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC | SOCK_NONBLOCK, 0,
|
||||
sockets) != 0) {
|
||||
failure_ = "Cannot create Linux privacy guard socket";
|
||||
return false;
|
||||
}
|
||||
posix_spawn_file_actions_t actions;
|
||||
int result = posix_spawn_file_actions_init(&actions);
|
||||
if (result == 0) {
|
||||
// Close the parent end first: it may occupy the guard's destination fd.
|
||||
result = posix_spawn_file_actions_addclose(&actions, sockets[0]);
|
||||
if (result == 0)
|
||||
result = posix_spawn_file_actions_adddup2(&actions, sockets[1],
|
||||
kLinuxPrivacyGuardSocket);
|
||||
if (result == 0 && sockets[1] != kLinuxPrivacyGuardSocket)
|
||||
result = posix_spawn_file_actions_addclose(&actions, sockets[1]);
|
||||
if (result == 0) {
|
||||
char executable[] = "/proc/self/exe";
|
||||
char* args[] = {executable,
|
||||
const_cast<char*>(kLinuxPrivacyGuardArgument), nullptr};
|
||||
result =
|
||||
posix_spawn(&child_, executable, &actions, nullptr, args, environ);
|
||||
}
|
||||
posix_spawn_file_actions_destroy(&actions);
|
||||
}
|
||||
close(sockets[1]);
|
||||
if (result != 0) {
|
||||
close(sockets[0]);
|
||||
child_ = -1;
|
||||
failure_ = std::string("Cannot start Linux privacy guard: ") +
|
||||
std::strerror(result);
|
||||
return false;
|
||||
}
|
||||
socket_ = sockets[0];
|
||||
reply_ = {};
|
||||
discovered_ = false;
|
||||
generation_ = 0;
|
||||
failure_.clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
void LostGuard() {
|
||||
if (socket_ >= 0) close(socket_);
|
||||
socket_ = -1;
|
||||
reply_ = {};
|
||||
recovering_ = false;
|
||||
failure_ =
|
||||
"Linux privacy guard disconnected; local protection is unavailable";
|
||||
// Do not claim successful recovery after an unexpected guard failure.
|
||||
// A normal guard reports restoration before closing its socket.
|
||||
}
|
||||
bool Send(LinuxPrivacyCommand command) {
|
||||
if (socket_ < 0) return false;
|
||||
if (send(socket_, &command, sizeof(command), MSG_NOSIGNAL) ==
|
||||
sizeof(command))
|
||||
return true;
|
||||
if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) LostGuard();
|
||||
return false;
|
||||
}
|
||||
void ReadReplies() {
|
||||
if (socket_ < 0) return;
|
||||
for (;;) {
|
||||
LinuxPrivacyReply next;
|
||||
const auto bytes = recv(socket_, &next, sizeof(next), 0);
|
||||
if (bytes < 0 &&
|
||||
(errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR))
|
||||
return;
|
||||
if (bytes != sizeof(next)) {
|
||||
LostGuard();
|
||||
return;
|
||||
}
|
||||
next.capability_reason[sizeof(next.capability_reason) - 1] = '\0';
|
||||
next.failure[sizeof(next.failure) - 1] = '\0';
|
||||
discovered_ = true;
|
||||
// Ignore queued capability/heartbeat replies from before this enable.
|
||||
// A matching generation can also acknowledge watchdog-driven recovery.
|
||||
if (next.generation != generation_) continue;
|
||||
reply_ = next;
|
||||
last_reply_ = Clock::now();
|
||||
emergency_ = emergency_ || next.emergency;
|
||||
if (next.failure[0]) failure_ = next.failure;
|
||||
recovered_ = next.recovered;
|
||||
if (next.recovered) {
|
||||
recovering_ = false;
|
||||
engaged_ = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int wake_ = -1;
|
||||
int socket_ = -1;
|
||||
pid_t child_ = -1;
|
||||
LinuxPrivacyReply reply_;
|
||||
uint64_t generation_ = 0;
|
||||
bool engaged_ = false;
|
||||
bool discovered_ = false;
|
||||
bool recovering_ = false;
|
||||
bool recovered_ = true;
|
||||
bool emergency_ = false;
|
||||
std::string failure_;
|
||||
Clock::time_point last_heartbeat_{};
|
||||
Clock::time_point last_reply_{};
|
||||
};
|
||||
} // namespace
|
||||
|
||||
std::unique_ptr<PrivacyBackend> CreateLinuxPrivacyBackend() {
|
||||
return std::make_unique<LinuxPrivacyBackend>();
|
||||
}
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,721 @@
|
||||
/* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. */
|
||||
#include "privacy_guard.h"
|
||||
|
||||
#include <X11/XKBlib.h>
|
||||
#include <X11/Xatom.h>
|
||||
#include <X11/extensions/XInput.h>
|
||||
#include <X11/extensions/XInput2.h>
|
||||
#include <X11/extensions/XTest.h>
|
||||
#include <X11/extensions/Xfixes.h>
|
||||
#include <X11/extensions/Xrandr.h>
|
||||
#include <X11/keysym.h>
|
||||
#include <fcntl.h>
|
||||
#include <poll.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cerrno>
|
||||
#include <chrono>
|
||||
#include <csignal>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <tuple>
|
||||
#include <vector>
|
||||
|
||||
namespace crossdesk {
|
||||
namespace {
|
||||
using Clock = std::chrono::steady_clock;
|
||||
using Gamma = std::unique_ptr<XRRCrtcGamma, decltype(&XRRFreeGamma)>;
|
||||
using Resources =
|
||||
std::unique_ptr<XRRScreenResources, decltype(&XRRFreeScreenResources)>;
|
||||
using CrtcInfo = std::unique_ptr<XRRCrtcInfo, decltype(&XRRFreeCrtcInfo)>;
|
||||
volatile std::sig_atomic_t stopping = 0;
|
||||
int x_error = 0;
|
||||
|
||||
void StopGuard(int) { stopping = 1; }
|
||||
int OnXError(Display*, XErrorEvent* event) {
|
||||
x_error = event->error_code;
|
||||
return 0;
|
||||
}
|
||||
|
||||
template <size_t N>
|
||||
void SetText(char (&dest)[N], const std::string& text) {
|
||||
std::strncpy(dest, text.c_str(), N - 1);
|
||||
dest[N - 1] = '\0';
|
||||
}
|
||||
|
||||
struct Output {
|
||||
Window root = 0;
|
||||
RRCrtc crtc = 0;
|
||||
RRMode mode = 0;
|
||||
int x = 0, y = 0;
|
||||
unsigned width = 0, height = 0;
|
||||
Rotation rotation = 0;
|
||||
std::vector<RROutput> connectors;
|
||||
Gamma saved{nullptr, XRRFreeGamma};
|
||||
|
||||
bool SameLayout(const Output& other) const {
|
||||
return std::tie(root, crtc, mode, x, y, width, height, rotation,
|
||||
connectors) == std::tie(other.root, other.crtc, other.mode,
|
||||
other.x, other.y, other.width,
|
||||
other.height, other.rotation,
|
||||
other.connectors);
|
||||
}
|
||||
};
|
||||
|
||||
struct Device {
|
||||
int id = 0, use = 0, attachment = 0;
|
||||
bool keyboard = false, touch = false;
|
||||
std::string name;
|
||||
|
||||
bool operator==(const Device& other) const {
|
||||
return std::tie(id, use, attachment, keyboard, touch, name) ==
|
||||
std::tie(other.id, other.use, other.attachment, other.keyboard,
|
||||
other.touch, other.name);
|
||||
}
|
||||
};
|
||||
|
||||
bool IsBlack(const XRRCrtcGamma* gamma) {
|
||||
if (!gamma || gamma->size <= 0) return false;
|
||||
for (int i = 0; i < gamma->size; ++i)
|
||||
if (gamma->red[i] || gamma->green[i] || gamma->blue[i]) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// This class lives only in the exec'd guard process. Its Xlib error handler is
|
||||
// isolated from the GUI and capture threads' process-global error handlers.
|
||||
class NativePrivacy {
|
||||
public:
|
||||
~NativePrivacy() {
|
||||
Recover();
|
||||
if (display_) XCloseDisplay(display_);
|
||||
}
|
||||
|
||||
void Query() {
|
||||
reply_.supported = reply_.input_supported = false;
|
||||
std::string error;
|
||||
if (!Initialize(error)) {
|
||||
SetText(reply_.capability_reason, error);
|
||||
return;
|
||||
}
|
||||
std::vector<Output> outputs;
|
||||
std::vector<Device> devices;
|
||||
if (!ReadOutputs(outputs, true, error) || !ReadDevices(devices, error)) {
|
||||
SetText(reply_.capability_reason, error);
|
||||
return;
|
||||
}
|
||||
const bool keyboard = std::any_of(devices.begin(), devices.end(),
|
||||
[](const auto& d) { return d.keyboard; });
|
||||
if (!keyboard) {
|
||||
SetText(reply_.capability_reason,
|
||||
"No local keyboard available for privacy recovery");
|
||||
return;
|
||||
}
|
||||
reply_.supported = true;
|
||||
// XIGrabDevice does not suppress XI 2.2 touch delivery. Do not advertise
|
||||
// input protection for touchscreens without a separate touch grab.
|
||||
reply_.input_supported = std::none_of(
|
||||
devices.begin(), devices.end(), [](const auto& d) { return d.touch; });
|
||||
SetText(reply_.capability_reason,
|
||||
reply_.input_supported
|
||||
? "X11 privacy black screen available; local recovery: "
|
||||
"Ctrl+Alt+Shift+F12"
|
||||
: "X11 privacy black screen available; touchscreen input "
|
||||
"blocking is unavailable");
|
||||
}
|
||||
|
||||
void Enable(bool block_input) {
|
||||
++reply_.generation;
|
||||
reply_.failure[0] = '\0';
|
||||
reply_.emergency = false;
|
||||
if (!reply_.recovered) {
|
||||
Fail("Linux privacy resources are still in use");
|
||||
return;
|
||||
}
|
||||
Query();
|
||||
if (!reply_.supported || (block_input && !reply_.input_supported)) {
|
||||
Fail(reply_.capability_reason);
|
||||
return;
|
||||
}
|
||||
std::string error;
|
||||
keys_.clear();
|
||||
if (!Acquire(error) || !ReadOutputs(outputs_, true, error) ||
|
||||
!ReadDevices(devices_, error) || !ProtectInput(block_input, error)) {
|
||||
Fail(error);
|
||||
return;
|
||||
}
|
||||
// No display ramp is changed until every original ramp has been saved.
|
||||
// Gamma is applied at scanout, after XGetImage/DRM framebuffer capture.
|
||||
// There is deliberately no cover window to contaminate remote frames.
|
||||
x_error = 0;
|
||||
for (const auto& output : outputs_) {
|
||||
Gamma black(XRRAllocGamma(output.saved->size), XRRFreeGamma);
|
||||
if (!black) {
|
||||
Fail("Cannot allocate Linux privacy display ramp");
|
||||
return;
|
||||
}
|
||||
std::fill_n(black->red, black->size, 0);
|
||||
std::fill_n(black->green, black->size, 0);
|
||||
std::fill_n(black->blue, black->size, 0);
|
||||
XRRSetCrtcGamma(display_, output.crtc, black.get());
|
||||
}
|
||||
for (int i = 0; i < ScreenCount(display_); ++i) {
|
||||
XFixesHideCursor(display_, RootWindow(display_, i));
|
||||
hidden_roots_.push_back(RootWindow(display_, i));
|
||||
}
|
||||
XSync(display_, False);
|
||||
if (x_error) {
|
||||
Fail("Cannot blank Linux displays or hide the local cursor");
|
||||
return;
|
||||
}
|
||||
// Active slave grabs temporarily detach devices. Snapshot after grabbing,
|
||||
// and monitor subsequent hardware/hierarchy changes during protection.
|
||||
if (!ReadDevices(devices_, error)) {
|
||||
Fail(error);
|
||||
return;
|
||||
}
|
||||
reply_.active = true;
|
||||
Check();
|
||||
}
|
||||
|
||||
void Recover() {
|
||||
reply_.active = false;
|
||||
if (!display_) {
|
||||
reply_.recovered = true;
|
||||
return;
|
||||
}
|
||||
for (int id : grabbed_) XIUngrabDevice(display_, id, CurrentTime);
|
||||
grabbed_.clear();
|
||||
for (Window root : hidden_roots_) XFixesShowCursor(display_, root);
|
||||
hidden_roots_.clear();
|
||||
// Keep failed restorations for retry. Removed CRTCs no longer own a local
|
||||
// output; all still-present CRTCs must acknowledge the original ramp.
|
||||
for (auto it = outputs_.begin(); it != outputs_.end();) {
|
||||
x_error = 0;
|
||||
Resources resources(XRRGetScreenResourcesCurrent(display_, it->root),
|
||||
XRRFreeScreenResources);
|
||||
if (!resources) {
|
||||
++it;
|
||||
continue;
|
||||
}
|
||||
const bool exists =
|
||||
std::find(resources->crtcs, resources->crtcs + resources->ncrtc,
|
||||
it->crtc) != resources->crtcs + resources->ncrtc;
|
||||
if (exists && it->saved) {
|
||||
XRRSetCrtcGamma(display_, it->crtc, it->saved.get());
|
||||
XSync(display_, False);
|
||||
if (x_error) {
|
||||
++it;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
it = outputs_.erase(it);
|
||||
}
|
||||
keys_.clear();
|
||||
if (outputs_.empty() && owner_) {
|
||||
XDestroyWindow(display_, owner_);
|
||||
owner_ = 0;
|
||||
}
|
||||
XSync(display_, False);
|
||||
reply_.recovered = outputs_.empty() && !owner_;
|
||||
}
|
||||
|
||||
void Fail(const std::string& reason) {
|
||||
SetText(reply_.failure, reason);
|
||||
Recover();
|
||||
}
|
||||
|
||||
void Poll() {
|
||||
if (!display_) return;
|
||||
while (XPending(display_)) {
|
||||
XEvent event{};
|
||||
XNextEvent(display_, &event);
|
||||
if (event.type != GenericEvent || event.xcookie.extension != xi_opcode_ ||
|
||||
!XGetEventData(display_, &event.xcookie))
|
||||
continue;
|
||||
if (reply_.active) {
|
||||
const int type = event.xcookie.evtype;
|
||||
if (type == XI_KeyPress || type == XI_KeyRelease) {
|
||||
const auto* key = static_cast<XIDeviceEvent*>(event.xcookie.data);
|
||||
Key(key->sourceid, key->detail, type == XI_KeyPress);
|
||||
} else if (!blocking_ &&
|
||||
(type == XI_RawKeyPress || type == XI_RawKeyRelease)) {
|
||||
const auto* key = static_cast<XIRawEvent*>(event.xcookie.data);
|
||||
Key(key->sourceid, key->detail, type == XI_RawKeyPress);
|
||||
}
|
||||
}
|
||||
XFreeEventData(display_, &event.xcookie);
|
||||
}
|
||||
if (reply_.active &&
|
||||
Clock::now() - checked_ >= std::chrono::milliseconds(100))
|
||||
Check();
|
||||
if (!reply_.active && !reply_.recovered) Recover();
|
||||
}
|
||||
|
||||
const LinuxPrivacyReply& Reply() const { return reply_; }
|
||||
int Connection() const { return display_ ? ConnectionNumber(display_) : -1; }
|
||||
void AcknowledgeEmergency() { reply_.emergency = false; }
|
||||
|
||||
private:
|
||||
bool Initialize(std::string& error) {
|
||||
if (initialized_) return true;
|
||||
const char* session = std::getenv("XDG_SESSION_TYPE");
|
||||
const char* wayland = std::getenv("WAYLAND_DISPLAY");
|
||||
if ((session && std::strcmp(session, "wayland") == 0) ||
|
||||
(wayland && *wayland)) {
|
||||
error =
|
||||
"Linux privacy screen requires an X11 session; Wayland is "
|
||||
"unsupported";
|
||||
return false;
|
||||
}
|
||||
const char* address = std::getenv("DISPLAY");
|
||||
if (!address ||
|
||||
(*address != ':' && std::strncmp(address, "unix:", 5) != 0)) {
|
||||
error = "Linux privacy screen requires a local X11 display";
|
||||
return false;
|
||||
}
|
||||
if (!display_) display_ = XOpenDisplay(nullptr);
|
||||
if (!display_) {
|
||||
error = "Cannot open the local X11 display";
|
||||
return false;
|
||||
}
|
||||
int event = 0, code = 0, opcode = 0;
|
||||
int major = 1, minor = 3;
|
||||
if (XQueryExtension(display_, "XWAYLAND", &opcode, &event, &code)) {
|
||||
error = "XWayland cannot protect native Wayland displays";
|
||||
return false;
|
||||
}
|
||||
if (!XRRQueryExtension(display_, &event, &code) ||
|
||||
!XRRQueryVersion(display_, &major, &minor) || major < 1 ||
|
||||
(major == 1 && minor < 3)) {
|
||||
error = "Linux privacy screen requires RandR 1.3 display gamma control";
|
||||
return false;
|
||||
}
|
||||
major = 4;
|
||||
minor = 0;
|
||||
if (!XFixesQueryExtension(display_, &event, &code) ||
|
||||
!XFixesQueryVersion(display_, &major, &minor) || major < 4) {
|
||||
error = "Linux privacy screen requires XFixes 4 cursor hiding";
|
||||
return false;
|
||||
}
|
||||
major = 2;
|
||||
minor = 2;
|
||||
if (!XQueryExtension(display_, "XInputExtension", &xi_opcode_, &event,
|
||||
&code) ||
|
||||
XIQueryVersion(display_, &major, &minor) != Success || major < 2) {
|
||||
error = "Linux privacy screen requires XInput 2 local input monitoring";
|
||||
return false;
|
||||
}
|
||||
xtest_property_ = XInternAtom(display_, "XTEST Device", True);
|
||||
if (!xtest_property_ ||
|
||||
!XTestQueryExtension(display_, &event, &code, &major, &minor)) {
|
||||
error = "Cannot identify X11 remote input devices";
|
||||
return false;
|
||||
}
|
||||
selection_ = XInternAtom(display_, "_CROSSDESK_PRIVACY_GUARD", False);
|
||||
std::array<unsigned char, XIMaskLen(XI_LASTEVENT)> bits{};
|
||||
XISetMask(bits.data(), XI_RawKeyPress);
|
||||
XISetMask(bits.data(), XI_RawKeyRelease);
|
||||
XIEventMask mask{XIAllDevices, static_cast<int>(bits.size()), bits.data()};
|
||||
x_error = 0;
|
||||
for (int i = 0; i < ScreenCount(display_); ++i)
|
||||
XISelectEvents(display_, RootWindow(display_, i), &mask, 1);
|
||||
XSync(display_, False);
|
||||
if (x_error) {
|
||||
error = "Cannot monitor the local privacy recovery shortcut";
|
||||
return false;
|
||||
}
|
||||
initialized_ = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ReadOutputs(std::vector<Output>& outputs, bool save,
|
||||
std::string& error) {
|
||||
outputs.clear();
|
||||
x_error = 0;
|
||||
for (int screen = 0; screen < ScreenCount(display_); ++screen) {
|
||||
const Window root = RootWindow(display_, screen);
|
||||
Resources resources(XRRGetScreenResourcesCurrent(display_, root),
|
||||
XRRFreeScreenResources);
|
||||
if (!resources) {
|
||||
error = "Cannot enumerate Linux display outputs";
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < resources->ncrtc; ++i) {
|
||||
CrtcInfo info(
|
||||
XRRGetCrtcInfo(display_, resources.get(), resources->crtcs[i]),
|
||||
XRRFreeCrtcInfo);
|
||||
if (!info) {
|
||||
error = "Cannot inspect Linux display controller";
|
||||
return false;
|
||||
}
|
||||
if (!info->mode || !info->noutput) continue;
|
||||
Output output;
|
||||
output.root = root;
|
||||
output.crtc = resources->crtcs[i];
|
||||
output.mode = info->mode;
|
||||
output.x = info->x;
|
||||
output.y = info->y;
|
||||
output.width = info->width;
|
||||
output.height = info->height;
|
||||
output.rotation = info->rotation;
|
||||
output.connectors.assign(info->outputs, info->outputs + info->noutput);
|
||||
std::sort(output.connectors.begin(), output.connectors.end());
|
||||
if (save) {
|
||||
output.saved.reset(XRRGetCrtcGamma(display_, output.crtc));
|
||||
if (!output.saved || output.saved->size <= 0 ||
|
||||
IsBlack(output.saved.get())) {
|
||||
error =
|
||||
"Display gamma control is unavailable or the display is "
|
||||
"already blank";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
outputs.push_back(std::move(output));
|
||||
}
|
||||
}
|
||||
XSync(display_, False);
|
||||
if (x_error || outputs.empty()) {
|
||||
error = "No controllable Linux display outputs";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ReadDevices(std::vector<Device>& devices, std::string& error) {
|
||||
devices.clear();
|
||||
x_error = 0;
|
||||
int count = 0;
|
||||
XIDeviceInfo* info = XIQueryDevice(display_, XIAllDevices, &count);
|
||||
if (!info) {
|
||||
error = "Cannot enumerate local X11 input devices";
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < count; ++i) {
|
||||
const auto& d = info[i];
|
||||
if (!d.enabled || d.use == XIMasterPointer || d.use == XIMasterKeyboard)
|
||||
continue;
|
||||
Atom type = None;
|
||||
int format = 0;
|
||||
unsigned long items = 0, remaining = 0;
|
||||
unsigned char* value = nullptr;
|
||||
const int result =
|
||||
XIGetProperty(display_, d.deviceid, xtest_property_, 0, 1, False,
|
||||
XA_INTEGER, &type, &format, &items, &remaining, &value);
|
||||
const bool injected = result == Success && type == XA_INTEGER &&
|
||||
format == 8 && items == 1 && value && value[0] == 1;
|
||||
if (value) XFree(value);
|
||||
if (injected) continue;
|
||||
Device device;
|
||||
device.id = d.deviceid;
|
||||
device.use = d.use;
|
||||
device.attachment = d.attachment;
|
||||
device.name = d.name ? d.name : "";
|
||||
for (int c = 0; c < d.num_classes; ++c) {
|
||||
device.keyboard |= d.classes[c]->type == XIKeyClass;
|
||||
device.touch |= d.classes[c]->type == XITouchClass;
|
||||
}
|
||||
devices.push_back(std::move(device));
|
||||
}
|
||||
XIFreeDeviceInfo(info);
|
||||
XSync(display_, False);
|
||||
if (x_error) {
|
||||
error = "Cannot verify local X11 input devices";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Acquire(std::string& error) {
|
||||
// A second instance must not save the first instance's black ramps as its
|
||||
// originals. Selection ownership and creation are atomic on this server.
|
||||
XGrabServer(display_);
|
||||
if (XGetSelectionOwner(display_, selection_) == None) {
|
||||
owner_ = XCreateSimpleWindow(display_, DefaultRootWindow(display_), 0, 0,
|
||||
1, 1, 0, 0, 0);
|
||||
XSetSelectionOwner(display_, selection_, owner_, CurrentTime);
|
||||
}
|
||||
XUngrabServer(display_);
|
||||
XSync(display_, False);
|
||||
if (!owner_ || XGetSelectionOwner(display_, selection_) != owner_) {
|
||||
error = "Another CrossDesk instance owns Linux privacy protection";
|
||||
return false;
|
||||
}
|
||||
reply_.recovered = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ProtectInput(bool block_input, std::string& error) {
|
||||
blocking_ = block_input;
|
||||
if (!block_input) return true;
|
||||
for (const auto& device : devices_) {
|
||||
if (device.touch) {
|
||||
error = "Touchscreen privacy input blocking is unavailable";
|
||||
return false;
|
||||
}
|
||||
if (device.keyboard) {
|
||||
// Preserve held local modifiers for the emergency chord independently
|
||||
// of the releases sent to the master keyboard below.
|
||||
XDevice* keyboard = XOpenDevice(display_, device.id);
|
||||
XDeviceState* state =
|
||||
keyboard ? XQueryDeviceState(display_, keyboard) : nullptr;
|
||||
if (!state) {
|
||||
if (keyboard) XCloseDevice(display_, keyboard);
|
||||
error = "Cannot inspect held local keyboard input";
|
||||
return false;
|
||||
}
|
||||
XInputClass* input = state->data;
|
||||
for (int i = 0; i < state->num_classes; ++i) {
|
||||
if (input->c_class == KeyClass) {
|
||||
const auto* keys = reinterpret_cast<XKeyState*>(input);
|
||||
for (int key = 0; key < 256; ++key)
|
||||
keys_[device.id][key] = keys->keys[key / 8] & (1 << (key % 8));
|
||||
}
|
||||
input = reinterpret_cast<XInputClass*>(
|
||||
reinterpret_cast<char*>(input) + input->length);
|
||||
}
|
||||
XFreeDeviceState(state);
|
||||
XCloseDevice(display_, keyboard);
|
||||
}
|
||||
std::array<unsigned char, XIMaskLen(XI_LASTEVENT)> bits{};
|
||||
for (int type : {XI_KeyPress, XI_KeyRelease, XI_ButtonPress,
|
||||
XI_ButtonRelease, XI_Motion})
|
||||
XISetMask(bits.data(), type);
|
||||
XIEventMask mask{device.id, static_cast<int>(bits.size()), bits.data()};
|
||||
const int result = XIGrabDevice(
|
||||
display_, device.id, DefaultRootWindow(display_), CurrentTime, None,
|
||||
GrabModeAsync, GrabModeAsync, False, &mask);
|
||||
if (result != GrabSuccess) {
|
||||
error = "Cannot grab every local X11 input device";
|
||||
return false;
|
||||
}
|
||||
grabbed_.push_back(device.id);
|
||||
}
|
||||
return ReleaseHeldInput(error);
|
||||
}
|
||||
|
||||
bool ReleaseHeldInput(std::string& error) {
|
||||
// Slave grabs float physical devices, but their previous presses can
|
||||
// remain on the master. Release them after all grabs are installed so a
|
||||
// held local Ctrl/button cannot alter the remote controller's next input.
|
||||
int original_pointer = 0, count = 0;
|
||||
XIGetClientPointer(display_, None, &original_pointer);
|
||||
XIDeviceInfo* masters = XIQueryDevice(display_, XIAllMasterDevices, &count);
|
||||
if (!masters) {
|
||||
error = "Cannot inspect held X11 input";
|
||||
return false;
|
||||
}
|
||||
x_error = 0;
|
||||
bool ok = true;
|
||||
for (int i = 0; i < count; ++i) {
|
||||
if (masters[i].use != XIMasterPointer || !masters[i].enabled) continue;
|
||||
XISetClientPointer(display_, None, masters[i].deviceid);
|
||||
char keys[32]{};
|
||||
XQueryKeymap(display_, keys);
|
||||
for (int key = 8; key < 256; ++key)
|
||||
if (keys[key / 8] & (1 << (key % 8)))
|
||||
ok &= XTestFakeKeyEvent(display_, key, False, CurrentTime) != 0;
|
||||
Window root = None, child = None;
|
||||
double root_x = 0, root_y = 0, win_x = 0, win_y = 0;
|
||||
XIButtonState buttons{};
|
||||
XIModifierState modifiers{};
|
||||
XIGroupState group{};
|
||||
XIQueryPointer(display_, masters[i].deviceid, DefaultRootWindow(display_),
|
||||
&root, &child, &root_x, &root_y, &win_x, &win_y, &buttons,
|
||||
&modifiers, &group);
|
||||
for (int button = 1; button < buttons.mask_len * 8; ++button)
|
||||
if (XIMaskIsSet(buttons.mask, button))
|
||||
ok &= XTestFakeButtonEvent(display_, button, False, CurrentTime) != 0;
|
||||
if (buttons.mask) XFree(buttons.mask);
|
||||
XSync(display_, False);
|
||||
XQueryKeymap(display_, keys);
|
||||
for (char pressed : keys) ok &= pressed == 0;
|
||||
buttons = {};
|
||||
XIQueryPointer(display_, masters[i].deviceid, DefaultRootWindow(display_),
|
||||
&root, &child, &root_x, &root_y, &win_x, &win_y, &buttons,
|
||||
&modifiers, &group);
|
||||
for (int byte = 0; byte < buttons.mask_len; ++byte)
|
||||
ok &= buttons.mask[byte] == 0;
|
||||
if (buttons.mask) XFree(buttons.mask);
|
||||
}
|
||||
XIFreeDeviceInfo(masters);
|
||||
if (original_pointer) XISetClientPointer(display_, None, original_pointer);
|
||||
XSync(display_, False);
|
||||
if (!ok || x_error) {
|
||||
error = "Cannot release held X11 input for privacy";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void Key(int source, int keycode, bool down) {
|
||||
if (keycode < 0 || keycode >= 256 ||
|
||||
std::none_of(devices_.begin(), devices_.end(), [source](const auto& d) {
|
||||
return d.id == source && d.keyboard;
|
||||
}))
|
||||
return;
|
||||
keys_[source][keycode] = down;
|
||||
bool ctrl = false, alt = false, shift = false;
|
||||
for (const auto& [id, keys] : keys_) {
|
||||
for (int code = 0; code < 256; ++code) {
|
||||
if (!keys[code]) continue;
|
||||
const KeySym symbol = XkbKeycodeToKeysym(display_, code, 0, 0);
|
||||
ctrl |= symbol == XK_Control_L || symbol == XK_Control_R;
|
||||
alt |= symbol == XK_Alt_L || symbol == XK_Alt_R;
|
||||
shift |= symbol == XK_Shift_L || symbol == XK_Shift_R;
|
||||
}
|
||||
}
|
||||
if (down && ctrl && alt && shift &&
|
||||
XkbKeycodeToKeysym(display_, keycode, 0, 0) == XK_F12) {
|
||||
reply_.emergency = true;
|
||||
Recover();
|
||||
}
|
||||
}
|
||||
|
||||
void Check() {
|
||||
checked_ = Clock::now();
|
||||
std::string error;
|
||||
std::vector<Output> current;
|
||||
std::vector<Device> devices;
|
||||
if (!ReadOutputs(current, false, error) || !ReadDevices(devices, error)) {
|
||||
Fail(error);
|
||||
return;
|
||||
}
|
||||
if (current.size() != outputs_.size() ||
|
||||
!std::equal(
|
||||
current.begin(), current.end(), outputs_.begin(),
|
||||
[](const auto& a, const auto& b) { return a.SameLayout(b); })) {
|
||||
Fail("Display topology changed; Linux privacy has been turned off");
|
||||
return;
|
||||
}
|
||||
if (devices != devices_) {
|
||||
Fail("Local input devices changed; Linux privacy has been turned off");
|
||||
return;
|
||||
}
|
||||
x_error = 0;
|
||||
for (const auto& output : outputs_) {
|
||||
Gamma ramp(XRRGetCrtcGamma(display_, output.crtc), XRRFreeGamma);
|
||||
if (!IsBlack(ramp.get())) {
|
||||
Fail(
|
||||
"Display gamma protection was changed; Linux privacy is "
|
||||
"unavailable");
|
||||
return;
|
||||
}
|
||||
}
|
||||
XSync(display_, False);
|
||||
if (x_error || XGetSelectionOwner(display_, selection_) != owner_)
|
||||
Fail("Cannot verify Linux privacy protection");
|
||||
}
|
||||
|
||||
Display* display_ = nullptr;
|
||||
bool initialized_ = false;
|
||||
bool blocking_ = false;
|
||||
int xi_opcode_ = 0;
|
||||
Atom xtest_property_ = None, selection_ = None;
|
||||
Window owner_ = 0;
|
||||
std::vector<Output> outputs_;
|
||||
std::vector<Device> devices_;
|
||||
std::vector<int> grabbed_;
|
||||
std::vector<Window> hidden_roots_;
|
||||
std::map<int, std::array<bool, 256>> keys_;
|
||||
LinuxPrivacyReply reply_;
|
||||
Clock::time_point checked_{};
|
||||
};
|
||||
} // namespace
|
||||
|
||||
int RunLinuxPrivacyGuard() {
|
||||
const int socket = kLinuxPrivacyGuardSocket;
|
||||
int type = 0;
|
||||
socklen_t length = sizeof(type);
|
||||
if (getsockopt(socket, SOL_SOCKET, SO_TYPE, &type, &length) != 0 ||
|
||||
type != SOCK_SEQPACKET)
|
||||
return 1;
|
||||
fcntl(socket, F_SETFD, FD_CLOEXEC);
|
||||
fcntl(socket, F_SETFL, O_NONBLOCK);
|
||||
struct sigaction action {};
|
||||
action.sa_handler = StopGuard;
|
||||
sigemptyset(&action.sa_mask);
|
||||
sigaction(SIGTERM, &action, nullptr);
|
||||
sigaction(SIGINT, &action, nullptr);
|
||||
sigaction(SIGHUP, &action, nullptr);
|
||||
XSetErrorHandler(OnXError);
|
||||
NativePrivacy privacy;
|
||||
auto heartbeat = Clock::now();
|
||||
bool connected = true;
|
||||
bool dirty = false;
|
||||
while (connected && !stopping) {
|
||||
pollfd fds[] = {{socket, POLLIN, 0}, {privacy.Connection(), POLLIN, 0}};
|
||||
poll(fds, 2, privacy.Reply().active ? 25 : 250);
|
||||
if (fds[0].revents & (POLLERR | POLLHUP | POLLNVAL)) break;
|
||||
for (int i = 0; i < 64; ++i) {
|
||||
LinuxPrivacyCommand command;
|
||||
const auto bytes = recv(socket, &command, sizeof(command), 0);
|
||||
if (bytes < 0 &&
|
||||
(errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR))
|
||||
break;
|
||||
if (bytes != sizeof(command)) {
|
||||
connected = false;
|
||||
break;
|
||||
}
|
||||
heartbeat = Clock::now();
|
||||
switch (command) {
|
||||
case LinuxPrivacyCommand::query:
|
||||
if (!privacy.Reply().active) privacy.Query();
|
||||
break;
|
||||
case LinuxPrivacyCommand::enable:
|
||||
privacy.Enable(false);
|
||||
break;
|
||||
case LinuxPrivacyCommand::enable_blocking:
|
||||
privacy.Enable(true);
|
||||
break;
|
||||
case LinuxPrivacyCommand::recover:
|
||||
privacy.Recover();
|
||||
break;
|
||||
case LinuxPrivacyCommand::heartbeat:
|
||||
break;
|
||||
default:
|
||||
connected = false;
|
||||
break;
|
||||
}
|
||||
dirty = true;
|
||||
}
|
||||
if (!connected || stopping) break;
|
||||
const auto before = privacy.Reply();
|
||||
privacy.Poll();
|
||||
if (privacy.Reply().active &&
|
||||
Clock::now() - heartbeat > std::chrono::seconds(3))
|
||||
privacy.Fail(
|
||||
"Privacy controller stopped responding; local desktop and input "
|
||||
"restored");
|
||||
const auto& after = privacy.Reply();
|
||||
dirty |= before.active != after.active ||
|
||||
before.recovered != after.recovered ||
|
||||
before.emergency != after.emergency ||
|
||||
std::strcmp(before.failure, after.failure) != 0;
|
||||
if (dirty) {
|
||||
const auto& reply = privacy.Reply();
|
||||
if (send(socket, &reply, sizeof(reply), MSG_NOSIGNAL) == sizeof(reply)) {
|
||||
dirty = false;
|
||||
privacy.AcknowledgeEmergency();
|
||||
} else if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR)
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Keep trying if the X server temporarily rejected restoration, even after
|
||||
// the parent exited. Disconnecting releases XI grabs and cursor hiding too.
|
||||
do {
|
||||
privacy.Recover();
|
||||
if (!privacy.Reply().recovered) poll(nullptr, 0, 250);
|
||||
} while (!privacy.Reply().recovered);
|
||||
const auto& reply = privacy.Reply();
|
||||
const auto ignored = send(socket, &reply, sizeof(reply), MSG_NOSIGNAL);
|
||||
(void)ignored;
|
||||
close(socket);
|
||||
return 0;
|
||||
}
|
||||
} // namespace crossdesk
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-13
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _PRIVACY_GUARD_H_
|
||||
#define _PRIVACY_GUARD_H_
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
inline constexpr char kLinuxPrivacyGuardArgument[] = "--linux-privacy-guard";
|
||||
inline constexpr int kLinuxPrivacyGuardSocket = 3;
|
||||
|
||||
// Private protocol over an inherited SOCK_SEQPACKET socket. No listening
|
||||
// socket, shell, elevated privileges or separately installed helper is needed.
|
||||
enum class LinuxPrivacyCommand : uint8_t {
|
||||
query,
|
||||
enable,
|
||||
enable_blocking,
|
||||
recover,
|
||||
heartbeat,
|
||||
};
|
||||
|
||||
struct LinuxPrivacyReply {
|
||||
uint64_t generation = 0;
|
||||
bool supported = false;
|
||||
bool input_supported = false;
|
||||
bool active = false;
|
||||
bool recovered = true;
|
||||
bool emergency = false;
|
||||
char capability_reason[256]{};
|
||||
char failure[256]{};
|
||||
};
|
||||
|
||||
int RunLinuxPrivacyGuard();
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
#include <display_stream_id.h>
|
||||
#include "platform.h"
|
||||
#include "privacy_controller.h"
|
||||
#include "rd_log.h"
|
||||
#if defined(CROSSDESK_HAS_DRM) && CROSSDESK_HAS_DRM
|
||||
#include "screen_capturer_drm.h"
|
||||
@@ -64,6 +65,13 @@ int ScreenCapturerLinux::Init(const int fps, cb_desktop_data cb) {
|
||||
return;
|
||||
}
|
||||
invalid_stream_id_logged_.store(false, std::memory_order_relaxed);
|
||||
{
|
||||
std::lock_guard lock(privacy_mutex_);
|
||||
if (privacy_ && !capture_paused_ && !capture_announced_) {
|
||||
capture_announced_ = true;
|
||||
privacy_->CaptureChanged(true);
|
||||
}
|
||||
}
|
||||
if (callback_orig_) {
|
||||
callback_orig_(data, size, width, height, mapped_stream_id.c_str(),
|
||||
native_frame);
|
||||
@@ -145,6 +153,7 @@ int ScreenCapturerLinux::Init(const int fps, cb_desktop_data cb) {
|
||||
}
|
||||
|
||||
int ScreenCapturerLinux::Destroy() {
|
||||
Stop();
|
||||
if (impl_) {
|
||||
impl_->Destroy();
|
||||
impl_.reset();
|
||||
@@ -169,6 +178,11 @@ int ScreenCapturerLinux::Start(bool show_cursor) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
{
|
||||
std::lock_guard lock(privacy_mutex_);
|
||||
capture_paused_ = false;
|
||||
}
|
||||
|
||||
// X11 output names, DRM connectors and Wayland stream handles may all change
|
||||
// after an HDMI hotplug, so rebuild the current backend before each session.
|
||||
const int refresh_ret = RefreshCurrentBackend();
|
||||
@@ -226,6 +240,11 @@ int ScreenCapturerLinux::Stop() {
|
||||
return 0;
|
||||
}
|
||||
const int ret = impl_->Stop();
|
||||
{
|
||||
std::lock_guard lock(privacy_mutex_);
|
||||
capture_announced_ = false;
|
||||
if (privacy_) privacy_->CaptureChanged(false);
|
||||
}
|
||||
UpdateAliasesFromBackend(impl_.get());
|
||||
return ret;
|
||||
}
|
||||
@@ -234,13 +253,25 @@ int ScreenCapturerLinux::Pause(int monitor_index) {
|
||||
if (!impl_) {
|
||||
return -1;
|
||||
}
|
||||
return impl_->Pause(monitor_index);
|
||||
const int ret = impl_->Pause(monitor_index);
|
||||
{
|
||||
// An in-flight frame must not announce running again after a pause.
|
||||
std::lock_guard lock(privacy_mutex_);
|
||||
capture_paused_ = true;
|
||||
capture_announced_ = false;
|
||||
if (privacy_) privacy_->CaptureChanged(false);
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
int ScreenCapturerLinux::Resume(int monitor_index) {
|
||||
if (!impl_) {
|
||||
return -1;
|
||||
}
|
||||
{
|
||||
std::lock_guard lock(privacy_mutex_);
|
||||
capture_paused_ = false;
|
||||
}
|
||||
return impl_->Resume(monitor_index);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ class ScreenCapturerLinux : public ScreenCapturer {
|
||||
|
||||
public:
|
||||
int Init(const int fps, cb_desktop_data cb) override;
|
||||
void SetPrivacyController(PrivacyController* privacy) override { privacy_ = privacy; }
|
||||
int Destroy() override;
|
||||
int Start(bool show_cursor) override;
|
||||
int Stop() override;
|
||||
@@ -53,6 +54,10 @@ class ScreenCapturerLinux : public ScreenCapturer {
|
||||
|
||||
private:
|
||||
std::unique_ptr<ScreenCapturer> impl_;
|
||||
PrivacyController* privacy_ = nullptr;
|
||||
std::mutex privacy_mutex_;
|
||||
bool capture_announced_ = false;
|
||||
bool capture_paused_ = false;
|
||||
BackendType backend_ = BackendType::kNone;
|
||||
int fps_ = 60;
|
||||
cb_desktop_data callback_;
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "runtime/cursor_position.h"
|
||||
#include "../../privacy/privacy_cursor_mac.h"
|
||||
|
||||
namespace crossdesk {
|
||||
namespace {
|
||||
@@ -161,7 +162,9 @@ bool CursorStateProvider::Sample(const std::vector<DisplayInfo>& 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;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <unordered_map>
|
||||
|
||||
#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<int, int> 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);
|
||||
}
|
||||
|
||||
@@ -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 <cstdint>
|
||||
|
||||
namespace crossdesk {
|
||||
// Shared by the keyboard/mouse injectors and the privacy input tap.
|
||||
inline constexpr int64_t kCrossDeskInjectedEvent = 0x43524f5353444553;
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "mouse_controller.h"
|
||||
#include "../mac_injected_event.h"
|
||||
|
||||
#include <remote_action.h>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
/* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. */
|
||||
|
||||
#import <AppKit/AppKit.h>
|
||||
#import <ApplicationServices/ApplicationServices.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <deque>
|
||||
#include <functional>
|
||||
#include <cstdlib>
|
||||
#include <libproc.h>
|
||||
#include <mutex>
|
||||
#include <utility>
|
||||
#include <unistd.h>
|
||||
|
||||
#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<std::function<void()>> tasks;
|
||||
std::mutex capture_mutex;
|
||||
MacPrivacyCaptureState capture_state;
|
||||
|
||||
void OnMain(std::function<void()> task) {
|
||||
{
|
||||
std::lock_guard lock(tasks_mutex);
|
||||
tasks.push_back(std::move(task));
|
||||
}
|
||||
dispatch_async(dispatch_get_main_queue(), ^{ FlushMacPrivacyTasks(); });
|
||||
}
|
||||
|
||||
void PublishCovers(std::vector<uint32_t> 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<Monitor> 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<Monitor>& monitors) {
|
||||
uint32_t count = 0;
|
||||
if (CGGetActiveDisplayList(0, nullptr, &count) != kCGErrorSuccess || !count)
|
||||
return "No active macOS desktop; privacy screen unavailable";
|
||||
std::vector<CGDirectDisplayID> 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<WindowState>()) {
|
||||
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<uint32_t> 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<uint32_t>(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<MacPrivacyBackend*>(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<WindowState> 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<std::function<void()>> 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<PrivacyBackend> CreateMacPrivacyBackend() {
|
||||
return std::make_unique<MacPrivacyBackend>();
|
||||
}
|
||||
} // namespace crossdesk
|
||||
@@ -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 <cstdint>
|
||||
#include <vector>
|
||||
|
||||
namespace crossdesk {
|
||||
struct MacPrivacyCaptureState {
|
||||
uint64_t revision = 0;
|
||||
uint64_t applied_revision = 0;
|
||||
std::vector<uint32_t> excluded_windows;
|
||||
uint32_t cursor_window = 0;
|
||||
};
|
||||
MacPrivacyCaptureState GetMacPrivacyCaptureState();
|
||||
void AcknowledgeMacPrivacyCapture(uint64_t revision);
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -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 <AppKit/AppKit.h>
|
||||
|
||||
namespace crossdesk {
|
||||
// Returns nil if the CrossDesk icon resource cannot be loaded.
|
||||
NSView* CreateMacPrivacyCoverView(NSRect frame, NSString* unlock_hint);
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,106 @@
|
||||
/* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. */
|
||||
|
||||
#include "privacy_cover_view.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
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<NSString*>* 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;
|
||||
}
|
||||
}
|
||||
@@ -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 <memory>
|
||||
#include <cstdint>
|
||||
|
||||
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> impl_;
|
||||
};
|
||||
|
||||
// Local hiding must not be reported as an invisible cursor to controllers.
|
||||
bool IsMacPrivacyCursorHidden();
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,157 @@
|
||||
/* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. */
|
||||
#include "privacy_cursor_mac.h"
|
||||
|
||||
#import <AppKit/AppKit.h>
|
||||
#import <ApplicationServices/ApplicationServices.h>
|
||||
#include <atomic>
|
||||
#include <dlfcn.h>
|
||||
|
||||
@interface CrossDeskPrivacyCursorPanel : NSPanel
|
||||
@end
|
||||
@implementation CrossDeskPrivacyCursorPanel
|
||||
- (BOOL)canBecomeKeyWindow { return NO; }
|
||||
- (BOOL)canBecomeMainWindow { return NO; }
|
||||
@end
|
||||
|
||||
namespace crossdesk {
|
||||
namespace {
|
||||
std::atomic<bool> 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<Connection>(dlsym(RTLD_DEFAULT, "CGSMainConnectionID"));
|
||||
Copy copy = reinterpret_cast<Copy>(dlsym(RTLD_DEFAULT, "CGSCopyConnectionProperty"));
|
||||
Set set = reinterpret_cast<Set>(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<Impl>()) {}
|
||||
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<uint32_t>(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(); }
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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<ScreenCapturer> screen_capturer_sck_impl_;
|
||||
PrivacyController* privacy_ = nullptr;
|
||||
};
|
||||
} // namespace crossdesk
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
#include "display_info.h"
|
||||
#include <display_stream_id.h>
|
||||
#include "rd_log.h"
|
||||
#include "privacy_controller.h"
|
||||
#include "../privacy/privacy_capture_state.h"
|
||||
#include <algorithm>
|
||||
|
||||
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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<SCWindow*>* 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<std::mutex> lock(lock_);
|
||||
if (!capture_requested_) return;
|
||||
std::unique_lock<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> lock(_capturer_lock);
|
||||
_capturer = nullptr;
|
||||
}
|
||||
|
||||
- (void)stream:(SCStream*)stream didStopWithError:(NSError*)error {
|
||||
std::lock_guard<std::mutex> lock(_capturer_lock);
|
||||
if (_capturer) _capturer->CaptureStopped(stream, error);
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
std::unique_ptr<ScreenCapturer> ScreenCapturerSck::CreateScreenCapturerSck() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
#include <wtsapi32.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#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<HWND>(cover->hwnd), nullptr});
|
||||
windows_.push_back({rect, reinterpret_cast<HWND>(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<GetPrivacyWindowBand>(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 <algorithm>
|
||||
|
||||
#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<int>(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<int>(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<int>(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<int>(extent.cx) + 4,
|
||||
MulDiv(520, static_cast<int>(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<int>(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<uint32_t*>(bits);
|
||||
std::fill(pixels, pixels + static_cast<size_t>(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<size_t>(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<size_t>(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<size_t>(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<size_t>(width) * height);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
@@ -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 <Windows.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
struct PrivacyProbeImage {
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
std::vector<uint32_t> 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_
|
||||
@@ -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);
|
||||
|
||||
@@ -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<std::mutex> 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_t>(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<uint8_t> 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));
|
||||
|
||||
@@ -45,7 +45,7 @@ class ScreenCapturerWin : public ScreenCapturer {
|
||||
int ResetToInitialMonitor() override;
|
||||
|
||||
std::vector<DisplayInfo> 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; }
|
||||
|
||||
|
||||
@@ -7,14 +7,12 @@
|
||||
#ifndef _PRIVACY_BACKEND_H_
|
||||
#define _PRIVACY_BACKEND_H_
|
||||
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
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,26 @@ 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<PrivacyBackend> CreateWindowsPrivacyBackend();
|
||||
bool IsWindowsPrivacyDesktopAvailable();
|
||||
#endif
|
||||
#ifdef __APPLE__
|
||||
std::unique_ptr<PrivacyBackend> CreateMacPrivacyBackend();
|
||||
// Flush queued AppKit cleanup after joining the controller at app shutdown.
|
||||
void FlushMacPrivacyTasks();
|
||||
#endif
|
||||
#ifdef __linux__
|
||||
std::unique_ptr<PrivacyBackend> CreateLinuxPrivacyBackend();
|
||||
#endif
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
|
||||
@@ -1,496 +1,210 @@
|
||||
/*
|
||||
* @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 <chrono>
|
||||
#include <cstring>
|
||||
|
||||
#include "rd_log.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
uint64_t PrivacyController::Now() {
|
||||
return static_cast<uint64_t>(
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::steady_clock::now().time_since_epoch())
|
||||
.count());
|
||||
return std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
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;
|
||||
#elif defined(__linux__)
|
||||
if (!factory) factory = CreateLinuxPrivacyBackend;
|
||||
#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<int>(state), status_.overlay_active, status_.input_blocked,
|
||||
status_.remote_paused, reason);
|
||||
LOG_INFO("Privacy: state={}, overlay={}, input_blocked={}, reason={}",
|
||||
static_cast<int>(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;
|
||||
// Windows announces a stopped capture while preparing the first session.
|
||||
// Keep automatic privacy pending until capture starts, within its deadline.
|
||||
// Once capture was running or backend enable began, a stop remains a fault.
|
||||
const bool waiting_for_initial_capture =
|
||||
automatic_enable_ && enable_pending_ && !capture_running_;
|
||||
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 && !waiting_for_initial_capture)
|
||||
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<size_t>(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<size_t>(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
|
||||
|
||||
@@ -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 <remote_action.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <random>
|
||||
#include <thread>
|
||||
|
||||
#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<std::unique_ptr<PrivacyBackend>()>;
|
||||
@@ -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<PrivacyBackend> backend_;
|
||||
PrivacyStatus status_{};
|
||||
PrivacyFrameVerifier verifier_;
|
||||
PrivacyScreenText text_;
|
||||
std::mt19937 random_{std::random_device{}()};
|
||||
std::atomic<bool> remote_paused_{false};
|
||||
std::atomic<bool> engaged_{false};
|
||||
std::atomic<bool> 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
|
||||
|
||||
@@ -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 <cstddef>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
|
||||
#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<size_t>(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<size_t>(row) * width + x];
|
||||
const auto inverse =
|
||||
y[static_cast<size_t>(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<Challenge> challenges_;
|
||||
std::deque<uint32_t> issued_values_;
|
||||
unsigned matches_ = 0;
|
||||
};
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif // _PRIVACY_FRAME_VERIFIER_H_
|
||||
@@ -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 <cstdint>
|
||||
|
||||
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_
|
||||
@@ -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;
|
||||
|
||||
@@ -167,6 +167,13 @@ 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})
|
||||
elseif is_os("linux") then
|
||||
add_files("apps/desktop/src/platform/linux/privacy/*.cpp")
|
||||
add_syslinks("Xi", {public = true})
|
||||
end
|
||||
|
||||
if is_os("windows") then
|
||||
@@ -199,10 +206,13 @@ 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")
|
||||
elseif is_os("linux") then
|
||||
add_deps("privacy")
|
||||
add_packages("libyuv")
|
||||
add_files("apps/desktop/src/platform/linux/screen_capturer/screen_capturer_linux.cpp")
|
||||
add_files("apps/desktop/src/platform/linux/screen_capturer/screen_capturer_x11.cpp")
|
||||
|
||||
Vendored
+1
-1
Submodule deps/submodules/minirtc updated: 9313ae8b92...32b689b170
+3
-1
@@ -11,7 +11,7 @@
|
||||
|
||||
## Windows
|
||||
|
||||
安装 Visual Studio 2022 的“使用 C++ 的桌面开发”工作负载及 Windows SDK,并安装 Xmake。在开发者 PowerShell 中执行:
|
||||
安装 Visual Studio 2022 的“使用 C++ 的桌面开发”工作负载、Windows SDK 和“适用于 Windows 的 C++ Clang 编译器”组件,并安装 Xmake。Windows x64 的 libyuv 使用 clang-cl 编译,以启用 SIMD 图像转换和缩放;主程序仍使用 MSVC。在开发者 PowerShell 中执行:
|
||||
|
||||
```powershell
|
||||
git clone --recurse-submodules https://github.com/kunkundi/crossdesk.git
|
||||
@@ -21,6 +21,8 @@ xmake b -vy crossdesk
|
||||
xmake r crossdesk
|
||||
```
|
||||
|
||||
性能复测应使用 `release`;`debug` 保留调试信息并关闭主程序编译优化。
|
||||
|
||||
安装包还使用 NSIS,需将仓库中的 [nsProcess.dll](../apps/desktop/scripts/windows/nsProcess.dll) 放入 NSIS 的 `Plugins/x86-unicode` 目录,并确保 `makensis` 在 `PATH` 中。完成 Release 编译后运行:
|
||||
|
||||
```powershell
|
||||
|
||||
+3
-1
@@ -11,7 +11,7 @@ After cloning, run build and packaging commands from the repository root. The [X
|
||||
|
||||
## Windows
|
||||
|
||||
Install Visual Studio 2022 with the **Desktop development with C++** workload and Windows SDK, plus Xmake. In a developer PowerShell, run:
|
||||
Install Visual Studio 2022 with the **Desktop development with C++** workload, Windows SDK, and **C++ Clang Compiler for Windows** component, plus Xmake. Windows x64 builds use clang-cl for libyuv to enable SIMD conversion and scaling; the application still uses MSVC. In a developer PowerShell, run:
|
||||
|
||||
```powershell
|
||||
git clone --recurse-submodules https://github.com/kunkundi/crossdesk.git
|
||||
@@ -21,6 +21,8 @@ xmake b -vy crossdesk
|
||||
xmake r crossdesk
|
||||
```
|
||||
|
||||
Use `release` for performance measurements; `debug` keeps symbols and disables application compiler optimizations.
|
||||
|
||||
Installer packaging also uses NSIS. Copy the repository's [nsProcess.dll](../apps/desktop/scripts/windows/nsProcess.dll) into NSIS's `Plugins/x86-unicode` directory and ensure `makensis` is on `PATH`. After building Release, run:
|
||||
|
||||
```powershell
|
||||
|
||||
Reference in New Issue
Block a user