[feat] add privacy unlock controls and remote relocking

This commit is contained in:
dijunkun
2026-10-27 10:22:25 +08:00
parent d1e4a31efb
commit ea3a2e1faa
41 changed files with 470 additions and 72 deletions
@@ -137,6 +137,8 @@ int ConfigCenter::Load() {
enable_daemon_ = ini_->GetBoolValue(section_, "enable_daemon", enable_daemon_);
enable_privacy_screen_.store(
ini_->GetBoolValue(section_, "enable_privacy_screen", true));
privacy_local_unlock_allowed_.store(
ini_->GetBoolValue(section_, "privacy_local_unlock_allowed", true));
lock_on_disconnect_.store(
ini_->GetBoolValue(section_, "lock_on_disconnect", true));
save_remote_previews_.store(
@@ -234,6 +236,8 @@ int ConfigCenter::Save() {
ini_->SetBoolValue(section_, "enable_daemon", enable_daemon_);
ini_->SetBoolValue(section_, "enable_privacy_screen",
enable_privacy_screen_.load());
ini_->SetBoolValue(section_, "privacy_local_unlock_allowed",
privacy_local_unlock_allowed_.load());
ini_->SetBoolValue(section_, "lock_on_disconnect", lock_on_disconnect_.load());
ini_->SetBoolValue(section_, "save_remote_previews",
save_remote_previews_.load());
@@ -409,6 +413,18 @@ int ConfigCenter::SetPrivacyScreen(bool enable_privacy_screen) {
return 0;
}
int ConfigCenter::SetPrivacyLocalUnlock(bool allowed) {
if (privacy_local_unlock_allowed_.load() == allowed) return 0;
if (StoreValues({{"privacy_local_unlock_allowed", allowed ? "true" : "false"}}) != 0)
return -1;
privacy_local_unlock_allowed_.store(allowed);
return 0;
}
bool ConfigCenter::IsPrivacyLocalUnlockAllowed() const {
return privacy_local_unlock_allowed_.load();
}
int ConfigCenter::SetLockOnDisconnect(bool enabled) {
if (StoreValues({{"lock_on_disconnect", enabled ? "true" : "false"}}) != 0)
return -1;
@@ -49,6 +49,7 @@ class ConfigCenter {
int SetAutostart(bool enable_autostart);
int SetDaemon(bool enable_daemon);
int SetPrivacyScreen(bool enable_privacy_screen);
int SetPrivacyLocalUnlock(bool allowed);
int SetLockOnDisconnect(bool enabled);
int SetSaveRemotePreviews(bool enabled);
int SetPortableServicePromptSuppressed(bool suppressed);
@@ -73,6 +74,7 @@ class ConfigCenter {
bool IsEnableAutostart() const;
bool IsEnableDaemon() const;
bool IsEnablePrivacyScreen() const;
bool IsPrivacyLocalUnlockAllowed() const;
bool IsLockOnDisconnect() const;
bool IsSaveRemotePreviews() const;
bool IsPortableServicePromptSuppressed() const;
@@ -105,6 +107,7 @@ class ConfigCenter {
bool enable_autostart_ = false;
bool enable_daemon_ = false;
std::atomic<bool> enable_privacy_screen_{true};
std::atomic<bool> privacy_local_unlock_allowed_{true};
std::atomic<bool> lock_on_disconnect_{true};
std::atomic<bool> save_remote_previews_{true};
bool portable_service_prompt_suppressed_ = false;
@@ -1504,6 +1504,7 @@ void GuiApplication::ResetSettingsUi(bool gallery_window) {
main->set_save_remote_previews(config_center_->IsSaveRemotePreviews());
main->set_privacy_on_connect_enabled(
config_center_->IsEnablePrivacyScreen());
main->set_privacy_local_unlock_allowed(config_center_->IsPrivacyLocalUnlockAllowed());
main->set_lock_on_disconnect_enabled(config_center_->IsLockOnDisconnect());
#ifdef _WIN32
main->set_capture_method_visible(true);
@@ -2155,9 +2156,9 @@ void GuiApplication::BindStreamCallbacks() {
SendReliableDataFrame(props->peer_, message.c_str(), message.size(),
props->control_data_label_.c_str());
});
stream->on_toggle_privacy([this] {
const auto send_privacy = [this](bool relock) {
auto props = SelectedSession();
if (!props || !props->peer_ || !props->control_mouse_ ||
if (!props || !props->peer_ || (!relock && !props->control_mouse_) ||
props->connection_status_.load() != ConnectionStatus::Connected) return;
RemoteAction action{};
action.type = ControlType::privacy_command;
@@ -2168,9 +2169,12 @@ void GuiApplication::BindStreamCallbacks() {
const auto controls = GetPrivacyControlState(
status, props->privacy_status_received_, props->privacy_command_pending_,
now - props->privacy_status_tick_, now - props->privacy_command_tick_);
if (!props->privacy_desktop_available_ || !controls.can_toggle) return;
action.pc.flag = controls.active ? PrivacyCommandFlag::disable
: PrivacyCommandFlag::enable;
const bool can_lock = props->privacy_status_received_ && status.supported &&
now - props->privacy_status_tick_ <= 5000 &&
(!props->privacy_command_pending_ || now - props->privacy_command_tick_ >= 15000);
if (!props->privacy_desktop_available_ || !(relock ? can_lock : controls.can_toggle)) return;
action.pc.flag = relock ? PrivacyCommandFlag::lock
: controls.active ? PrivacyCommandFlag::disable : PrivacyCommandFlag::enable;
action.pc.block_local_input = true;
props->privacy_command_pending_ = true;
props->privacy_command_tick_ = now;
@@ -2181,9 +2185,17 @@ void GuiApplication::BindStreamCallbacks() {
props->control_data_label_.c_str()) != 0) {
std::lock_guard lock(props->privacy_status_mutex_);
props->privacy_command_pending_ = false;
props->privacy_status_received_ = false;
if (!relock) props->privacy_status_received_ = false;
LOG_WARN("Privacy command send failed, remote_id={}", props->remote_id_);
}
};
stream->on_toggle_privacy([send_privacy] { send_privacy(false); });
stream->on_lock_privacy([send_privacy] { send_privacy(true); });
stream->on_dismiss_privacy_unlock([this] {
if (auto props = SelectedSession()) {
std::lock_guard lock(props->privacy_status_mutex_);
props->privacy_unlock_notice_ = false;
}
});
stream->on_toggle_mouse_control([this] {
auto props = SelectedSession();
@@ -2590,6 +2602,11 @@ void GuiApplication::Tick() {
HandlePendingPresenceProbe();
HandlePresenceProbeTimeout();
HandlePrivacy();
if (privacy_settings_changed_) {
for (const auto& window : {ui_->main, ui_->wall})
window->set_privacy_local_unlock_allowed(config_center_->IsPrivacyLocalUnlockAllowed());
privacy_settings_changed_ = false;
}
HandleScreenLock();
HandleWindowsServiceIntegration();
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
@@ -3569,9 +3586,9 @@ void GuiApplication::SyncXWaylandWindowActivation() {
#endif
void GuiApplication::SetStreamKeyboardFocus(bool focused) {
// The local upgrade dialog owns input even while the native window is active.
// Local dialogs own input even while the native window is active.
if (focused && ui_ && ui_->stream &&
(*ui_->stream)->get_remote_update_visible()) {
(*ui_->stream)->get_modal_visible()) {
focused = false;
}
if (focus_on_stream_window_ == focused) {
@@ -3826,6 +3843,11 @@ void GuiApplication::SyncStreamWindow() {
now - props->privacy_status_tick_, now - props->privacy_command_tick_);
const bool connected =
props->connection_status_.load() == ConnectionStatus::Connected;
(*ui_->stream)->set_privacy_unlock_visible(connected && props->privacy_unlock_notice_);
(*ui_->stream)->set_privacy_can_lock(connected && props->privacy_desktop_available_ &&
props->privacy_status_received_ && status.supported &&
now - props->privacy_status_tick_ <= 5000 &&
(!props->privacy_command_pending_ || now - props->privacy_command_tick_ >= 15000));
(*ui_->stream)->set_privacy_active(controls.active);
(*ui_->stream)->set_privacy_off(connected && controls.off);
(*ui_->stream)->set_privacy_unsupported(controls.unsupported);
@@ -4580,6 +4602,14 @@ bool GuiApplication::SaveSettingsFromUi(bool gallery_window) {
return false;
}
#endif
if (config_center_->SetPrivacyLocalUnlock(main->get_privacy_local_unlock_allowed()) != 0) {
if (wall_changed) ui_->gallery.Save(ui_->gallery_path);
main->set_settings_save_error(UiText(
localization_language_index_ == 0 ? "无法保存隐私屏解锁设置,请重试。"
: localization_language_index_ == 2 ? "Не удалось сохранить настройку разблокировки. Повторите попытку."
: "Could not save privacy unlock settings. Try again."));
return false;
}
language_button_value_ =
localization::detail::ClampLanguageIndex(main->get_language_index());
video_encode_format_button_value_ = std::clamp(main->get_codec_index(), 0, 1);
@@ -4594,7 +4624,9 @@ bool GuiApplication::SaveSettingsFromUi(bool gallery_window) {
localization_language_index_ = language_button_value_;
config_center_->SetLanguage(localization_language_);
privacy_.SetText(
{localization::privacy_screen_unlock_hint[localization_language_index_]});
{localization::privacy_screen_unlock_hint[localization_language_index_],
localization::privacy_screen_locked_hint[localization_language_index_],
config_center_->IsPrivacyLocalUnlockAllowed()});
config_center_->SetVideoEncodeFormat(
static_cast<ConfigCenter::VIDEO_ENCODE_FORMAT>(
video_encode_format_button_value_));
@@ -4667,7 +4699,9 @@ void GuiApplication::ApplySettingsFromConfig(bool reconnect) {
enable_self_hosted_ = config_center_->IsSelfHosted();
enable_autostart_ = config_center_->IsEnableAutostart();
enable_daemon_ = config_center_->IsEnableDaemon();
privacy_.SetText({localization::privacy_screen_unlock_hint[localization_language_index_]});
privacy_.SetText({localization::privacy_screen_unlock_hint[localization_language_index_],
localization::privacy_screen_locked_hint[localization_language_index_],
config_center_->IsPrivacyLocalUnlockAllowed()});
const auto path = config_center_->GetFileTransferSavePath();
std::snprintf(file_transfer_save_path_buf_, sizeof(file_transfer_save_path_buf_), "%s", path.c_str());
const auto host = config_center_->GetSignalServerHost();
@@ -404,6 +404,12 @@ struct TranslationRow {
X(wayland_notice_acknowledge, u8"我知道了", "Got it", u8"Понятно") \
X(show_main_window, u8"显示主界面", "Show Main Window", \
u8"Показать главное окно") \
X(privacy_local_unlock, u8"允许被控时解锁隐私屏:", "Allow privacy screen unlock while controlled:", u8"Разрешить разблокировку приватного экрана при удалённом управлении:") \
X(privacy_screen_locked_hint, u8"隐私屏已开启,本机解锁已禁用", "Privacy screen is on. Local unlocking is disabled.", u8"Приватный экран включён. Локальная разблокировка отключена.") \
X(privacy_unlocked_title, u8"隐私屏已被解锁", "Privacy screen unlocked", u8"Приватный экран разблокирован") \
X(privacy_unlocked_message, u8"被控端已通过组合键解除隐私屏。点击“锁定”将重新开启隐私屏,并禁止本机使用组合键解锁。", "The remote privacy screen was unlocked using the local shortcut. Lock it again to restore privacy and disable local shortcut unlocking.", u8"Приватный экран отключён локальным сочетанием клавиш. Заблокируйте его снова, чтобы восстановить защиту и запретить локальную разблокировку.") \
X(privacy_unlock_confirm, u8"确认", "Confirm", u8"Подтвердить") \
X(privacy_unlock_lock, u8"锁定", "Lock", u8"Заблокировать") \
X(privacy_screen_unlock_hint, u8"按下快捷键,解除隐私屏", \
"To turn off the privacy screen, press", \
u8"Чтобы отключить приватный экран, нажмите") \
@@ -162,6 +162,7 @@ class GuiRuntime : protected gui_detail::GuiState {
DisconnectLockPolicy disconnect_lock_;
std::mutex privacy_commands_mutex_;
std::deque<std::pair<std::string, PrivacyCommand>> privacy_commands_;
bool privacy_settings_changed_ = false;
uint32_t last_privacy_revision_ = 0;
uint64_t last_privacy_status_tick_ = 0;
ClipboardController clipboard_;
@@ -316,6 +316,12 @@ void PeerEventHandler::OnReceiveDataBuffer(
std::lock_guard lock(props->privacy_status_mutex_);
if (!props->privacy_status_received_ ||
static_cast<int32_t>(remote_action.ps.revision - props->privacy_status_.revision) >= 0) {
if (props->privacy_status_received_ &&
static_cast<int32_t>(remote_action.ps.local_unlock_revision -
props->privacy_status_.local_unlock_revision) > 0)
props->privacy_unlock_notice_ = true;
if (remote_action.ps.state == PrivacyState::on)
props->privacy_unlock_notice_ = false;
props->privacy_status_ = remote_action.ps;
props->privacy_status_received_ = true;
props->privacy_status_tick_ = SDL_GetTicks();
@@ -10,6 +10,7 @@
#include <shared_mutex>
#include "rd_log.h"
#include "localization.h"
#include "runtime/gui_runtime.h"
namespace crossdesk {
@@ -71,6 +72,14 @@ void GuiRuntime::QueuePrivacyCommand(const std::string& remote_id,
}
void GuiRuntime::HandlePrivacy() {
const auto sync_text = [this] {
const int language = localization::detail::ClampLanguageIndex(
static_cast<int>(config_center_->GetLanguage()));
privacy_.SetText({localization::privacy_screen_unlock_hint[language],
localization::privacy_screen_locked_hint[language],
config_center_->IsPrivacyLocalUnlockAllowed()});
};
sync_text();
std::deque<std::pair<std::string, PrivacyCommand>> commands;
{
std::lock_guard lock(privacy_commands_mutex_);
@@ -87,6 +96,16 @@ void GuiRuntime::HandlePrivacy() {
if (command.flag == PrivacyCommandFlag::enable) {
if (privacy_sessions_.CanEnable())
privacy_.Enable(command.block_local_input);
} else if (command.flag == PrivacyCommandFlag::lock) {
if (privacy_sessions_.CanEnable()) {
if (config_center_->SetPrivacyLocalUnlock(false) == 0) {
privacy_settings_changed_ = true;
sync_text();
privacy_.Enable(true, true);
} else {
privacy_.RejectCommand("Could not save the local privacy unlock setting; try again");
}
}
} else if (command.flag == PrivacyCommandFlag::disable) {
privacy_.Disable();
}
@@ -193,6 +193,7 @@ struct RemoteSession {
std::mutex privacy_status_mutex_;
PrivacyStatus privacy_status_{};
bool privacy_status_received_ = false;
bool privacy_unlock_notice_ = false;
bool privacy_desktop_available_ = true;
bool privacy_command_pending_ = false;
uint64_t privacy_command_tick_ = 0;
+4 -1
View File
@@ -81,6 +81,7 @@ export global UiStrings {
in-out property <string> daemon: "Run in background";
in-out property <string> save-remote-previews: "Save remote screen previews:";
in-out property <string> unknown-platform: "Unknown platform";
in-out property <string> privacy-local-unlock: "Allow privacy screen unlock while controlled:";
in-out property <string> privacy-on-connect: "Privacy screen on connect";
in-out property <string> lock-on-disconnect: "Lock screen on disconnect";
in-out property <string> file-save-path: "File save path";
@@ -651,6 +652,7 @@ export component MainWindow inherits Window {
// Show the preference on every platform; only adapted hosts can change it.
in property <bool> privacy-setting-available: false;
in-out property <bool> privacy-on-connect-enabled: true;
in-out property <bool> privacy-local-unlock-allowed: true;
in property <bool> lock-on-disconnect-available: false;
in-out property <bool> lock-on-disconnect-enabled: true;
in-out property <string> file-save-path: "";
@@ -2728,8 +2730,9 @@ export component MainWindow inherits Window {
font-size: 11px; color: WorkbenchStyle.muted; wrap: word-wrap;
}
SettingsGroup {
title: GalleryStrings.tr("隐私保护", "Privacy", "Приватность"); rows: 2;
title: GalleryStrings.tr("隐私保护", "Privacy", "Приватность"); rows: 3;
SettingsToggleRow { label: UiStrings.privacy-on-connect; checked <=> root.privacy-on-connect-enabled; enabled: root.privacy-setting-available; }
SettingsToggleRow { label: UiStrings.privacy-local-unlock; checked <=> root.privacy-local-unlock-allowed; enabled: root.privacy-setting-available; }
SettingsToggleRow { label: UiStrings.lock-on-disconnect; checked <=> root.lock-on-disconnect-enabled; enabled: root.lock-on-disconnect-available; divider: false; }
}
Text {
+57 -13
View File
@@ -47,6 +47,10 @@ export global StreamStrings {
in-out property <string> select-display: "Select Display";
in-out property <string> send-shortcut: "Send Shortcut";
in-out property <string> privacy-unlocked-title: "Privacy screen unlocked";
in-out property <string> privacy-unlocked-message: "The remote privacy screen was unlocked using the local shortcut. Lock it again to restore privacy and disable local shortcut unlocking.";
in-out property <string> privacy-unlock-confirm: "Confirm";
in-out property <string> privacy-unlock-lock: "Lock";
in-out property <string> privacy-enable: "Enable privacy screen";
in-out property <string> privacy-disable: "Turn privacy off / recover";
in-out property <string> control-mouse: "Control Mouse";
@@ -296,6 +300,11 @@ export component StreamWindow inherits Window {
in property <string> status-text: "";
in property <string> receiving-text: "";
in property <bool> remote-update-visible: false;
in property <bool> privacy-unlock-visible: false;
in property <bool> privacy-can-lock: false;
out property <bool> modal-visible: root.remote-update-visible || root.privacy-unlock-visible;
callback dismiss-privacy-unlock;
callback lock-privacy;
callback dismiss-remote-update;
in property <[string]> displays;
in-out property <int> selected-display: 0;
@@ -491,8 +500,8 @@ export component StreamWindow inherits Window {
changed mouse-control-enabled => {
if !root.mouse-control-enabled { root.cancel-remote-pointer(); }
}
changed remote-update-visible => {
if root.remote-update-visible {
changed modal-visible => {
if root.modal-visible {
root.close-control-menus();
root.control-drag-pending = false;
root.end-control-drag();
@@ -502,7 +511,7 @@ export component StreamWindow inherits Window {
// adapter and aborts the process; defer focus to the event loop.
input-focus-timer.running = true;
input-focus-timer.restart();
root.keyboard-focus-changed(!root.remote-update-visible);
root.keyboard-focus-changed(!root.modal-visible);
}
input-focus-timer := Timer {
interval: 1ms;
@@ -617,7 +626,7 @@ export component StreamWindow inherits Window {
root.set-remote-button(button, false);
root.pointer-input(button, kind, x, y);
}
} else if root.window-active && root.mouse-control-enabled && !root.remote-update-visible {
} else if root.window-active && root.mouse-control-enabled && !root.modal-visible {
let remote-target = root.remote-pointer-active
|| (!root.control-menu-open && !root.is-local-control-area(x, y));
if kind == PointerEventKind.down && remote-target
@@ -653,7 +662,7 @@ export component StreamWindow inherits Window {
// so a captured drag can never also toggle the toolbar.
let toggle-expanded = kind == PointerEventKind.up && root.control-drag-pending
&& !root.control-dragging
&& root.window-active && !root.remote-update-visible
&& root.window-active && !root.modal-visible
&& x >= control.x + logo-button.x && x < control.x + logo-button.x + logo-button.width
&& y >= control.y + logo-button.y && y < control.y + logo-button.y + logo-button.height;
root.control-drag-pending = false;
@@ -702,7 +711,7 @@ export component StreamWindow inherits Window {
}
is-local-control-area(pointer-x, pointer-y) => {
if root.remote-update-visible { return true; }
if root.modal-visible { return true; }
let over-control = pointer-x >= root.control-surface-x
&& pointer-x <= root.control-surface-x + root.control-surface-width
&& pointer-y >= control.y
@@ -1015,11 +1024,11 @@ export component StreamWindow inherits Window {
drop-zone := DropArea {
can-drop(event) => {
return !root.remote-update-visible && root.can-drop-file(event.data)
return !root.modal-visible && root.can-drop-file(event.data)
? DragAction.copy : DragAction.none;
}
dropped(event) => {
if root.remote-update-visible { return DragAction.none; }
if root.modal-visible { return DragAction.none; }
root.file-dropped(event.data);
return DragAction.copy;
}
@@ -1030,16 +1039,16 @@ export component StreamWindow inherits Window {
focus-gained => { root.keyboard-focus-changed(true); }
focus-lost => { root.keyboard-focus-changed(false); }
key-pressed(event) => {
if !root.remote-update-visible {
if !root.modal-visible {
root.key-input(event.text, true, event.modifiers.control, event.modifiers.alt, event.modifiers.shift, event.modifiers.meta);
}
accept
}
key-released(event) => {
if root.remote-update-visible {
if root.modal-visible {
// Dismiss on release so the same key cannot reach the peer.
if event.text == Key.Escape || event.text == Key.Return {
root.dismiss-remote-update();
if root.privacy-unlock-visible { root.dismiss-privacy-unlock(); } else { root.dismiss-remote-update(); }
}
} else {
root.key-input(event.text, false, event.modifiers.control, event.modifiers.alt, event.modifiers.shift, event.modifiers.meta);
@@ -1743,7 +1752,7 @@ export component StreamWindow inherits Window {
}
}
if root.remote-update-visible: Rectangle {
if root.modal-visible: Rectangle {
width: parent.width; height: parent.height;
background: #0c162c52;
z: 13;
@@ -1752,8 +1761,43 @@ export component StreamWindow inherits Window {
scroll-event(event) => { accept }
}
}
if root.privacy-unlock-visible: DialogSurface {
x: (parent.width - self.width) / 2;
y: max(16px, (parent.height - self.height) / 2);
width: min(460px, parent.width - 32px);
height: unlock-message.y + unlock-message.height + 84px;
outline-color: UiSurface.edge;
border-radius: UiRadius.surface;
z: 14;
TouchArea { scroll-event(event) => { accept } }
unlock-title := Text {
x: 24px; y: 24px; width: parent.width - 48px;
height: max(28px, self.preferred-height);
text: StreamStrings.privacy-unlocked-title;
color: #1b2433; font-size: 20px; font-weight: 600; wrap: word-wrap;
}
unlock-message := Text {
x: 24px; y: unlock-title.y + unlock-title.height + 8px;
width: parent.width - 48px; height: max(22px, self.preferred-height);
text: StreamStrings.privacy-unlocked-message;
color: #667085; font-size: 15px; wrap: word-wrap;
}
SecondaryButton {
x: parent.width - 244px; y: parent.height - 60px;
width: 104px; height: 36px;
text: StreamStrings.privacy-unlock-confirm;
clicked => { root.dismiss-privacy-unlock(); }
}
PrimaryButton {
x: parent.width - 128px; y: parent.height - 60px;
width: 104px; height: 36px;
text: StreamStrings.privacy-unlock-lock;
enabled: root.privacy-can-lock;
clicked => { root.lock-privacy(); }
}
}
remote-update-banner := DialogSurface {
visible: root.remote-update-visible;
visible: root.remote-update-visible && !root.privacy-unlock-visible;
x: (parent.width - self.width) / 2;
y: min(max(64px, parent.height * 0.18), max(16px, parent.height - self.height - 16px));
width: min(400px, parent.width - 48px);
@@ -105,6 +105,7 @@ inline int ApplyMainWindowStrings(
strings.set_daemon(Text(localization::enable_daemon[language]));
strings.set_save_remote_previews(Text(localization::save_remote_previews[language]));
strings.set_unknown_platform(Text(localization::unknown_platform[language]));
strings.set_privacy_local_unlock(Text(localization::privacy_local_unlock[language]));
strings.set_privacy_on_connect(Text(localization::privacy_on_connect[language]));
strings.set_lock_on_disconnect(Text(localization::lock_on_disconnect[language]));
strings.set_file_save_path(
@@ -206,6 +207,10 @@ inline int ApplyStreamWindowStrings(
strings.set_remote_update_dismiss(Text(localization::remote_update_dismiss[language]));
strings.set_select_display(Text(localization::select_display[language]));
strings.set_send_shortcut(Text(localization::send_shortcut[language]));
strings.set_privacy_unlocked_title(Text(localization::privacy_unlocked_title[language]));
strings.set_privacy_unlocked_message(Text(localization::privacy_unlocked_message[language]));
strings.set_privacy_unlock_confirm(Text(localization::privacy_unlock_confirm[language]));
strings.set_privacy_unlock_lock(Text(localization::privacy_unlock_lock[language]));
strings.set_privacy_enable(Text(localization::privacy_enable[language]));
strings.set_privacy_disable(Text(localization::privacy_disable[language]));
strings.set_control_mouse(Text(localization::control_mouse[language]));
@@ -63,10 +63,12 @@ class LinuxPrivacyBackend final : public PrivacyBackend {
: "Checking Linux privacy support"};
}
bool Enable(bool block_input, const PrivacyScreenText&,
bool Enable(bool block_input, const PrivacyScreenText& text,
std::string& error) override {
failure_.clear();
emergency_ = false;
UpdateText(text);
if (!failure_.empty()) { error = failure_; return false; }
if (!Send(block_input ? LinuxPrivacyCommand::enable_blocking
: LinuxPrivacyCommand::enable)) {
error = failure_;
@@ -80,6 +82,11 @@ class LinuxPrivacyBackend final : public PrivacyBackend {
return true;
}
void UpdateText(const PrivacyScreenText& text) override {
if (socket_ >= 0 && !Send(text.allow_local_unlock ? LinuxPrivacyCommand::allow_local_unlock
: LinuxPrivacyCommand::forbid_local_unlock))
failure_ = "Cannot update the local privacy unlock setting";
}
void Recover() override {
ReadReplies();
if (recovered_) {
@@ -129,6 +129,8 @@ class NativePrivacy {
"blocking is unavailable");
}
void SetLocalUnlockAllowed(bool allowed) { allow_local_unlock_ = allowed; }
void Enable(bool block_input) {
++reply_.generation;
reply_.failure[0] = '\0';
@@ -570,7 +572,7 @@ class NativePrivacy {
shift |= symbol == XK_Shift_L || symbol == XK_Shift_R;
}
}
if (down && ctrl && alt && shift &&
if (allow_local_unlock_ && down && ctrl && alt && shift &&
XkbKeycodeToKeysym(display_, keycode, 0, 0) == XK_F12) {
reply_.emergency = true;
Recover();
@@ -624,6 +626,7 @@ class NativePrivacy {
std::vector<Window> hidden_roots_;
std::map<int, std::array<bool, 256>> keys_;
LinuxPrivacyReply reply_;
bool allow_local_unlock_ = true;
Clock::time_point checked_{};
};
} // namespace
@@ -676,6 +679,12 @@ int RunLinuxPrivacyGuard() {
case LinuxPrivacyCommand::recover:
privacy.Recover();
break;
case LinuxPrivacyCommand::allow_local_unlock:
privacy.SetLocalUnlockAllowed(true);
break;
case LinuxPrivacyCommand::forbid_local_unlock:
privacy.SetLocalUnlockAllowed(false);
break;
case LinuxPrivacyCommand::heartbeat:
break;
default:
@@ -22,6 +22,8 @@ enum class LinuxPrivacyCommand : uint8_t {
enable_blocking,
recover,
heartbeat,
allow_local_unlock,
forbid_local_unlock,
};
struct LinuxPrivacyReply {
@@ -200,6 +200,7 @@ class MacPrivacyBackend final : public PrivacyBackend {
const auto caps = Query();
if (!caps.overlay) { error = caps.reason; return false; }
block_input_ = block_input;
allow_local_unlock_ = text.allow_local_unlock;
emergency_ = false;
tap_failed_ = false;
CGEventMask mask = CGEventMaskBit(kCGEventKeyDown) | CGEventMaskBit(kCGEventKeyUp) |
@@ -239,7 +240,8 @@ class MacPrivacyBackend final : public PrivacyBackend {
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()]);
[NSString stringWithUTF8String:(text.allow_local_unlock ? text.unlock_hint : text.locked_hint).c_str()],
text.allow_local_unlock);
if (!content) {
state->failure = "Could not load the CrossDesk privacy screen logo";
break;
@@ -260,6 +262,17 @@ class MacPrivacyBackend final : public PrivacyBackend {
});
return true;
}
void UpdateText(const PrivacyScreenText& text) override {
allow_local_unlock_ = text.allow_local_unlock;
auto state = state_;
OnMain([state, text] {
std::lock_guard lock(state->mutex);
for (const auto& monitor : state->monitors)
UpdateMacPrivacyCoverView(monitor.cover.contentView,
[NSString stringWithUTF8String:(text.allow_local_unlock ? text.unlock_hint : text.locked_hint).c_str()],
text.allow_local_unlock);
});
}
void Recover() override {
if (tap_) { CGEventTapEnable(tap_, false); CFMachPortInvalidate(tap_); }
if (tap_source_) {
@@ -359,7 +372,7 @@ class MacPrivacyBackend final : public PrivacyBackend {
CGEventGetIntegerValueField(event, kCGEventSourceUnixProcessID) == getpid()) return event;
const auto flags = CGEventGetFlags(event);
const auto chord = kCGEventFlagMaskControl | kCGEventFlagMaskAlternate | kCGEventFlagMaskShift;
if (type == kCGEventKeyDown && (flags & chord) == chord &&
if (self->allow_local_unlock_ && type == kCGEventKeyDown && (flags & chord) == chord &&
CGEventGetIntegerValueField(event, kCGKeyboardEventKeycode) == 53) {
self->emergency_ = true;
return nullptr;
@@ -372,6 +385,7 @@ class MacPrivacyBackend final : public PrivacyBackend {
CFMachPortRef tap_ = nullptr;
CFRunLoopSourceRef tap_source_ = nullptr;
bool block_input_ = false;
bool allow_local_unlock_ = true;
bool emergency_ = false;
bool tap_failed_ = false;
};
@@ -11,7 +11,8 @@
namespace crossdesk {
// Returns nil if the CrossDesk icon resource cannot be loaded.
NSView* CreateMacPrivacyCoverView(NSRect frame, NSString* unlock_hint);
NSView* CreateMacPrivacyCoverView(NSRect frame, NSString* hint, bool allow_local_unlock = true);
void UpdateMacPrivacyCoverView(NSView* view, NSString* hint, bool allow_local_unlock);
}
#endif
@@ -52,6 +52,7 @@ CGFloat TextHeight(NSAttributedString* text, CGFloat width) {
@interface CrossDeskPrivacyCoverView : NSView
@property(nonatomic, strong) NSImage* logo;
@property(nonatomic, copy) NSString* unlockHint;
@property(nonatomic) BOOL allowLocalUnlock;
@end
@implementation CrossDeskPrivacyCoverView
@@ -77,7 +78,7 @@ CGFloat TextHeight(NSAttributedString* text, CGFloat width) {
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 shortcut_height = self.allowLocalUnlock ? TextHeight(shortcut, text_width) : 0;
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)
@@ -86,19 +87,26 @@ CGFloat TextHeight(NSAttributedString* text, CGFloat width) {
[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,
[hint drawWithRect:NSMakeRect(text_left, bottom - shortcut_height - (self.allowLocalUnlock ? 12 * scale : 0) - hint_height,
text_width, hint_height) options:kTextOptions];
[shortcut drawWithRect:NSMakeRect(text_left, bottom - shortcut_height,
if (self.allowLocalUnlock) [shortcut drawWithRect:NSMakeRect(text_left, bottom - shortcut_height,
text_width, shortcut_height) options:kTextOptions];
}
@end
namespace crossdesk {
NSView* CreateMacPrivacyCoverView(NSRect frame, NSString* unlock_hint) {
void UpdateMacPrivacyCoverView(NSView* view, NSString* hint, bool allow_local_unlock) {
auto* cover = static_cast<CrossDeskPrivacyCoverView*>(view);
cover.unlockHint = hint;
cover.allowLocalUnlock = allow_local_unlock;
cover.needsDisplay = YES;
}
NSView* CreateMacPrivacyCoverView(NSRect frame, NSString* unlock_hint, bool allow_local_unlock) {
NSImage* logo = LoadPrivacyLogo();
if (!logo) return nil;
auto* view = [[CrossDeskPrivacyCoverView alloc] initWithFrame:frame];
view.logo = logo;
view.allowLocalUnlock = allow_local_unlock;
view.unlockHint = unlock_hint ?: @"To turn off the privacy screen, press";
view.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable;
return view;
@@ -190,6 +190,10 @@ class Windows {
return TRUE;
}
if (message == WM_CLOSE) return 0;
if (message == kPrivacyTextChanged) {
InvalidateRect(hwnd, nullptr, TRUE);
return 0;
}
if ((message == WM_DISPLAYCHANGE || message == WM_DPICHANGED) && self) {
self->changed = true;
InvalidateRect(hwnd, nullptr, TRUE);
@@ -200,8 +204,12 @@ class Windows {
HDC dc = BeginPaint(hwnd, &paint);
RECT rect{};
GetClientRect(hwnd, &rect);
const bool allow_unlock = self &&
InterlockedCompareExchange(&self->shared_->allow_local_unlock, 0, 0);
if (self && !DrawPrivacyCover(dc, rect, PrivacyWindowDpi(hwnd), module,
self->shared_->unlock_hint)) {
allow_unlock ? self->shared_->unlock_hint
: self->shared_->locked_hint,
allow_unlock)) {
self->paint_failed_ = true;
self->Fail(L"Paint privacy logo and unlock instructions");
}
@@ -37,7 +37,7 @@ bool Select(HDC dc, HGDIOBJ object) {
} // namespace
bool DrawPrivacyCover(HDC dc, const RECT& rect, UINT dpi, HINSTANCE module,
const wchar_t* unlock_hint) {
const wchar_t* unlock_hint, bool allow_local_unlock) {
if (!FillRect(dc, &rect, static_cast<HBRUSH>(GetStockObject(BLACK_BRUSH))))
return false;
const int width = rect.right - rect.left;
@@ -68,6 +68,7 @@ bool DrawPrivacyCover(HDC dc, const RECT& rect, UINT dpi, HINSTANCE module,
}};
constexpr UINT flags = DT_LEFT | DT_WORDBREAK | DT_NOPREFIX;
for (size_t i = 0; i < lines.size(); ++i) {
if (i == 1 && !allow_local_unlock) continue;
auto& line = lines[i];
resources.fonts[i] =
CreateFontW(-px(line.size), 0, 0, 0, line.weight, FALSE, FALSE, FALSE,
@@ -81,7 +82,7 @@ bool DrawPrivacyCover(HDC dc, const RECT& rect, UINT dpi, HINSTANCE module,
}
const int content_height =
static_cast<int>(lines[0].bounds.bottom + lines[1].bounds.bottom) +
px(12);
(allow_local_unlock ? px(12) : 0);
// Match the icon width to the name; each side keeps its own height.
const int brand_gap = px(6);
const int brand_width = static_cast<int>(lines[kBrandLine].bounds.right);
@@ -110,6 +111,7 @@ bool DrawPrivacyCover(HDC dc, const RECT& rect, UINT dpi, HINSTANCE module,
DT_CENTER | DT_SINGLELINE | DT_NOPREFIX))
return false;
for (size_t i = 0; i < kInstructionLines; ++i) {
if (i == 1 && !allow_local_unlock) continue;
const auto& line = lines[i];
if (!Select(dc, resources.fonts[i])) return false;
RECT bounds{left, top, left + text_width, top + line.bounds.bottom};
@@ -13,7 +13,7 @@ namespace crossdesk {
// Paints the local, opaque cover. The caller owns the HWND and its DPI context.
bool DrawPrivacyCover(HDC dc, const RECT& rect, UINT dpi, HINSTANCE module,
const wchar_t* unlock_hint);
const wchar_t* unlock_hint, bool allow_local_unlock = true);
} // namespace crossdesk
@@ -120,6 +120,7 @@ class WindowsPrivacyBackend final : public PrivacyBackend {
}
if (!Initialize(error)) return false;
emergency_ = false;
allow_local_unlock_ = text.allow_local_unlock;
health_error_.clear();
if (!RegisterHotKey(sink_, kHotkeyId,
MOD_CONTROL | MOD_ALT | MOD_SHIFT | MOD_NOREPEAT,
@@ -149,6 +150,10 @@ class WindowsPrivacyBackend final : public PrivacyBackend {
return true;
}
void UpdateText(const PrivacyScreenText& text) override {
allow_local_unlock_ = text.allow_local_unlock;
band_guard_.SetLocalUnlockAllowed(text.allow_local_unlock);
}
void Recover() override {
for (auto& hook : window_events_) {
if (UnhookWinEvent(hook))
@@ -475,7 +480,8 @@ class WindowsPrivacyBackend final : public PrivacyBackend {
auto self = reinterpret_cast<WindowsPrivacyBackend*>(
GetWindowLongPtrW(hwnd, GWLP_USERDATA));
if (msg == WM_HOTKEY || msg == kEmergency) {
if (self && hwnd == self->sink_) self->emergency_ = true;
if (self && hwnd == self->sink_ && self->allow_local_unlock_)
self->emergency_ = true;
return 0;
}
if (msg == WM_MOUSEACTIVATE) return MA_NOACTIVATE;
@@ -522,6 +528,7 @@ class WindowsPrivacyBackend final : public PrivacyBackend {
std::unique_ptr<ScopedWindowsPhysicalCoordinates> dpi_scope_;
bool registered_ = false;
bool hotkey_ = false;
bool allow_local_unlock_ = true;
bool emergency_ = false;
bool layout_changed_ = false;
bool topology_pending_ = false;
@@ -153,6 +153,11 @@ bool PrivacyBandGuard::Start(const PrivacyScreenText& text,
text.unlock_hint.c_str(), -1, shared_->unlock_hint,
static_cast<int>(std::size(shared_->unlock_hint))))
return Failed("Encode privacy screen text", error);
if (!MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS,
text.locked_hint.c_str(), -1, shared_->locked_hint,
static_cast<int>(std::size(shared_->locked_hint))))
return Failed("Encode locked privacy screen text", error);
shared_->allow_local_unlock = text.allow_local_unlock;
shared_->parent_pid = GetCurrentProcessId();
HANDLE handles[] = {parent.value, stop_, ready_, mapping_};
std::wstring bootstrap;
@@ -299,6 +304,16 @@ bool PrivacyBandGuard::Windows(std::vector<PrivacyBandWindow>& windows,
return retry();
}
void PrivacyBandGuard::SetLocalUnlockAllowed(bool allowed) {
if (!shared_ || InterlockedExchange(&shared_->allow_local_unlock, allowed) == allowed) return;
std::vector<PrivacyBandWindow> windows;
std::string error;
if (Windows(windows, error))
for (const auto& window : windows)
PostMessageW(reinterpret_cast<HWND>(static_cast<uintptr_t>(window.hwnd)),
kPrivacyTextChanged, 0, 0);
}
bool PrivacyBandGuard::Stop(std::string& error) {
bool ok = true;
if (process_) {
@@ -27,6 +27,7 @@ class PrivacyBandGuard {
~PrivacyBandGuard();
static bool Available(std::string& error);
bool Start(const PrivacyScreenText& text, std::string& error);
void SetLocalUnlockAllowed(bool allowed);
bool Stop(std::string& error);
bool Healthy(std::string& error);
bool Windows(std::vector<PrivacyBandWindow>& windows, std::string& error);
@@ -14,7 +14,8 @@
namespace crossdesk {
constexpr uint32_t kPrivacyBandMagic = 0x43534231;
constexpr uint32_t kPrivacyBandVersion = 3;
constexpr uint32_t kPrivacyBandVersion = 4;
constexpr UINT kPrivacyTextChanged = WM_APP + 72;
constexpr DWORD kPrivacyWindowBand = 18; // ZBID_ABOVELOCK_UX, current desktop.
constexpr size_t kPrivacyMaxMonitors = 64;
constexpr wchar_t kPrivacyBandBootstrap[] = L"CROSSDESK_PRIVACY_BAND_BOOTSTRAP";
@@ -40,6 +41,8 @@ struct PrivacyBandShared {
alignas(8) volatile LONG64 heartbeat = 0;
wchar_t operation[128]{};
wchar_t unlock_hint[128]{};
wchar_t locked_hint[128]{};
volatile LONG allow_local_unlock = 1;
uint32_t count = 0;
PrivacyBandWindow windows[kPrivacyMaxMonitors]{};
};
@@ -14,6 +14,8 @@ namespace crossdesk {
struct PrivacyScreenText {
std::string unlock_hint = "To turn off the privacy screen, press";
std::string locked_hint = "Privacy screen is on. Local unlocking is disabled.";
bool allow_local_unlock = true;
};
struct PrivacyCapabilities {
@@ -38,6 +40,8 @@ class PrivacyBackend {
virtual PrivacyCapabilities Query() = 0;
virtual bool Enable(bool block_input, const PrivacyScreenText& text,
std::string& error) = 0;
// Update a live cover and its local shortcut policy without exposing the desktop.
virtual void UpdateText(const PrivacyScreenText& text) {}
virtual void Recover() = 0;
virtual bool IsRecovered() const = 0;
virtual bool RecoveryPending() const { return false; }
@@ -43,7 +43,11 @@ PrivacyStatus PrivacyController::Snapshot() const {
}
void PrivacyController::SetText(const PrivacyScreenText& text) {
std::lock_guard lock(mutex_);
if (text_.unlock_hint == text.unlock_hint && text_.locked_hint == text.locked_hint &&
text_.allow_local_unlock == text.allow_local_unlock) return;
text_ = text;
text_pending_ = true;
WakeLocked();
}
void PrivacyController::SetState(PrivacyState state, const std::string& reason) {
status_.state = state;
@@ -54,9 +58,28 @@ void PrivacyController::SetState(PrivacyState state, const std::string& 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) {
void PrivacyController::RejectCommand(const std::string& reason) {
std::lock_guard lock(mutex_);
if (quit_ || engaged_.load() || disable_pending_) return;
if (disable_pending_) {
failure_ = reason;
SetState(PrivacyState::failed, reason);
} else if (engaged_.load()) {
// A rejected command must not remove an already working cover.
SetState(status_.state, reason);
} else {
SetState(PrivacyState::failed, reason);
}
}
void PrivacyController::Enable(bool block_input, bool wait_for_recovery) {
std::lock_guard lock(mutex_);
if (quit_) return;
if (engaged_.load() && !disable_pending_) {
// Another controller may have restored privacy before this lock arrived.
// Acknowledge it so this controller does not wait for a nonexistent transition.
if (wait_for_recovery) SetState(status_.state, status_.reason);
return;
}
if (disable_pending_ && !wait_for_recovery) return;
if (!status_.supported || (block_input && !status_.input_block_supported)) {
SetState(PrivacyState::unsupported, "Privacy screen is unavailable");
return;
@@ -227,9 +250,17 @@ void PrivacyController::Run(Factory factory) {
}
}
}
if (text_pending_ && backend_) {
const auto text = text_;
text_pending_ = false;
lock.unlock();
backend_->UpdateText(text);
lock.lock();
}
const auto health = backend_ ? backend_->Poll() : PrivacyHealth{};
if (engaged_.load() && !disable_pending_) {
if (health.emergency_exit) {
if (health.emergency_exit && text_.allow_local_unlock) {
++status_.local_unlock_revision;
automatic_enable_ = false;
resume_after_unlock_ = false;
failure_.clear();
@@ -23,7 +23,8 @@ class PrivacyController {
PrivacyController& operator=(const PrivacyController&) = delete;
PrivacyStatus Snapshot() const;
void SetText(const PrivacyScreenText& text);
void Enable(bool block_input);
void Enable(bool block_input, bool wait_for_recovery = false);
void RejectCommand(const std::string& reason);
void EnableOnConnection();
// Preserve automatic session intent while Windows shows another desktop.
void SuspendForDesktop();
@@ -50,6 +51,7 @@ class PrivacyController {
std::unique_ptr<PrivacyBackend> backend_;
PrivacyStatus status_{};
PrivacyScreenText text_;
bool text_pending_ = false;
std::atomic<bool> engaged_{false};
bool quit_ = false;
bool enable_pending_ = false;