[feat] add macOS privacy screen and keep remote sessions running

This commit is contained in:
kunkundi
2026-09-13 00:38:40 +08:00
parent 23bf2ff8b8
commit a6e325f190
38 changed files with 1161 additions and 1046 deletions
@@ -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_]});
}
@@ -1237,6 +1236,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__)
ui_->main->set_privacy_setting_available(true);
#else
ui_->main->set_privacy_setting_available(false);
@@ -2569,14 +2570,10 @@ void GuiApplication::SyncStreamWindow() {
const int language = localization_language_index_;
std::string text = !connected || !props->privacy_status_received_
? localization::privacy_screen[language]
: controls.timeout || controls.stale ? localization::privacy_timeout[language]
: controls.warning ? localization::privacy_paused[language]
: controls.unsupported ? localization::privacy_unsupported[language]
: controls.enabled ? localization::privacy_on[language]
: !status.supported ? localization::privacy_unsupported[language]
: controls.off ? localization::privacy_off[language]
: localization::privacy_screen[language];
if (connected && controls.warning && status.reason[0] != '\0')
text += std::string("\n") + status.reason;
(*ui_->stream)->set_privacy_status_text(UiText(text));
}
int remote_cursor_shape =
@@ -2822,13 +2819,17 @@ void GuiApplication::SyncServerWindow() {
});
}
// Keep the local connection panel out of captured privacy sessions and
// transitions; it is unnecessary while the physical screens are covered.
// macOS covers the local panel with capture-excluded privacy windows. Keep
// the panel alive so remote controllers can still see and operate it.
bool show_controller_window = has_connected_controller;
#if !defined(__APPLE__)
// Preserve the existing panel behavior on other platforms.
const auto privacy_status = privacy_.Snapshot();
const bool privacy_covering = privacy_status.overlay_active ||
privacy_status.state == PrivacyState::starting ||
privacy_status.state == PrivacyState::stopping;
const bool show_controller_window = has_connected_controller && !privacy_covering;
show_controller_window = show_controller_window && !privacy_covering;
#endif
if (show_controller_window && !ui_->server) {
ui_->server.emplace(ui::ServerWindow::create());
RegisterFontAwesome((*ui_->server)->window());
@@ -2998,7 +2999,6 @@ void GuiApplication::SaveSettingsFromUi() {
localization_language_index_ = language_button_value_;
config_center_->SetLanguage(localization_language_);
privacy_.SetText({
localization::privacy_verification[localization_language_index_],
localization::privacy_screen_unlock_hint[localization_language_index_]});
config_center_->SetVideoQuality(
static_cast<ConfigCenter::VIDEO_QUALITY>(video_quality_button_value_));
@@ -3026,6 +3026,8 @@ void GuiApplication::SaveSettingsFromUi() {
}
main->set_capture_method_index(
static_cast<int>(config_center_->GetScreenCaptureMethod()));
#endif
#if defined(_WIN32) || defined(__APPLE__)
if (config_center_->SetPrivacyScreen(main->get_privacy_on_connect_enabled()) !=
0) {
main->set_privacy_on_connect_enabled(
@@ -3365,7 +3367,7 @@ void GuiApplication::Cleanup() {
#if _WIN32 && CROSSDESK_PORTABLE
JoinPortableWindowsServiceInstallThread();
#endif
privacy_.Fail("Application exiting; remote operation paused");
privacy_.Disable();
clipboard_.Shutdown();
keyboard_.ForceReleasePressedKeys();
keyboard_.ReleaseAllRemotePressedKeys("application_exit");
@@ -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__)
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_ &&