mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-28 00:33:06 +08:00
[feat] add privacy unlock controls and remote relocking
This commit is contained in:
@@ -79,6 +79,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
private AlertDialog connectionDialog;
|
||||
private String pendingConnectionMessage="";
|
||||
private AlertDialog remoteUpdateDialog;
|
||||
private AlertDialog privacyUnlockDialog;
|
||||
private RemoteVersionCheck remoteVersionCheck;
|
||||
private String host, connectedRemote="", hostPlatform="", remoteClipboard="", pendingPassword="", page="home";
|
||||
private int port,selectedDisplay;
|
||||
@@ -350,6 +351,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
}
|
||||
private void confirmDisconnect(){disconnectDialog=new AlertDialog.Builder(this).setTitle("断开远程连接?").setMessage("断开后将返回首页").setNegativeButton("取消",null).setPositiveButton("断开连接",(d,w)->end("")).show();}
|
||||
private void end(String reason){
|
||||
dismissPrivacyUnlockDialog();
|
||||
remoteOrientation.end();
|
||||
if(backgroundSession!=null){BackgroundSession retained=backgroundSession;backgroundSession=null;retained.detach(this);retained.finish(reason);}
|
||||
boolean closeWindow=isInPictureInPictureMode();
|
||||
@@ -658,11 +660,42 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
if(foreground&&preferences.getBoolean("networkConsent",false))remoteVersionCheck.hostInfo(info);
|
||||
}
|
||||
}
|
||||
@Override public void privacyChanged(){if(controls!=null&&session!=null)controls.privacyScreen(session.privacyScreen);}
|
||||
private void dismissPrivacyUnlockDialog() {
|
||||
if (privacyUnlockDialog != null) { privacyUnlockDialog.dismiss(); privacyUnlockDialog = null; }
|
||||
}
|
||||
@Override public void privacyChanged() {
|
||||
if (controls != null && session != null) controls.privacyScreen(session.privacyScreen);
|
||||
if (!ready || session == null || !session.privacyScreen.unlockNotice) {
|
||||
dismissPrivacyUnlockDialog(); return;
|
||||
}
|
||||
if (!foreground || pictureInPictureUi || isInPictureInPictureMode() || isFinishing()) return;
|
||||
if (privacyUnlockDialog == null) {
|
||||
releaseKeys();
|
||||
if (controls != null) { controls.closeKeyboard(); controls.cancelMouseInput(); }
|
||||
if (video != null) video.releaseMouse();
|
||||
privacyUnlockDialog = new AlertDialog.Builder(this).setTitle("隐私屏已被解锁")
|
||||
.setMessage("被控端已通过组合键解除隐私屏。点击“锁定”将重新开启隐私屏,并禁止本机使用组合键解锁。")
|
||||
.setNegativeButton("确认", (dialog, which) -> {
|
||||
if (session != null) session.privacyScreen.unlockNotice = false;
|
||||
dismissPrivacyUnlockDialog();
|
||||
}).setPositiveButton("锁定", null).create();
|
||||
privacyUnlockDialog.setCancelable(false);
|
||||
privacyUnlockDialog.show();
|
||||
privacyUnlockDialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(view -> {
|
||||
if (session != null) session.lockPrivacy();
|
||||
});
|
||||
}
|
||||
String feedback = session.privacyScreen.feedback(android.os.SystemClock.elapsedRealtime());
|
||||
privacyUnlockDialog.setMessage("被控端已通过组合键解除隐私屏。点击“锁定”将重新开启隐私屏,并禁止本机使用组合键解锁。"
|
||||
+ (feedback.isEmpty() ? "" : "\n\n" + feedback));
|
||||
privacyUnlockDialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(
|
||||
session.privacyScreen.canLock(android.os.SystemClock.elapsedRealtime()));
|
||||
}
|
||||
@Override public void videoSize(int width,int height){videoWidth=width;videoHeight=height;if(video!=null)video.videoSize(width,height);if(controls!=null)controls.videoSize(width,height);pictureInPicture.videoSize(width,height);updatePictureInPicture();}
|
||||
@Override public void clipboard(String value){remoteClipboard=value;}
|
||||
@Override public void statistics(RemoteNetworkStatistics.Snapshot value){if(controls!=null)controls.statistics(value);if(value.fps>0)capturePreviewIfNeeded();}
|
||||
@Override public boolean dispatchKeyEvent(KeyEvent event){
|
||||
if (privacyUnlockDialog != null) return super.dispatchKeyEvent(event);
|
||||
int code=KeyMap.windowsCode(event.getKeyCode());View focus=getCurrentFocus();
|
||||
boolean remoteEditor=focus instanceof RemoteKeyboardInput;
|
||||
boolean desktopKey=event.isCtrlPressed()||event.isAltPressed()||event.isMetaPressed()||pressed.contains(code)
|
||||
@@ -790,7 +823,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
// onStart marks a new visit; focus/resume callbacks must not reopen a refused notice.
|
||||
@Override protected void onPause(){releaseKeys();if(controls!=null)controls.cancelMouseInput();if(video!=null)video.releaseMouse();super.onPause();}
|
||||
@Override protected void onStart(){super.onStart();foreground=true;restoreBackgroundSession();appUpdates.setEnabled(preferences.getBoolean("networkConsent",false));if(!preferences.getBoolean("networkConsent",false))consent(this::renderSignaling);ensureSignaling();showConnectionMessage();}
|
||||
@Override protected void onStop(){if(controls!=null)controls.closeKeyboard();foreground=false;appUpdates.setEnabled(false);if(privacyDialog!=null)privacyDialog.dismiss();pageHost.finish();
|
||||
@Override protected void onStop(){dismissPrivacyUnlockDialog();if(controls!=null)controls.closeKeyboard();foreground=false;appUpdates.setEnabled(false);if(privacyDialog!=null)privacyDialog.dismiss();pageHost.finish();
|
||||
if(connectionDialog!=null)connectionDialog.dismiss();
|
||||
// Closing native PiP is an explicit end, not a request for invisible
|
||||
// fallback. An unavailable/failed PiP can hand its live session off.
|
||||
@@ -801,7 +834,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
||||
resetRemoteVersionCheck();if(disconnectDialog!=null)disconnectDialog.dismiss();
|
||||
}else if(documentSession==null||documentSession!=session||isFinishing()){if(session!=null)end(isFinishing()?"":"远程窗口已关闭,或后台保活未能启动,会话已结束");stopSignaling();}
|
||||
super.onStop();}
|
||||
@Override protected void onDestroy(){mainHandler.removeCallbacks(documentTimeout);documentSession=null;documentToSave=null;appUpdates.setEnabled(false);pageHost.finish();resetRemoteVersionCheck();cancelVideoSettingsTimeout();previews.removeListener(previewChanged);if(privacyDialog!=null)privacyDialog.dismiss();if(previewDialog!=null)previewDialog.dismiss();announcementInbox.close();if(disconnectDialog!=null)disconnectDialog.dismiss();if(passwordDialog!=null)passwordDialog.dismiss();if(progressDialog!=null)progressDialog.dismiss();
|
||||
@Override protected void onDestroy(){dismissPrivacyUnlockDialog();mainHandler.removeCallbacks(documentTimeout);documentSession=null;documentToSave=null;appUpdates.setEnabled(false);pageHost.finish();resetRemoteVersionCheck();cancelVideoSettingsTimeout();previews.removeListener(previewChanged);if(privacyDialog!=null)privacyDialog.dismiss();if(previewDialog!=null)previewDialog.dismiss();announcementInbox.close();if(disconnectDialog!=null)disconnectDialog.dismiss();if(passwordDialog!=null)passwordDialog.dismiss();if(progressDialog!=null)progressDialog.dismiss();
|
||||
if(connectionDialog!=null)connectionDialog.dismiss();
|
||||
boolean retained=!isFinishing()&&backgroundSession!=null&&!backgroundSession.ended&&RemoteSessionService.current()==backgroundSession;
|
||||
if(backgroundSession!=null)backgroundSession.detach(this);
|
||||
|
||||
@@ -284,13 +284,15 @@ final class NativeSession implements AutoCloseable {
|
||||
if (type == 2 && player != null) player.setEnabled(value != 0);
|
||||
execute(() -> { if (connected){nControl(handle, type, value);if(type==4)networkStatistics.resetVideo();} });
|
||||
}
|
||||
void togglePrivacy() {
|
||||
void lockPrivacy() { sendPrivacy(true); }
|
||||
void togglePrivacy() { sendPrivacy(false); }
|
||||
private void sendPrivacy(boolean lock) {
|
||||
if(closed||!connected)return;
|
||||
boolean enable=!privacyScreen.active();
|
||||
long request=privacyScreen.begin(android.os.SystemClock.elapsedRealtime());
|
||||
long request=privacyScreen.begin(android.os.SystemClock.elapsedRealtime(),lock);
|
||||
if(request<0)return;
|
||||
listener.privacyChanged();
|
||||
execute(()->{if(connected&&!nPrivacy(handle,enable?1:2,true))ui(()->{
|
||||
execute(()->{if(connected&&!nPrivacy(handle,lock?3:enable?1:2,true))ui(()->{
|
||||
privacyScreen.failed(request);listener.privacyChanged();
|
||||
});});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import org.json.JSONObject;
|
||||
final class RemotePrivacyScreen {
|
||||
boolean desktopAvailable = true;
|
||||
boolean received, supported, inputBlockSupported, overlay, inputBlocked, remotePaused, pending;
|
||||
boolean unlockNotice;
|
||||
long localUnlockRevision;
|
||||
int state;
|
||||
long revision, requestRevision, requestId, receivedAt, requestedAt;
|
||||
String reason = "", failure = "";
|
||||
@@ -28,6 +30,11 @@ final class RemotePrivacyScreen {
|
||||
if (next == 3 && (!support || !cover || paused)) return;
|
||||
boolean blockSupport = value.getBoolean("input_block_supported"), blocked = value.getBoolean("input_blocked");
|
||||
String detail = value.getString("reason");
|
||||
long unlock = value.has("local_unlock_revision") ? value.getLong("local_unlock_revision") : 0;
|
||||
if (unlock < 0 || unlock > 0xffffffffL) return;
|
||||
if (received && (int)(unlock - localUnlockRevision) > 0) unlockNotice = true;
|
||||
if (next == 3) unlockNotice = false;
|
||||
localUnlockRevision = unlock;
|
||||
state = next; revision = rev; supported = support; overlay = cover; remotePaused = paused;
|
||||
inputBlockSupported = blockSupport; inputBlocked = blocked; reason = detail;
|
||||
received = true; receivedAt = now; failure = "";
|
||||
@@ -53,9 +60,13 @@ final class RemotePrivacyScreen {
|
||||
boolean canToggle(long now) {
|
||||
return desktopAvailable && !pending && fresh(now) && state != 4 && (active() || supported && state == 1);
|
||||
}
|
||||
long begin(long now) {
|
||||
boolean canLock(long now) {
|
||||
return desktopAvailable && !pending && fresh(now) && supported;
|
||||
}
|
||||
long begin(long now) { return begin(now, false); }
|
||||
long begin(long now, boolean lock) {
|
||||
tick(now);
|
||||
if (!canToggle(now)) return -1;
|
||||
if (!(lock ? canLock(now) : canToggle(now))) return -1;
|
||||
pending = true; requestedAt = now; requestRevision = revision; failure = "";
|
||||
return ++requestId;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -81,6 +81,7 @@ export class MobileState {
|
||||
privacyEnabled: boolean = false;
|
||||
privacyActive: boolean = false;
|
||||
privacyCanToggle: boolean = false;
|
||||
privacyCanLock: boolean = false;
|
||||
privacyVisible: boolean = true;
|
||||
networkTime: number = 0;
|
||||
unread: number = 0;
|
||||
@@ -266,9 +267,9 @@ export class MobileController {
|
||||
if (this.foreground && this.state.consent) this.checkLocal();
|
||||
}
|
||||
release(): void { this.releaseInput(); this.send({ op: 'release' }); }
|
||||
pointer(x: number, y: number, flag: number, scroll: number): void { this.send({ op: 'pointer', x: x, y: y, flag: flag, scroll: scroll }); }
|
||||
physicalKey(code: number, down: boolean): void { this.send({ op: 'key', code: code, down: down }); }
|
||||
key(code: number, down: boolean): void { this.send({ op: 'virtualKey', code: code, down: down }); }
|
||||
pointer(x: number, y: number, flag: number, scroll: number): void { if (this.state.modal === 'privacyUnlock') return; this.send({ op: 'pointer', x: x, y: y, flag: flag, scroll: scroll }); }
|
||||
physicalKey(code: number, down: boolean): void { if (this.state.modal === 'privacyUnlock') return; this.send({ op: 'key', code: code, down: down }); }
|
||||
key(code: number, down: boolean): void { if (this.state.modal === 'privacyUnlock') return; this.send({ op: 'virtualKey', code: code, down: down }); }
|
||||
chord(code: number, modifiers: number[] = []): void {
|
||||
modifiers.forEach((key: number) => this.key(key, true)); this.key(code, true); this.key(code, false);
|
||||
modifiers.slice().reverse().forEach((key: number) => this.key(key, false));
|
||||
@@ -352,11 +353,17 @@ export class MobileController {
|
||||
this.state.documentTitle = title; this.state.documentBack = back; this.state.page = 'document'; this.state.modal = '';
|
||||
} catch { this.toast('无法读取文档'); }
|
||||
}
|
||||
togglePrivacy(): void {
|
||||
dismissPrivacyUnlock(): void {
|
||||
this.privacyScreen.unlockNotice = false;
|
||||
if (this.state.modal === 'privacyUnlock') this.state.modal = '';
|
||||
}
|
||||
lockPrivacy(): void { this.sendPrivacy(true); }
|
||||
togglePrivacy(): void { this.sendPrivacy(false); }
|
||||
private sendPrivacy(lock: boolean): void {
|
||||
if (!this.state.ready || !this.state.consent) return;
|
||||
const enable = !this.privacyScreen.active(), request = this.privacyScreen.begin(this.now());
|
||||
const enable = !this.privacyScreen.active(), request = this.privacyScreen.begin(this.now(), lock);
|
||||
if (request < 0) return;
|
||||
this.send({ op: 'privacy', flag: enable ? 1 : 2, request: request,
|
||||
this.send({ op: 'privacy', flag: lock ? 3 : enable ? 1 : 2, request: request,
|
||||
blockLocalInput: true });
|
||||
this.syncPrivacy();
|
||||
}
|
||||
@@ -365,6 +372,13 @@ export class MobileController {
|
||||
this.state.privacyStatus = this.privacyScreen.status(now); this.state.privacyFeedback = this.privacyScreen.feedback(now);
|
||||
this.state.privacyButtonTitle = this.privacyScreen.buttonTitle(now);
|
||||
this.state.privacyVisible = this.privacyScreen.desktopAvailable;
|
||||
this.state.privacyCanLock = this.privacyScreen.canLock(now);
|
||||
if (this.foreground && this.state.ready && this.privacyScreen.unlockNotice && !this.state.modal) {
|
||||
this.release();
|
||||
this.state.modal = 'privacyUnlock';
|
||||
} else if (!this.privacyScreen.unlockNotice && this.state.modal === 'privacyUnlock') {
|
||||
this.state.modal = '';
|
||||
}
|
||||
this.state.privacyEnabled = this.privacyScreen.enabled(now);
|
||||
this.state.privacyActive = this.privacyScreen.active(); this.state.privacyCanToggle = this.privacyScreen.canToggle(now);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
export interface PrivacyStatus {
|
||||
state: number; supported: boolean; input_block_supported: boolean;
|
||||
overlay_active: boolean; input_blocked: boolean; remote_paused: boolean;
|
||||
revision: number; reason: string;
|
||||
revision: number; reason: string; local_unlock_revision?: number;
|
||||
}
|
||||
|
||||
export class RemotePrivacyScreen {
|
||||
desktopAvailable: boolean = true;
|
||||
snapshot?: PrivacyStatus;
|
||||
pending: boolean = false;
|
||||
unlockNotice: boolean = false;
|
||||
requestId: number = 0;
|
||||
private requestRevision: number = 0;
|
||||
private receivedAt: number = 0;
|
||||
@@ -26,6 +27,10 @@ export class RemotePrivacyScreen {
|
||||
typeof s.remote_paused !== 'boolean' || typeof s.reason !== 'string' || s.reason.includes('\0')) return;
|
||||
if (s.state === 3 && (!s.supported || !s.overlay_active || s.remote_paused)) return;
|
||||
if (this.snapshot && ((s.revision - this.snapshot.revision) | 0) < 0) return;
|
||||
const unlock = s.local_unlock_revision ?? 0;
|
||||
if (!Number.isInteger(unlock) || unlock < 0 || unlock > 0xffffffff) return;
|
||||
if (this.snapshot && ((unlock - (this.snapshot.local_unlock_revision ?? 0)) | 0) > 0) this.unlockNotice = true;
|
||||
if (s.state === 3) this.unlockNotice = false;
|
||||
this.snapshot = s; this.receivedAt = now; this.failure = '';
|
||||
if (this.pending && ((s.revision - this.requestRevision) | 0) > 0 && s.state !== 2 && s.state !== 4) this.pending = false;
|
||||
}
|
||||
@@ -57,9 +62,12 @@ export class RemotePrivacyScreen {
|
||||
const s = this.snapshot;
|
||||
return this.desktopAvailable && s !== undefined && !this.pending && this.fresh(now) && s.state !== 4 && (this.active() || s.supported && s.state === 1);
|
||||
}
|
||||
begin(now: number): number {
|
||||
canLock(now: number): boolean {
|
||||
return this.desktopAvailable && !this.pending && this.fresh(now) && this.snapshot?.supported === true;
|
||||
}
|
||||
begin(now: number, lock: boolean = false): number {
|
||||
this.tick(now);
|
||||
if (!this.canToggle(now) || !this.snapshot) return -1;
|
||||
if (!(lock ? this.canLock(now) : this.canToggle(now)) || !this.snapshot) return -1;
|
||||
this.pending = true; this.requestedAt = now; this.requestRevision = this.snapshot.revision; this.failure = '';
|
||||
this.requestId = (this.requestId + 1) >>> 0;
|
||||
return this.requestId;
|
||||
|
||||
@@ -91,6 +91,7 @@ struct Mobile {
|
||||
return saved;
|
||||
}
|
||||
private dismissModal(): void {
|
||||
if (this.state.modal === 'privacyUnlock') this.controller?.dismissPrivacyUnlock();
|
||||
if (this.state.modal === 'password') { this.state.password = ''; this.showPassword = false; }
|
||||
this.previewToggle = this.state.previews; this.state.modal = '';
|
||||
}
|
||||
@@ -537,6 +538,21 @@ struct Mobile {
|
||||
}.padding({ left: 26, right: 26, top: 24, bottom: 20 }).width('100%')
|
||||
this.Rule()
|
||||
this.Action('取消连接', () => this.controller?.end(''), true)
|
||||
} else if (this.state.modal === 'privacyUnlock') {
|
||||
Column({ space: 12 }) {
|
||||
Text('隐私屏已被解锁').fontSize(20).fontWeight(600).fontColor(MobileTheme.ink)
|
||||
Text('被控端已通过组合键解除隐私屏。点击“锁定”将重新开启隐私屏,并禁止本机使用组合键解锁。')
|
||||
.fontSize(14).lineHeight(21).fontColor(MobileTheme.secondary).width('100%')
|
||||
if (this.state.privacyFeedback) { Text(this.state.privacyFeedback).fontSize(12).fontColor(MobileTheme.secondary) }
|
||||
}.padding(24).width('100%')
|
||||
this.Rule()
|
||||
Row() {
|
||||
Button('确认', { type: ButtonType.Normal }).fontSize(16).fontColor(MobileTheme.blue).height(50).layoutWeight(1)
|
||||
.backgroundColor(Color.White).onClick(() => this.dismissModal())
|
||||
Divider().vertical(true).height(50).strokeWidth(0.5).color(MobileTheme.line)
|
||||
Button('锁定', { type: ButtonType.Normal }).fontSize(16).fontWeight(600).fontColor(MobileTheme.blue).height(50).layoutWeight(1)
|
||||
.backgroundColor(Color.White).enabled(this.state.privacyCanLock).onClick(() => this.controller?.lockPrivacy())
|
||||
}.width('100%')
|
||||
} else if (this.state.modal === 'clipboard') {
|
||||
Column({ space: 16 }) {
|
||||
this.SheetHeader('剪贴板')
|
||||
|
||||
@@ -195,6 +195,7 @@ typedef struct {
|
||||
- (void)sendFileAtURL:(NSURL *)fileURL;
|
||||
- (void)sendSecureAttentionSequence;
|
||||
- (void)sendPrivacyCommand:(BOOL)enable
|
||||
lock:(BOOL)lock
|
||||
blockLocalInput:(BOOL)blockLocalInput
|
||||
requestID:(uint32_t)requestID;
|
||||
|
||||
|
||||
@@ -1044,6 +1044,7 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
}
|
||||
|
||||
- (void)sendPrivacyCommand:(BOOL)enable
|
||||
lock:(BOOL)lock
|
||||
blockLocalInput:(BOOL)blockLocalInput
|
||||
requestID:(uint32_t)requestID {
|
||||
const uint64_t generation = _activeControllerGeneration.load();
|
||||
@@ -1051,7 +1052,8 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
if (![self isControllerGenerationActive:generation]) return;
|
||||
RemoteAction action{};
|
||||
action.type = ControlType::privacy_command;
|
||||
action.pc = {enable ? crossdesk::PrivacyCommandFlag::enable
|
||||
action.pc = {lock ? crossdesk::PrivacyCommandFlag::lock
|
||||
: enable ? crossdesk::PrivacyCommandFlag::enable
|
||||
: crossdesk::PrivacyCommandFlag::disable,
|
||||
static_cast<bool>(blockLocalInput)};
|
||||
const auto message = action.to_json();
|
||||
|
||||
@@ -9,11 +9,13 @@ struct RemotePrivacyStatus: Decodable {
|
||||
let remotePaused: Bool
|
||||
let revision: UInt32
|
||||
let reason: String
|
||||
let localUnlockRevision: UInt32?
|
||||
}
|
||||
|
||||
struct RemotePrivacyScreen {
|
||||
private(set) var desktopAvailable = true
|
||||
private(set) var snapshot: RemotePrivacyStatus?
|
||||
var unlockNotice = false
|
||||
private(set) var pending = false
|
||||
private(set) var requestID: UInt32 = 0
|
||||
private var requestRevision: UInt32 = 0
|
||||
@@ -64,14 +66,22 @@ struct RemotePrivacyScreen {
|
||||
!s.reason.contains("\0"),
|
||||
s.state != 3 || (s.supported && s.overlayActive && !s.remotePaused) else { return }
|
||||
if let previous = snapshot, Int32(bitPattern: s.revision &- previous.revision) < 0 { return }
|
||||
if let previous = snapshot,
|
||||
Int32(bitPattern: (s.localUnlockRevision ?? 0) &- (previous.localUnlockRevision ?? 0)) > 0 {
|
||||
unlockNotice = true
|
||||
}
|
||||
if s.state == 3 { unlockNotice = false }
|
||||
snapshot = s; receivedAt = now; failure = ""
|
||||
if pending && Int32(bitPattern: s.revision &- requestRevision) > 0 && ![2, 4].contains(s.state) {
|
||||
pending = false
|
||||
}
|
||||
}
|
||||
mutating func begin(at now: TimeInterval) -> UInt32? {
|
||||
func canLock(at now: TimeInterval) -> Bool {
|
||||
desktopAvailable && !pending && fresh(at: now) && snapshot?.supported == true
|
||||
}
|
||||
mutating func begin(at now: TimeInterval, lock: Bool = false) -> UInt32? {
|
||||
tick(at: now)
|
||||
guard canToggle(at: now), let s = snapshot else { return nil }
|
||||
guard (lock ? canLock(at: now) : canToggle(at: now)), let s = snapshot else { return nil }
|
||||
pending = true; requestedAt = now; requestRevision = s.revision; failure = ""
|
||||
requestID &+= 1
|
||||
return requestID
|
||||
|
||||
@@ -1106,12 +1106,14 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
|
||||
privacyTimer = timer
|
||||
}
|
||||
|
||||
func togglePrivacyScreen() {
|
||||
func lockPrivacyScreen() { sendPrivacyScreen(lock: true) }
|
||||
func togglePrivacyScreen() { sendPrivacyScreen(lock: false) }
|
||||
private func sendPrivacyScreen(lock: Bool) {
|
||||
guard hasNetworkConsent, isConnected else { return }
|
||||
privacyClock = ProcessInfo.processInfo.systemUptime
|
||||
let enable = !privacyScreen.active
|
||||
guard let request = privacyScreen.begin(at: privacyClock) else { return }
|
||||
bridge.sendPrivacyCommand(enable, blockLocalInput: true, requestID: request)
|
||||
guard let request = privacyScreen.begin(at: privacyClock, lock: lock) else { return }
|
||||
bridge.sendPrivacyCommand(enable, lock: lock, blockLocalInput: true, requestID: request)
|
||||
}
|
||||
|
||||
func rtcBridge(_ bridge: CrossDeskRTCBridge, didReceivePrivacyStatus data: Data) {
|
||||
|
||||
@@ -181,19 +181,28 @@ struct RemoteSessionView: View {
|
||||
floatingStatusControls
|
||||
.zIndex(30)
|
||||
|
||||
if session.isConnected && session.privacyScreen.unlockNotice {
|
||||
privacyUnlockOverlay.zIndex(60)
|
||||
}
|
||||
if disconnectConfirmationVisible {
|
||||
disconnectConfirmationOverlay
|
||||
.zIndex(50)
|
||||
}
|
||||
}
|
||||
.alert("升级提示", isPresented: Binding(
|
||||
get: { session.remoteUpdateMessage != nil },
|
||||
set: { if !$0 { session.dismissRemoteUpdate() } }
|
||||
get: { !session.privacyScreen.unlockNotice && session.remoteUpdateMessage != nil },
|
||||
set: { if !$0 && !session.privacyScreen.unlockNotice { session.dismissRemoteUpdate() } }
|
||||
)) {
|
||||
Button("知道了", role: .cancel) { session.dismissRemoteUpdate() }
|
||||
} message: {
|
||||
Text(session.remoteUpdateMessage ?? "")
|
||||
}
|
||||
.onChange(of: session.privacyScreen.unlockNotice) { visible in
|
||||
if visible {
|
||||
mouseInput.releaseAll()
|
||||
keyboardInputVisible = false
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
mouseInput.send = { x, y, flag, wheel in
|
||||
cursorPosition = CGPoint(x: CGFloat(x), y: CGFloat(y))
|
||||
@@ -268,6 +277,35 @@ struct RemoteSessionView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var privacyUnlockOverlay: some View {
|
||||
ZStack {
|
||||
Color.black.opacity(0.28).ignoresSafeArea().contentShape(Rectangle()).onTapGesture { }
|
||||
VStack(spacing: 0) {
|
||||
VStack(spacing: 12) {
|
||||
Text("隐私屏已被解锁").font(.headline)
|
||||
Text("被控端已通过组合键解除隐私屏。点击“锁定”将重新开启隐私屏,并禁止本机使用组合键解锁。")
|
||||
.font(.subheadline).foregroundStyle(.secondary)
|
||||
let feedback = session.privacyScreen.feedback(at: session.privacyClock)
|
||||
if !feedback.isEmpty { Text(feedback).font(.caption).foregroundStyle(.secondary) }
|
||||
}.padding(24)
|
||||
Divider()
|
||||
HStack(spacing: 0) {
|
||||
Button("确认") { session.privacyScreen.unlockNotice = false }
|
||||
.frame(maxWidth: .infinity, minHeight: 48)
|
||||
Divider().frame(height: 48)
|
||||
Button("锁定") { session.lockPrivacyScreen() }
|
||||
.fontWeight(.semibold).frame(maxWidth: .infinity, minHeight: 48)
|
||||
.disabled(!session.privacyScreen.canLock(at: session.privacyClock))
|
||||
}
|
||||
}
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.shadow(color: .black.opacity(0.2), radius: 18, y: 7)
|
||||
.frame(maxWidth: 380).padding(20)
|
||||
}
|
||||
.accessibilityAddTraits(.isModal)
|
||||
}
|
||||
|
||||
private var disconnectConfirmationOverlay: some View {
|
||||
GeometryReader { proxy in
|
||||
ZStack {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
namespace crossdesk::mobile_controller {
|
||||
inline std::string PrivacyMessage(int flag, bool block_local_input) {
|
||||
if (flag < 0 || flag > 2) return {};
|
||||
if (flag < 0 || flag > 3) return {};
|
||||
RemoteAction action{};
|
||||
action.type = privacy_command;
|
||||
action.pc = {static_cast<PrivacyCommandFlag>(flag), block_local_input};
|
||||
|
||||
@@ -157,7 +157,7 @@ struct ServiceCommand {
|
||||
};
|
||||
|
||||
enum class PrivacyState { unsupported, off, starting, on, stopping, failed };
|
||||
enum class PrivacyCommandFlag { query, enable, disable };
|
||||
enum class PrivacyCommandFlag { query, enable, disable, lock };
|
||||
struct PrivacyCommand {
|
||||
PrivacyCommandFlag flag;
|
||||
bool block_local_input;
|
||||
@@ -171,6 +171,8 @@ struct PrivacyStatus {
|
||||
bool remote_paused;
|
||||
uint32_t revision;
|
||||
char reason[256];
|
||||
// Monotonic event counter retained across status heartbeats and recovery.
|
||||
uint32_t local_unlock_revision;
|
||||
};
|
||||
|
||||
struct VideoSettings {
|
||||
|
||||
@@ -224,7 +224,8 @@ std::string RemoteAction::ToJson(const RemoteAction& action) {
|
||||
{"overlay_active", action.ps.overlay_active},
|
||||
{"input_blocked", action.ps.input_blocked},
|
||||
{"remote_paused", action.ps.remote_paused},
|
||||
{"revision", action.ps.revision}, {"reason", action.ps.reason}};
|
||||
{"revision", action.ps.revision}, {"reason", action.ps.reason},
|
||||
{"local_unlock_revision", action.ps.local_unlock_revision}};
|
||||
break;
|
||||
case ControlType::host_infomation: {
|
||||
json displays = json::array();
|
||||
@@ -409,7 +410,7 @@ bool RemoteAction::FromJson(const std::string& json_string,
|
||||
case ControlType::privacy_command: {
|
||||
const auto& command = object.at("privacy_command");
|
||||
const int flag = command.at("flag").get<int>();
|
||||
if (flag < 0 || flag > 2) return false;
|
||||
if (flag < 0 || flag > 3) return false;
|
||||
output.pc.flag = static_cast<PrivacyCommandFlag>(flag);
|
||||
output.pc.block_local_input = command.at("block_local_input").get<bool>();
|
||||
break;
|
||||
@@ -427,6 +428,12 @@ bool RemoteAction::FromJson(const std::string& json_string,
|
||||
output.ps.input_blocked = status.at("input_blocked").get<bool>();
|
||||
output.ps.remote_paused = status.at("remote_paused").get<bool>();
|
||||
output.ps.revision = status.at("revision").get<uint32_t>();
|
||||
output.ps.local_unlock_revision = 0;
|
||||
if (const auto event = status.find("local_unlock_revision"); event != status.end()) {
|
||||
if (!event->is_number_integer() || event->get<int64_t>() < 0 ||
|
||||
event->get<uint64_t>() > UINT32_MAX) return false;
|
||||
output.ps.local_unlock_revision = event->get<uint32_t>();
|
||||
}
|
||||
std::memcpy(output.ps.reason, reason.c_str(), reason.size() + 1);
|
||||
// Never render a contradictory success from a malformed peer.
|
||||
if (output.ps.state == PrivacyState::on &&
|
||||
|
||||
Reference in New Issue
Block a user