mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-21 03:40:40 +08:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56ebfdf8e7 | ||
|
|
99ae0fc811 | ||
|
|
9af7417c46 | ||
|
|
ceb0a1654a | ||
|
|
4f200095f4 | ||
|
|
bd0a7aa2f6 | ||
|
|
b87ab025be | ||
|
|
5b60695a52 | ||
|
|
0002bcea74 |
@@ -682,7 +682,9 @@ jobs:
|
||||
$portableFiles = @(
|
||||
@("crossdesk.exe", "CrossDesk.exe"),
|
||||
@("crossdesk_service.exe", "crossdesk_service.exe"),
|
||||
@("crossdesk_session_helper.exe", "crossdesk_session_helper.exe")
|
||||
@("crossdesk_session_helper.exe", "crossdesk_session_helper.exe"),
|
||||
@("crossdesk_privacy_cursor_helper.exe", "crossdesk_privacy_cursor_helper.exe"),
|
||||
@("crossdesk_privacy_window.dll", "crossdesk_privacy_window.dll")
|
||||
)
|
||||
|
||||
foreach ($file in $portableFiles) {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-11
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
IDI_CROSSDESK_LOGO ICON "crossdesk.ico"
|
||||
|
||||
#define CROSSDESK_FILE_DESCRIPTION "CrossDesk Privacy Window"
|
||||
#define CROSSDESK_ORIGINAL_FILENAME "crossdesk_privacy_window.dll"
|
||||
#define CROSSDESK_FILE_TYPE VFT_DLL
|
||||
|
||||
#include "version_info.rcinc"
|
||||
@@ -1,6 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Keep readelf and ldd output stable for the runtime dependency checks below.
|
||||
export LC_ALL=C
|
||||
|
||||
if [[ $# -lt 3 || $# -gt 4 ]]; then
|
||||
echo "Usage: $0 <debian-arch> <xmake-arch> <version> [extra-recommendation]" >&2
|
||||
exit 2
|
||||
@@ -169,11 +172,12 @@ EOF
|
||||
|
||||
cat > "$DESKTOP_DIR/$PKG_NAME.desktop" <<EOF
|
||||
[Desktop Entry]
|
||||
Version=$DEB_VERSION
|
||||
Version=1.0
|
||||
Name=$APP_NAME
|
||||
Comment=$DESCRIPTION
|
||||
Exec=/usr/bin/$PKG_NAME
|
||||
Icon=$PKG_NAME
|
||||
StartupWMClass=$PKG_NAME
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=Utility;
|
||||
|
||||
@@ -88,8 +88,10 @@ installApp:
|
||||
; Bundle service-side binaries required by the Windows service flow
|
||||
File "..\..\..\..\build\windows\x64\release\crossdesk_service.exe"
|
||||
File "..\..\..\..\build\windows\x64\release\crossdesk_session_helper.exe"
|
||||
File "..\..\..\..\build\windows\x64\release\crossdesk_privacy_cursor_helper.exe"
|
||||
File "..\..\..\..\build\windows\x64\release\crossdesk_privacy_window.dll"
|
||||
; Bundle runtime DLLs from the release output directory
|
||||
File "..\..\..\..\build\windows\x64\release\*.dll"
|
||||
File /x crossdesk_privacy_window.dll "..\..\..\..\build\windows\x64\release\*.dll"
|
||||
|
||||
Call RegisterInstalledService
|
||||
|
||||
@@ -145,6 +147,8 @@ uninstallApp:
|
||||
Delete "$INSTDIR\CrossDesk.exe"
|
||||
Delete "$INSTDIR\crossdesk_service.exe"
|
||||
Delete "$INSTDIR\crossdesk_session_helper.exe"
|
||||
Delete "$INSTDIR\crossdesk_privacy_cursor_helper.exe"
|
||||
Delete "$INSTDIR\crossdesk_privacy_window.dll"
|
||||
Delete "$INSTDIR\uninstall.exe"
|
||||
|
||||
; Recursively delete installation directory
|
||||
|
||||
@@ -58,6 +58,14 @@ int ConfigCenter::Load() {
|
||||
video_frame_rate_ = static_cast<VIDEO_FRAME_RATE>(ini_.GetLongValue(
|
||||
section_, "video_frame_rate", static_cast<long>(video_frame_rate_)));
|
||||
|
||||
const long screen_capture_method_value = ini_.GetLongValue(
|
||||
section_, "screen_capture_method",
|
||||
static_cast<long>(ScreenCaptureMethod::Auto));
|
||||
screen_capture_method_ = IsValidScreenCaptureMethod(screen_capture_method_value)
|
||||
? static_cast<ScreenCaptureMethod>(
|
||||
screen_capture_method_value)
|
||||
: ScreenCaptureMethod::Auto;
|
||||
|
||||
const long video_adaptation_policy_value = ini_.GetLongValue(
|
||||
section_, "video_adaptation_policy",
|
||||
static_cast<long>(video_adaptation_policy_));
|
||||
@@ -116,19 +124,11 @@ int ConfigCenter::Load() {
|
||||
} else {
|
||||
signal_server_port_ = 0;
|
||||
}
|
||||
const char* coturn_server_port_value =
|
||||
ini_.GetValue(section_, "coturn_server_port", nullptr);
|
||||
if (coturn_server_port_value != nullptr &&
|
||||
strlen(coturn_server_port_value) > 0) {
|
||||
coturn_server_port_ =
|
||||
static_cast<int>(ini_.GetLongValue(section_, "coturn_server_port", 0));
|
||||
} else {
|
||||
coturn_server_port_ = 0;
|
||||
}
|
||||
|
||||
enable_autostart_ =
|
||||
ini_.GetBoolValue(section_, "enable_autostart", enable_autostart_);
|
||||
enable_daemon_ = ini_.GetBoolValue(section_, "enable_daemon", enable_daemon_);
|
||||
enable_privacy_screen_.store(
|
||||
ini_.GetBoolValue(section_, "enable_privacy_screen", true));
|
||||
portable_service_prompt_suppressed_ =
|
||||
ini_.GetBoolValue(section_, "portable_service_prompt_suppressed",
|
||||
portable_service_prompt_suppressed_);
|
||||
@@ -155,6 +155,8 @@ int ConfigCenter::Save() {
|
||||
static_cast<long>(video_quality_));
|
||||
ini_.SetLongValue(section_, "video_frame_rate",
|
||||
static_cast<long>(video_frame_rate_));
|
||||
ini_.SetLongValue(section_, "screen_capture_method",
|
||||
static_cast<long>(screen_capture_method_));
|
||||
ini_.SetLongValue(section_, "video_adaptation_policy",
|
||||
static_cast<long>(video_adaptation_policy_));
|
||||
ini_.SetLongValue(section_, "video_encode_format",
|
||||
@@ -171,12 +173,12 @@ int ConfigCenter::Save() {
|
||||
ini_.SetValue(section_, "signal_server_host", signal_server_host_.c_str());
|
||||
ini_.SetLongValue(section_, "signal_server_port",
|
||||
static_cast<long>(signal_server_port_));
|
||||
ini_.SetLongValue(section_, "coturn_server_port",
|
||||
static_cast<long>(coturn_server_port_));
|
||||
}
|
||||
|
||||
ini_.SetBoolValue(section_, "enable_autostart", enable_autostart_);
|
||||
ini_.SetBoolValue(section_, "enable_daemon", enable_daemon_);
|
||||
ini_.SetBoolValue(section_, "enable_privacy_screen",
|
||||
enable_privacy_screen_.load());
|
||||
ini_.SetBoolValue(section_, "portable_service_prompt_suppressed",
|
||||
portable_service_prompt_suppressed_);
|
||||
|
||||
@@ -225,6 +227,20 @@ int ConfigCenter::SetVideoFrameRate(VIDEO_FRAME_RATE video_frame_rate) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ConfigCenter::SetScreenCaptureMethod(ScreenCaptureMethod method) {
|
||||
if (!IsValidScreenCaptureMethod(static_cast<long>(method))) {
|
||||
return -1;
|
||||
}
|
||||
ini_.SetLongValue(section_, "screen_capture_method", static_cast<long>(method));
|
||||
if (ini_.SaveFile(config_path_.c_str()) < 0) {
|
||||
ini_.SetLongValue(section_, "screen_capture_method",
|
||||
static_cast<long>(screen_capture_method_));
|
||||
return -1;
|
||||
}
|
||||
screen_capture_method_ = method;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ConfigCenter::SetVideoAdaptationPolicy(
|
||||
VIDEO_ADAPTATION_POLICY policy) {
|
||||
video_adaptation_policy_ = policy;
|
||||
@@ -316,17 +332,6 @@ int ConfigCenter::SetServerPort(int signal_server_port) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ConfigCenter::SetCoturnServerPort(int coturn_server_port) {
|
||||
coturn_server_port_ = coturn_server_port;
|
||||
ini_.SetLongValue(section_, "coturn_server_port",
|
||||
static_cast<long>(coturn_server_port_));
|
||||
SI_Error rc = ini_.SaveFile(config_path_.c_str());
|
||||
if (rc < 0) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ConfigCenter::SetSelfHosted(bool enable_self_hosted) {
|
||||
enable_self_hosted_ = enable_self_hosted;
|
||||
ini_.SetBoolValue(section_, "enable_self_hosted", enable_self_hosted_);
|
||||
@@ -346,19 +351,9 @@ int ConfigCenter::SetSelfHosted(bool enable_self_hosted) {
|
||||
signal_server_port_ = static_cast<int>(
|
||||
ini_.GetLongValue(section_, "signal_server_port", 0));
|
||||
}
|
||||
const char* coturn_server_port_value =
|
||||
ini_.GetValue(section_, "coturn_server_port", nullptr);
|
||||
if (coturn_server_port_value != nullptr &&
|
||||
strlen(coturn_server_port_value) > 0) {
|
||||
coturn_server_port_ = static_cast<int>(
|
||||
ini_.GetLongValue(section_, "coturn_server_port", 0));
|
||||
}
|
||||
|
||||
ini_.SetValue(section_, "signal_server_host", signal_server_host_.c_str());
|
||||
ini_.SetLongValue(section_, "signal_server_port",
|
||||
static_cast<long>(signal_server_port_));
|
||||
ini_.SetLongValue(section_, "coturn_server_port",
|
||||
static_cast<long>(coturn_server_port_));
|
||||
}
|
||||
|
||||
SI_Error rc = ini_.SaveFile(config_path_.c_str());
|
||||
@@ -403,6 +398,21 @@ int ConfigCenter::SetDaemon(bool enable_daemon) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ConfigCenter::SetPrivacyScreen(bool enable_privacy_screen) {
|
||||
const bool previous = enable_privacy_screen_.load();
|
||||
if (previous == enable_privacy_screen) return 0;
|
||||
ini_.SetBoolValue(section_, "enable_privacy_screen", enable_privacy_screen);
|
||||
if (ini_.SaveFile(config_path_.c_str()) < 0) {
|
||||
ini_.SetBoolValue(section_, "enable_privacy_screen", previous);
|
||||
LOG_ERROR("Failed to save automatic privacy screen preference");
|
||||
return -1;
|
||||
}
|
||||
// RTC callbacks read only the value committed by the settings thread.
|
||||
enable_privacy_screen_.store(enable_privacy_screen);
|
||||
LOG_INFO("Privacy screen on incoming connection: {}", enable_privacy_screen);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ConfigCenter::SetPortableServicePromptSuppressed(bool suppressed) {
|
||||
portable_service_prompt_suppressed_ = suppressed;
|
||||
ini_.SetBoolValue(section_, "portable_service_prompt_suppressed",
|
||||
@@ -427,6 +437,10 @@ ConfigCenter::VIDEO_FRAME_RATE ConfigCenter::GetVideoFrameRate() const {
|
||||
return video_frame_rate_;
|
||||
}
|
||||
|
||||
ScreenCaptureMethod ConfigCenter::GetScreenCaptureMethod() const {
|
||||
return screen_capture_method_;
|
||||
}
|
||||
|
||||
ConfigCenter::VIDEO_ADAPTATION_POLICY
|
||||
ConfigCenter::GetVideoAdaptationPolicy() const {
|
||||
return video_adaptation_policy_;
|
||||
@@ -456,8 +470,6 @@ std::string ConfigCenter::GetSignalServerHost() const {
|
||||
|
||||
int ConfigCenter::GetSignalServerPort() const { return signal_server_port_; }
|
||||
|
||||
int ConfigCenter::GetCoturnServerPort() const { return coturn_server_port_; }
|
||||
|
||||
std::string ConfigCenter::GetDefaultServerHost() const {
|
||||
return signal_server_host_default_;
|
||||
}
|
||||
@@ -466,16 +478,16 @@ int ConfigCenter::GetDefaultSignalServerPort() const {
|
||||
return server_port_default_;
|
||||
}
|
||||
|
||||
int ConfigCenter::GetDefaultCoturnServerPort() const {
|
||||
return coturn_server_port_default_;
|
||||
}
|
||||
|
||||
bool ConfigCenter::IsSelfHosted() const { return enable_self_hosted_; }
|
||||
|
||||
bool ConfigCenter::IsEnableAutostart() const { return enable_autostart_; }
|
||||
|
||||
bool ConfigCenter::IsEnableDaemon() const { return enable_daemon_; }
|
||||
|
||||
bool ConfigCenter::IsEnablePrivacyScreen() const {
|
||||
return enable_privacy_screen_.load();
|
||||
}
|
||||
|
||||
bool ConfigCenter::IsPortableServicePromptSuppressed() const {
|
||||
return portable_service_prompt_suppressed_;
|
||||
}
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
#ifndef _CONFIG_CENTER_H_
|
||||
#define _CONFIG_CENTER_H_
|
||||
|
||||
#include <atomic>
|
||||
#include <string>
|
||||
|
||||
#include "SimpleIni.h"
|
||||
#include "screen_capturer/screen_capture_method.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
@@ -39,6 +41,7 @@ class ConfigCenter {
|
||||
int SetLanguage(LANGUAGE language);
|
||||
int SetVideoQuality(VIDEO_QUALITY video_quality);
|
||||
int SetVideoFrameRate(VIDEO_FRAME_RATE video_frame_rate);
|
||||
int SetScreenCaptureMethod(ScreenCaptureMethod method);
|
||||
int SetVideoAdaptationPolicy(VIDEO_ADAPTATION_POLICY policy);
|
||||
int SetVideoEncodeFormat(VIDEO_ENCODE_FORMAT video_encode_format);
|
||||
int SetHardwareVideoCodec(bool hardware_video_codec);
|
||||
@@ -47,10 +50,10 @@ class ConfigCenter {
|
||||
int SetSrtp(bool enable_srtp);
|
||||
int SetServerHost(const std::string& signal_server_host);
|
||||
int SetServerPort(int signal_server_port);
|
||||
int SetCoturnServerPort(int coturn_server_port);
|
||||
int SetSelfHosted(bool enable_self_hosted);
|
||||
int SetAutostart(bool enable_autostart);
|
||||
int SetDaemon(bool enable_daemon);
|
||||
int SetPrivacyScreen(bool enable_privacy_screen);
|
||||
int SetPortableServicePromptSuppressed(bool suppressed);
|
||||
int SetFileTransferSavePath(const std::string& path);
|
||||
|
||||
@@ -59,6 +62,7 @@ class ConfigCenter {
|
||||
LANGUAGE GetLanguage() const;
|
||||
VIDEO_QUALITY GetVideoQuality() const;
|
||||
VIDEO_FRAME_RATE GetVideoFrameRate() const;
|
||||
ScreenCaptureMethod GetScreenCaptureMethod() const;
|
||||
VIDEO_ADAPTATION_POLICY GetVideoAdaptationPolicy() const;
|
||||
VIDEO_ENCODE_FORMAT GetVideoEncodeFormat() const;
|
||||
bool IsHardwareVideoCodec() const;
|
||||
@@ -67,13 +71,12 @@ class ConfigCenter {
|
||||
bool IsEnableSrtp() const;
|
||||
std::string GetSignalServerHost() const;
|
||||
int GetSignalServerPort() const;
|
||||
int GetCoturnServerPort() const;
|
||||
std::string GetDefaultServerHost() const;
|
||||
int GetDefaultSignalServerPort() const;
|
||||
int GetDefaultCoturnServerPort() const;
|
||||
bool IsSelfHosted() const;
|
||||
bool IsEnableAutostart() const;
|
||||
bool IsEnableDaemon() const;
|
||||
bool IsEnablePrivacyScreen() const;
|
||||
bool IsPortableServicePromptSuppressed() const;
|
||||
std::string GetFileTransferSavePath() const;
|
||||
|
||||
@@ -88,6 +91,7 @@ class ConfigCenter {
|
||||
LANGUAGE language_ = LANGUAGE::CHINESE;
|
||||
VIDEO_QUALITY video_quality_ = VIDEO_QUALITY::HIGH;
|
||||
VIDEO_FRAME_RATE video_frame_rate_ = VIDEO_FRAME_RATE::FPS_60;
|
||||
ScreenCaptureMethod screen_capture_method_ = ScreenCaptureMethod::Auto;
|
||||
VIDEO_ADAPTATION_POLICY video_adaptation_policy_ =
|
||||
VIDEO_ADAPTATION_POLICY::QUALITY_PRIORITY;
|
||||
VIDEO_ENCODE_FORMAT video_encode_format_ = VIDEO_ENCODE_FORMAT::H264;
|
||||
@@ -98,11 +102,10 @@ class ConfigCenter {
|
||||
std::string signal_server_host_default_ = "api.crossdesk.cn";
|
||||
int signal_server_port_ = 0;
|
||||
int server_port_default_ = 9099;
|
||||
int coturn_server_port_ = 0;
|
||||
int coturn_server_port_default_ = 3478;
|
||||
bool enable_self_hosted_ = false;
|
||||
bool enable_autostart_ = false;
|
||||
bool enable_daemon_ = false;
|
||||
std::atomic<bool> enable_privacy_screen_{true};
|
||||
bool portable_service_prompt_suppressed_ = false;
|
||||
std::string file_transfer_save_path_ = "";
|
||||
};
|
||||
|
||||
@@ -66,8 +66,6 @@ struct InteractionState {
|
||||
bool mouse_controller_is_started_ = false;
|
||||
bool start_screen_capturer_ = false;
|
||||
bool screen_capturer_is_started_ = false;
|
||||
bool start_speaker_capturer_ = false;
|
||||
bool speaker_capturer_is_started_ = false;
|
||||
bool start_keyboard_capturer_ = false;
|
||||
bool show_cursor_ = false;
|
||||
bool keyboard_capturer_is_started_ = false;
|
||||
@@ -198,4 +196,4 @@ struct ApplicationState : MainWindowState,
|
||||
|
||||
} // namespace crossdesk::gui_detail
|
||||
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
#include "platform.h"
|
||||
#include "platform/video_renderer.h"
|
||||
#include "rendering/slint_video_presenter.h"
|
||||
#include "runtime/privacy_control_state.h"
|
||||
#if _WIN32
|
||||
#include <windows.h>
|
||||
|
||||
@@ -1027,6 +1028,9 @@ void GuiApplication::InitializeSettings() {
|
||||
language_button_value_ = localization_language_index_;
|
||||
localization_language_ =
|
||||
static_cast<ConfigCenter::LANGUAGE>(localization_language_index_);
|
||||
privacy_.SetText({
|
||||
localization::privacy_verification[localization_language_index_],
|
||||
localization::privacy_screen_unlock_hint[localization_language_index_]});
|
||||
}
|
||||
|
||||
bool GuiApplication::InitializeSDL() {
|
||||
@@ -1111,6 +1115,9 @@ void GuiApplication::InitializeUi() {
|
||||
wayland_display_guard.emplace("WAYLAND_DISPLAY");
|
||||
wayland_socket_guard.emplace("WAYLAND_SOCKET");
|
||||
}
|
||||
// Match crossdesk.desktop even when the Debian launcher runs crossdesk-bin.
|
||||
// Set this before creating any windows so X11 and Wayland share the same id.
|
||||
slint::set_xdg_app_id("crossdesk");
|
||||
#endif
|
||||
ui_ = std::make_unique<SlintUi>();
|
||||
ui_->video_presenter =
|
||||
@@ -1151,6 +1158,7 @@ void GuiApplication::InitializeSystemTray() {
|
||||
ui_->main->show();
|
||||
ui_->main->window().set_minimized(false);
|
||||
#if defined(__APPLE__)
|
||||
MacActivateWindow(ui_->main->window().appkit_view());
|
||||
// A window restored from the tray may only obtain its NSWindow after the
|
||||
// callback returns, so let the regular UI tick configure it.
|
||||
ui_->main_native_titlebar_attempts = 30;
|
||||
@@ -1161,16 +1169,12 @@ void GuiApplication::InitializeSystemTray() {
|
||||
ui_->main->hide();
|
||||
}
|
||||
};
|
||||
auto open_settings = [this] {
|
||||
auto open_settings = [this, show_window] {
|
||||
if (!ui_) {
|
||||
return;
|
||||
}
|
||||
ui_->main->show();
|
||||
ui_->main->window().set_minimized(false);
|
||||
show_window();
|
||||
ui_->main->set_settings_open(true);
|
||||
#if defined(__APPLE__)
|
||||
ui_->main_native_titlebar_attempts = 30;
|
||||
#endif
|
||||
};
|
||||
auto exit_app = [this] {
|
||||
exit_ = true;
|
||||
@@ -1227,10 +1231,19 @@ void GuiApplication::ResetSettingsUi() {
|
||||
ui_->main->set_self_hosted_enabled(enable_self_hosted_);
|
||||
ui_->main->set_autostart_enabled(enable_autostart_);
|
||||
ui_->main->set_daemon_enabled(enable_daemon_);
|
||||
ui_->main->set_privacy_on_connect_enabled(
|
||||
config_center_->IsEnablePrivacyScreen());
|
||||
#ifdef _WIN32
|
||||
ui_->main->set_capture_method_visible(true);
|
||||
ui_->main->set_capture_method_index(
|
||||
static_cast<int>(config_center_->GetScreenCaptureMethod()));
|
||||
ui_->main->set_privacy_setting_available(true);
|
||||
#else
|
||||
ui_->main->set_privacy_setting_available(false);
|
||||
#endif
|
||||
ui_->main->set_file_save_path(file_transfer_save_path_buf_);
|
||||
ui_->main->set_server_host(signal_server_ip_self_);
|
||||
ui_->main->set_server_port(signal_server_port_self_);
|
||||
ui_->main->set_coturn_port(coturn_server_port_self_);
|
||||
}
|
||||
|
||||
void GuiApplication::BindMainCallbacks() {
|
||||
@@ -1388,7 +1401,6 @@ void GuiApplication::BindMainCallbacks() {
|
||||
main->on_save_self_hosted_settings([this] {
|
||||
const std::string host(ui_->main->get_server_host());
|
||||
const auto signal_port = ParsePort(ui_->main->get_server_port());
|
||||
const auto coturn_port = ParsePort(ui_->main->get_coturn_port());
|
||||
if (!host.empty()) {
|
||||
config_center_->SetServerHost(host);
|
||||
std::memset(signal_server_ip_self_, 0, sizeof(signal_server_ip_self_));
|
||||
@@ -1405,25 +1417,14 @@ void GuiApplication::BindMainCallbacks() {
|
||||
std::snprintf(signal_server_port_, sizeof(signal_server_port_), "%d",
|
||||
*signal_port);
|
||||
}
|
||||
if (coturn_port) {
|
||||
config_center_->SetCoturnServerPort(*coturn_port);
|
||||
std::snprintf(coturn_server_port_self_, sizeof(coturn_server_port_self_),
|
||||
"%d", *coturn_port);
|
||||
std::snprintf(coturn_server_port_, sizeof(coturn_server_port_), "%d",
|
||||
*coturn_port);
|
||||
}
|
||||
});
|
||||
main->on_cancel_self_hosted_settings([this] {
|
||||
const std::string host = config_center_->GetSignalServerHost();
|
||||
ui_->main->set_server_host(UiText(host));
|
||||
const int signal_port = config_center_->GetSignalServerPort();
|
||||
const int coturn_port = config_center_->GetCoturnServerPort();
|
||||
ui_->main->set_server_port(signal_port > 0
|
||||
? UiText(std::to_string(signal_port))
|
||||
: slint::SharedString{});
|
||||
ui_->main->set_coturn_port(coturn_port > 0
|
||||
? UiText(std::to_string(coturn_port))
|
||||
: slint::SharedString{});
|
||||
});
|
||||
main->on_open_download([this] { OpenUrl("https://crossdesk.cn"); });
|
||||
main->on_connection_cancel([this] {
|
||||
@@ -1600,6 +1601,36 @@ void GuiApplication::BindStreamCallbacks() {
|
||||
SendReliableDataFrame(props->peer_, message.c_str(), message.size(),
|
||||
props->control_data_label_.c_str());
|
||||
});
|
||||
stream->on_toggle_privacy([this] {
|
||||
auto props = SelectedSession();
|
||||
if (!props || !props->peer_ || !props->control_mouse_ ||
|
||||
props->connection_status_.load() != ConnectionStatus::Connected) return;
|
||||
RemoteAction action{};
|
||||
action.type = ControlType::privacy_command;
|
||||
{
|
||||
std::lock_guard lock(props->privacy_status_mutex_);
|
||||
const auto& status = props->privacy_status_;
|
||||
const uint64_t now = SDL_GetTicks();
|
||||
const auto controls = GetPrivacyControlState(
|
||||
status, props->privacy_status_received_, props->privacy_command_pending_,
|
||||
now - props->privacy_status_tick_, now - props->privacy_command_tick_);
|
||||
if (!controls.can_toggle) return;
|
||||
action.pc.flag = controls.active ? PrivacyCommandFlag::disable
|
||||
: PrivacyCommandFlag::enable;
|
||||
action.pc.block_local_input = true;
|
||||
props->privacy_command_pending_ = true;
|
||||
props->privacy_command_tick_ = now;
|
||||
props->privacy_request_revision_ = status.revision;
|
||||
}
|
||||
const auto message = action.to_json();
|
||||
if (SendReliableDataFrame(props->peer_, message.data(), message.size(),
|
||||
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;
|
||||
LOG_WARN("Privacy command send failed, remote_id={}", props->remote_id_);
|
||||
}
|
||||
});
|
||||
stream->on_toggle_mouse_control([this] {
|
||||
auto props = SelectedSession();
|
||||
if (!props || !props->connection_established_) {
|
||||
@@ -1807,6 +1838,7 @@ void GuiApplication::Tick() {
|
||||
HandleConnectionStatusChange();
|
||||
HandlePendingPresenceProbe();
|
||||
HandlePresenceProbeTimeout();
|
||||
HandlePrivacy();
|
||||
HandleWindowsServiceIntegration();
|
||||
#if defined(__linux__) && !defined(__APPLE__)
|
||||
SyncXWaylandWindowActivation();
|
||||
@@ -2117,7 +2149,7 @@ void GuiApplication::SyncMainWindow() {
|
||||
ui_->main->set_latest_version(UiText(latest_version_));
|
||||
ui_->main->set_release_name(UiText(release_name_));
|
||||
ui_->main->set_release_date(UiText(release_date_));
|
||||
ui_->main->set_settings_session_active(stream_window_inited_);
|
||||
ui_->main->set_settings_session_active(HasActiveSession());
|
||||
#if (((defined(_WIN32) || defined(__linux__)) && !defined(__aarch64__) && \
|
||||
!defined(__arm__) && USE_CUDA) || \
|
||||
defined(__APPLE__))
|
||||
@@ -2520,6 +2552,33 @@ void GuiApplication::SyncStreamWindow() {
|
||||
? localization::receiving_screen[localization_language_index_]
|
||||
: std::string{}));
|
||||
(*ui_->stream)->set_mouse_control_enabled(props->control_mouse_);
|
||||
{
|
||||
std::lock_guard lock(props->privacy_status_mutex_);
|
||||
const auto& status = props->privacy_status_;
|
||||
const uint64_t now = SDL_GetTicks();
|
||||
const auto controls = GetPrivacyControlState(
|
||||
status, props->privacy_status_received_, props->privacy_command_pending_,
|
||||
now - props->privacy_status_tick_, now - props->privacy_command_tick_);
|
||||
const bool connected =
|
||||
props->connection_status_.load() == ConnectionStatus::Connected;
|
||||
(*ui_->stream)->set_privacy_active(controls.active);
|
||||
(*ui_->stream)->set_privacy_off(connected && controls.off);
|
||||
(*ui_->stream)->set_privacy_unsupported(controls.unsupported);
|
||||
(*ui_->stream)->set_privacy_can_toggle(
|
||||
props->control_mouse_ && connected && controls.can_toggle);
|
||||
const int language = localization_language_index_;
|
||||
std::string text = !connected || !props->privacy_status_received_
|
||||
? localization::privacy_screen[language]
|
||||
: controls.timeout || controls.stale ? localization::privacy_timeout[language]
|
||||
: controls.warning ? localization::privacy_paused[language]
|
||||
: controls.enabled ? localization::privacy_on[language]
|
||||
: !status.supported ? localization::privacy_unsupported[language]
|
||||
: controls.off ? localization::privacy_off[language]
|
||||
: localization::privacy_screen[language];
|
||||
if (connected && controls.warning && status.reason[0] != '\0')
|
||||
text += std::string("\n") + status.reason;
|
||||
(*ui_->stream)->set_privacy_status_text(UiText(text));
|
||||
}
|
||||
int remote_cursor_shape =
|
||||
static_cast<int>(RemoteCursorShape::default_cursor);
|
||||
bool remote_cursor_active = false;
|
||||
@@ -2763,7 +2822,14 @@ void GuiApplication::SyncServerWindow() {
|
||||
});
|
||||
}
|
||||
|
||||
if (has_connected_controller && !ui_->server) {
|
||||
// Keep the local connection panel out of captured privacy sessions and
|
||||
// transitions; it is unnecessary while the physical screens are covered.
|
||||
const auto privacy_status = privacy_.Snapshot();
|
||||
const bool privacy_covering = privacy_status.overlay_active ||
|
||||
privacy_status.state == PrivacyState::starting ||
|
||||
privacy_status.state == PrivacyState::stopping;
|
||||
const bool show_controller_window = has_connected_controller && !privacy_covering;
|
||||
if (show_controller_window && !ui_->server) {
|
||||
ui_->server.emplace(ui::ServerWindow::create());
|
||||
RegisterFontAwesome((*ui_->server)->window());
|
||||
(*ui_->server)->set_controllers(ui_->controller_model);
|
||||
@@ -2787,7 +2853,7 @@ void GuiApplication::SyncServerWindow() {
|
||||
server_window_created_ = true;
|
||||
server_window_inited_ = true;
|
||||
}
|
||||
if (!has_connected_controller && ui_->server) {
|
||||
if (!show_controller_window && ui_->server) {
|
||||
(*ui_->server)->hide();
|
||||
ui_->server.reset();
|
||||
server_window_created_ = false;
|
||||
@@ -2931,6 +2997,9 @@ void GuiApplication::SaveSettingsFromUi() {
|
||||
static_cast<ConfigCenter::LANGUAGE>(language_button_value_);
|
||||
localization_language_index_ = language_button_value_;
|
||||
config_center_->SetLanguage(localization_language_);
|
||||
privacy_.SetText({
|
||||
localization::privacy_verification[localization_language_index_],
|
||||
localization::privacy_screen_unlock_hint[localization_language_index_]});
|
||||
config_center_->SetVideoQuality(
|
||||
static_cast<ConfigCenter::VIDEO_QUALITY>(video_quality_button_value_));
|
||||
config_center_->SetVideoFrameRate(static_cast<ConfigCenter::VIDEO_FRAME_RATE>(
|
||||
@@ -2947,6 +3016,22 @@ void GuiApplication::SaveSettingsFromUi() {
|
||||
config_center_->SetSelfHosted(enable_self_hosted_);
|
||||
config_center_->SetAutostart(enable_autostart_);
|
||||
config_center_->SetDaemon(enable_daemon_);
|
||||
#ifdef _WIN32
|
||||
if (!HasActiveSession()) {
|
||||
const auto capture_method = static_cast<ScreenCaptureMethod>(
|
||||
std::clamp(main->get_capture_method_index(), 0, 3));
|
||||
if (config_center_->SetScreenCaptureMethod(capture_method) != 0) {
|
||||
LOG_ERROR("Failed to save screen capture method");
|
||||
}
|
||||
}
|
||||
main->set_capture_method_index(
|
||||
static_cast<int>(config_center_->GetScreenCaptureMethod()));
|
||||
if (config_center_->SetPrivacyScreen(main->get_privacy_on_connect_enabled()) !=
|
||||
0) {
|
||||
main->set_privacy_on_connect_enabled(
|
||||
config_center_->IsEnablePrivacyScreen());
|
||||
}
|
||||
#endif
|
||||
|
||||
const std::string path(main->get_file_save_path());
|
||||
std::memset(file_transfer_save_path_buf_, 0,
|
||||
@@ -2962,9 +3047,6 @@ void GuiApplication::SaveSettingsFromUi() {
|
||||
if (const auto port = ParsePort(main->get_server_port())) {
|
||||
config_center_->SetServerPort(*port);
|
||||
}
|
||||
if (const auto port = ParsePort(main->get_coturn_port())) {
|
||||
config_center_->SetCoturnServerPort(*port);
|
||||
}
|
||||
|
||||
language_button_value_last_ = language_button_value_;
|
||||
video_quality_button_value_last_ = video_quality_button_value_;
|
||||
@@ -2981,7 +3063,7 @@ void GuiApplication::SaveSettingsFromUi() {
|
||||
file_transfer_save_path_last_ = path;
|
||||
ui_->localized_language = -1;
|
||||
|
||||
if (!stream_window_inited_) {
|
||||
if (!HasActiveSession()) {
|
||||
CloseAllRemoteSessions();
|
||||
CreateConnectionPeer();
|
||||
}
|
||||
@@ -3283,9 +3365,12 @@ void GuiApplication::Cleanup() {
|
||||
#if _WIN32 && CROSSDESK_PORTABLE
|
||||
JoinPortableWindowsServiceInstallThread();
|
||||
#endif
|
||||
privacy_.Fail("Application exiting; remote operation paused");
|
||||
clipboard_.Shutdown();
|
||||
keyboard_.ForceReleasePressedKeys();
|
||||
keyboard_.ReleaseAllRemotePressedKeys("application_exit");
|
||||
devices_.DestroyDevices();
|
||||
privacy_.Shutdown();
|
||||
devices_.DestroyFactories();
|
||||
CloseAllRemoteSessions();
|
||||
WaitForSessionCleanup();
|
||||
|
||||
@@ -20,6 +20,14 @@ struct TranslationRow {
|
||||
|
||||
// Single source of truth for all UI strings.
|
||||
#define CROSSDESK_LOCALIZATION_ALL(X) \
|
||||
X(privacy_screen, u8"隐私屏", "Privacy screen", u8"Приватный экран") \
|
||||
X(privacy_enable, u8"开启隐私屏", "Enable privacy screen", u8"Включить приватный экран") \
|
||||
X(privacy_disable, u8"关闭隐私屏 / 恢复操作", "Turn privacy off / recover", u8"Выключить / восстановить") \
|
||||
X(privacy_on, u8"隐私屏:已开启", "Privacy screen: on", u8"Приватный экран: включён") \
|
||||
X(privacy_off, u8"隐私屏:已关闭", "Privacy screen: off", u8"Приватный экран: выключен") \
|
||||
X(privacy_unsupported, u8"隐私屏:被控端暂不支持", "Privacy screen: unavailable on this host", u8"Приватный экран: недоступен на узле") \
|
||||
X(privacy_timeout, u8"隐私屏状态未知:被控端确认超时,可关闭后重试", "Privacy state unknown: host confirmation timed out; turn off to recover", u8"Статус неизвестен: время ожидания истекло") \
|
||||
X(privacy_paused, u8"隐私保护未就绪,远程操作已暂停。点击隐私屏按钮关闭并恢复。", "Privacy protection is not ready; remote operation paused. Click the privacy screen button to turn it off and recover.", u8"Приватность не готова; нажмите кнопку приватного экрана для выключения и восстановления управления.") \
|
||||
X(local_desktop, u8"本桌面", "Local Desktop", u8"Локальный рабочий стол") \
|
||||
X(local_id, u8"本机ID", "Local ID", u8"Локальный ID") \
|
||||
X(local_id_copied_to_clipboard, u8"已复制到剪贴板", "Copied to clipboard", \
|
||||
@@ -120,6 +128,9 @@ struct TranslationRow {
|
||||
u8"стола") \
|
||||
X(settings, u8"设置", "Settings", u8"Настройки") \
|
||||
X(language, u8"语言:", "Language:", u8"Язык:") \
|
||||
X(screen_capture_method, u8"采集方式:", "Capture Method:", \
|
||||
u8"Способ захвата:") \
|
||||
X(screen_capture_method_auto, u8"自动", "Auto", u8"Авто") \
|
||||
X(video_quality, u8"画面质量:", "Video Quality:", u8"Качество видео:") \
|
||||
X(video_frame_rate, u8"画面采集帧率:", \
|
||||
"Video Capture Frame Rate:", u8"Частота захвата видео:") \
|
||||
@@ -149,8 +160,6 @@ struct TranslationRow {
|
||||
"Server Address:", u8"Адрес сервера:") \
|
||||
X(self_hosted_server_port, u8"信令服务端口:", \
|
||||
"Signal Service Port:", u8"Порт сигнального сервиса:") \
|
||||
X(self_hosted_server_coturn_server_port, u8"中继服务端口:", \
|
||||
"Relay Service Port:", u8"Порт реле-сервиса:") \
|
||||
X(ok, u8"确认", "OK", u8"ОК") \
|
||||
X(cancel, u8"取消", "Cancel", u8"Отмена") \
|
||||
X(new_password, u8"请输入六位密码:", \
|
||||
@@ -204,6 +213,8 @@ struct TranslationRow {
|
||||
"Confirm to delete this connection", u8"Удалить это подключение?") \
|
||||
X(enable_autostart, u8"开机自启:", "Auto Start:", u8"Автозапуск:") \
|
||||
X(enable_daemon, u8"启用守护进程:", "Enable Daemon:", u8"Включить демон:") \
|
||||
X(privacy_on_connect, u8"被连接时开启隐私屏:", "Privacy screen on connect:", \
|
||||
u8"Приватность при подключении:") \
|
||||
X(takes_effect_after_restart, u8"重启后生效", "Takes effect after restart", \
|
||||
u8"Вступит в силу после перезапуска") \
|
||||
X(select_file, u8"选择文件发送", "Select File to Send", \
|
||||
@@ -239,6 +250,11 @@ struct TranslationRow {
|
||||
u8"Для работы приложения требуются следующие разрешения:") \
|
||||
X(show_main_window, u8"显示主界面", "Show Main Window", \
|
||||
u8"Показать главное окно") \
|
||||
X(privacy_verification, u8"隐私屏验证", "Privacy screen verification", \
|
||||
u8"Проверка приватности") \
|
||||
X(privacy_screen_unlock_hint, u8"按下快捷键,解除隐私屏", \
|
||||
"To turn off the privacy screen, press", \
|
||||
u8"Чтобы отключить приватный экран, нажмите") \
|
||||
X(exit_program, u8"退出", "Exit", u8"Выход")
|
||||
|
||||
inline constexpr TranslationRow kTranslationRows[] = {
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
#include "platform.h"
|
||||
#include "rd_log.h"
|
||||
#include "runtime/gui_runtime.h"
|
||||
#include "speaker_capturer_factory.h"
|
||||
#ifdef _WIN32
|
||||
#include "platform/windows/screen_capturer/screen_capturer_win.h"
|
||||
#include "platform/windows/input/mouse/mouse_controller.h"
|
||||
#endif
|
||||
|
||||
namespace crossdesk {
|
||||
namespace {
|
||||
@@ -19,7 +24,23 @@ constexpr auto kFrameDeadlineTolerance = std::chrono::milliseconds(1);
|
||||
|
||||
} // namespace
|
||||
|
||||
SessionDeviceManager::SessionDeviceManager(GuiRuntime &owner) : owner_(owner) {}
|
||||
SessionDeviceManager::SessionDeviceManager(GuiRuntime &owner)
|
||||
: owner_(owner),
|
||||
speaker_capture_(
|
||||
[] {
|
||||
return std::unique_ptr<SpeakerCapturer>(
|
||||
SpeakerCapturerFactory().Create());
|
||||
},
|
||||
[this](unsigned char *data, size_t size, const char *) {
|
||||
if (!owner_.peer_ || !owner_.privacy_.RemoteAllowed()) return;
|
||||
MiniRtcAudioFrame frame{};
|
||||
frame.data = reinterpret_cast<const char *>(data);
|
||||
frame.size = size;
|
||||
frame.captured_timestamp = GetSystemTimeMicros(owner_.peer_);
|
||||
SendAudioFrame(owner_.peer_, &frame, owner_.audio_label_.c_str());
|
||||
}) {}
|
||||
|
||||
SessionDeviceManager::~SessionDeviceManager() { speaker_capture_.Shutdown(); }
|
||||
|
||||
bool SessionDeviceManager::ShouldSendCapturedFrame(
|
||||
std::chrono::steady_clock::time_point now, int fps) {
|
||||
@@ -41,7 +62,6 @@ bool SessionDeviceManager::ShouldSendCapturedFrame(
|
||||
void SessionDeviceManager::Initialize() {
|
||||
InitializeAudioOutput();
|
||||
screen_capturer_factory_ = new ScreenCapturerFactory();
|
||||
speaker_capturer_factory_ = new SpeakerCapturerFactory();
|
||||
device_controller_factory_ = new DeviceControllerFactory();
|
||||
keyboard_capturer_ =
|
||||
static_cast<KeyboardCapturer *>(device_controller_factory_->Create(
|
||||
@@ -59,6 +79,12 @@ int SessionDeviceManager::InitializeScreenCapturer() {
|
||||
screen_capturer_ =
|
||||
static_cast<ScreenCapturer *>(screen_capturer_factory_->Create());
|
||||
}
|
||||
#ifdef _WIN32
|
||||
if (auto windows = dynamic_cast<ScreenCapturerWin*>(screen_capturer_)) {
|
||||
windows->SetPrivacyController(&owner_.privacy_);
|
||||
windows->SetCaptureMethod(owner_.config_center_->GetScreenCaptureMethod());
|
||||
}
|
||||
#endif
|
||||
|
||||
last_frame_time_ = {};
|
||||
next_frame_deadline_ = {};
|
||||
@@ -76,6 +102,7 @@ int SessionDeviceManager::InitializeScreenCapturer() {
|
||||
fps, [this, fps](unsigned char *data, int size, int width, int height,
|
||||
const char *display_name,
|
||||
const MiniRtcNativeVideoFrame *native_frame) {
|
||||
if (!owner_.privacy_.RemoteAllowed()) return;
|
||||
const auto now_time = std::chrono::steady_clock::now();
|
||||
if (!ShouldSendCapturedFrame(now_time, fps)) {
|
||||
return;
|
||||
@@ -138,6 +165,9 @@ int SessionDeviceManager::InitializeScreenCapturer() {
|
||||
}
|
||||
|
||||
MiniRtcVideoFrame frame{};
|
||||
// A new controller can connect after this callback's first gate check
|
||||
// while it is collecting the Connected peer IDs above.
|
||||
if (!owner_.privacy_.RemoteAllowed()) return;
|
||||
frame.data = reinterpret_cast<const char *>(data);
|
||||
frame.size = size;
|
||||
frame.width = width;
|
||||
@@ -200,45 +230,19 @@ int SessionDeviceManager::StopScreenCapturer() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int SessionDeviceManager::StartSpeakerCapturer() {
|
||||
if (!speaker_capturer_) {
|
||||
speaker_capturer_ =
|
||||
static_cast<SpeakerCapturer *>(speaker_capturer_factory_->Create());
|
||||
const int init_ret = speaker_capturer_->Init(
|
||||
[this](unsigned char *data, size_t size, const char *audio_name) {
|
||||
MiniRtcAudioFrame frame{};
|
||||
frame.data = reinterpret_cast<const char *>(data);
|
||||
frame.size = size;
|
||||
frame.captured_timestamp = GetSystemTimeMicros(owner_.peer_);
|
||||
SendAudioFrame(owner_.peer_, &frame, owner_.audio_label_.c_str());
|
||||
});
|
||||
#ifdef _WIN32
|
||||
void SessionDeviceManager::ReleaseRemoteMouseButtons() {
|
||||
if (auto mouse = dynamic_cast<PlatformMouseController*>(mouse_controller_))
|
||||
mouse->ReleasePressedButtons();
|
||||
}
|
||||
#endif
|
||||
|
||||
if (init_ret != 0) {
|
||||
speaker_capturer_->Destroy();
|
||||
delete speaker_capturer_;
|
||||
speaker_capturer_ = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
if (!speaker_capturer_) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const int ret = speaker_capturer_->Start();
|
||||
if (ret != 0) {
|
||||
LOG_ERROR("Start speaker capturer failed: {}", ret);
|
||||
return ret;
|
||||
}
|
||||
owner_.start_speaker_capturer_ = true;
|
||||
return 0;
|
||||
void SessionDeviceManager::StartSpeakerCapturer() {
|
||||
speaker_capture_.SetEnabled(true);
|
||||
}
|
||||
|
||||
int SessionDeviceManager::StopSpeakerCapturer() {
|
||||
if (speaker_capturer_) {
|
||||
speaker_capturer_->Stop();
|
||||
owner_.start_speaker_capturer_ = false;
|
||||
}
|
||||
return 0;
|
||||
void SessionDeviceManager::StopSpeakerCapturer() {
|
||||
speaker_capture_.SetEnabled(false);
|
||||
}
|
||||
|
||||
int SessionDeviceManager::StartMouseController() {
|
||||
@@ -448,16 +452,6 @@ void SessionDeviceManager::UpdateInteractions() {
|
||||
owner_.screen_capturer_is_started_ = false;
|
||||
}
|
||||
|
||||
if (owner_.start_speaker_capturer_ && !owner_.speaker_capturer_is_started_) {
|
||||
if (StartSpeakerCapturer() == 0) {
|
||||
owner_.speaker_capturer_is_started_ = true;
|
||||
}
|
||||
} else if (!owner_.start_speaker_capturer_ &&
|
||||
owner_.speaker_capturer_is_started_) {
|
||||
StopSpeakerCapturer();
|
||||
owner_.speaker_capturer_is_started_ = false;
|
||||
}
|
||||
|
||||
if (owner_.start_mouse_controller_ && !owner_.mouse_controller_is_started_) {
|
||||
if (StartMouseController() == 0) {
|
||||
owner_.mouse_controller_is_started_ = true;
|
||||
@@ -515,6 +509,7 @@ void SessionDeviceManager::DrainCapturedKeyboardInput() {
|
||||
std::lock_guard<std::mutex> lock(captured_keyboard_inputs_mutex_);
|
||||
inputs.swap(captured_keyboard_inputs_);
|
||||
}
|
||||
if (owner_.privacy_.Snapshot().input_blocked) return;
|
||||
for (const CapturedKeyboardInput &input : inputs) {
|
||||
owner_.keyboard_.SendKeyCommand(input.key_code, input.is_down,
|
||||
input.scan_code, input.extended);
|
||||
@@ -535,6 +530,7 @@ bool SessionDeviceManager::SendKeyboardCommand(int key_code, bool is_down,
|
||||
|
||||
void SessionDeviceManager::SendMouseCommand(const RemoteAction &action,
|
||||
int selected_display) {
|
||||
if (!owner_.privacy_.RemoteAllowed()) return;
|
||||
if (mouse_controller_) {
|
||||
mouse_controller_->SendMouseCommand(action, selected_display);
|
||||
}
|
||||
@@ -556,6 +552,7 @@ SessionDeviceManager::display_info_list() const {
|
||||
}
|
||||
|
||||
void SessionDeviceManager::DestroyDevices() {
|
||||
speaker_capture_.Shutdown();
|
||||
if (mouse_controller_) {
|
||||
mouse_controller_->Destroy();
|
||||
delete mouse_controller_;
|
||||
@@ -566,11 +563,6 @@ void SessionDeviceManager::DestroyDevices() {
|
||||
delete screen_capturer_;
|
||||
screen_capturer_ = nullptr;
|
||||
}
|
||||
if (speaker_capturer_) {
|
||||
speaker_capturer_->Destroy();
|
||||
delete speaker_capturer_;
|
||||
speaker_capturer_ = nullptr;
|
||||
}
|
||||
if (keyboard_capturer_) {
|
||||
delete keyboard_capturer_;
|
||||
keyboard_capturer_ = nullptr;
|
||||
@@ -580,8 +572,6 @@ void SessionDeviceManager::DestroyDevices() {
|
||||
void SessionDeviceManager::DestroyFactories() {
|
||||
delete screen_capturer_factory_;
|
||||
screen_capturer_factory_ = nullptr;
|
||||
delete speaker_capturer_factory_;
|
||||
speaker_capturer_factory_ = nullptr;
|
||||
delete device_controller_factory_;
|
||||
device_controller_factory_ = nullptr;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
#ifndef CROSSDESK_GUI_SESSION_DEVICE_MANAGER_H_
|
||||
#define CROSSDESK_GUI_SESSION_DEVICE_MANAGER_H_
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-09
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _SESSION_DEVICE_MANAGER_H_
|
||||
#define _SESSION_DEVICE_MANAGER_H_
|
||||
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
@@ -17,7 +24,7 @@
|
||||
#include "device_controller_factory.h"
|
||||
#include "display_info.h"
|
||||
#include "screen_capturer_factory.h"
|
||||
#include "speaker_capturer_factory.h"
|
||||
#include "speaker_capture_controller.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
@@ -29,6 +36,7 @@ class GuiRuntime;
|
||||
class SessionDeviceManager {
|
||||
public:
|
||||
explicit SessionDeviceManager(GuiRuntime &owner);
|
||||
~SessionDeviceManager();
|
||||
|
||||
void Initialize();
|
||||
void UpdateInteractions();
|
||||
@@ -38,8 +46,13 @@ public:
|
||||
int InitializeScreenCapturer();
|
||||
int StartScreenCapturer();
|
||||
int StopScreenCapturer();
|
||||
int StartSpeakerCapturer();
|
||||
int StopSpeakerCapturer();
|
||||
#ifdef _WIN32
|
||||
void ReleaseRemoteMouseButtons();
|
||||
#endif
|
||||
// Requests are asynchronous; Stop drains/revokes audio delivery before
|
||||
// returning so the connection peer can be destroyed safely.
|
||||
void StartSpeakerCapturer();
|
||||
void StopSpeakerCapturer();
|
||||
int StartMouseController();
|
||||
int StopMouseController();
|
||||
int StartKeyboardCapturer();
|
||||
@@ -76,8 +89,7 @@ private:
|
||||
SDL_AudioStream *output_stream_ = nullptr;
|
||||
ScreenCapturerFactory *screen_capturer_factory_ = nullptr;
|
||||
ScreenCapturer *screen_capturer_ = nullptr;
|
||||
SpeakerCapturerFactory *speaker_capturer_factory_ = nullptr;
|
||||
SpeakerCapturer *speaker_capturer_ = nullptr;
|
||||
SpeakerCaptureController speaker_capture_;
|
||||
DeviceControllerFactory *device_controller_factory_ = nullptr;
|
||||
MouseController *mouse_controller_ = nullptr;
|
||||
KeyboardCapturer *keyboard_capturer_ = nullptr;
|
||||
@@ -93,4 +105,4 @@ private:
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif // CROSSDESK_GUI_SESSION_DEVICE_MANAGER_H_
|
||||
#endif
|
||||
|
||||
@@ -212,8 +212,13 @@ int KeyboardController::SendKeyCommand(int key_code, bool is_down,
|
||||
|
||||
bool KeyboardController::InjectRemoteKey(int key_code, bool is_down,
|
||||
uint32_t scan_code, bool extended) {
|
||||
if (is_down && !owner_.privacy_.RemoteAllowed()) return false;
|
||||
#if _WIN32
|
||||
if (owner_.local_service_status_received_ &&
|
||||
if (owner_.privacy_.Engaged() && !IsWindowsPrivacyDesktopAvailable()) {
|
||||
owner_.privacy_.Fail("Secure desktop or locked session; remote key injection paused");
|
||||
return false;
|
||||
}
|
||||
if (!owner_.privacy_.Engaged() && owner_.local_service_status_received_ &&
|
||||
IsSecureDesktopInteractionRequired(owner_.local_interactive_stage_)) {
|
||||
const std::string response = SendCrossDeskSecureDesktopKeyInput(
|
||||
key_code, is_down, scan_code, extended, 1000);
|
||||
@@ -246,6 +251,8 @@ bool KeyboardController::InjectRemoteKey(int key_code, bool is_down,
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
// Recheck after the desktop query; key-up cleanup remains allowed while paused.
|
||||
if (is_down && !owner_.privacy_.RemoteAllowed()) return false;
|
||||
return owner_.devices_.SendKeyboardCommand(key_code, is_down, scan_code,
|
||||
extended);
|
||||
}
|
||||
@@ -342,11 +349,50 @@ void KeyboardController::ReleaseRemotePressedKeys(const std::string& remote_id,
|
||||
keys_to_release.size(), remote_id, reason ? reason : "unknown");
|
||||
}
|
||||
for (const PressedKey& key : keys_to_release) {
|
||||
InjectRemoteKey(key.key_code, false, key.scan_code, key.extended);
|
||||
if (!InjectRemoteKey(key.key_code, false, key.scan_code, key.extended)) {
|
||||
#ifdef _WIN32
|
||||
if (owner_.privacy_.Engaged()) {
|
||||
std::lock_guard lock(remote_states_mutex_);
|
||||
pending_privacy_releases_[key.key_code] = key;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void KeyboardController::ReleaseAllRemotePressedKeys(const char* reason) {
|
||||
std::vector<std::string> remotes;
|
||||
{
|
||||
std::lock_guard lock(remote_states_mutex_);
|
||||
for (const auto& [id, state] : remote_states_) remotes.push_back(id);
|
||||
}
|
||||
for (const auto& id : remotes) ReleaseRemotePressedKeys(id, reason);
|
||||
CheckRemoteTimeouts();
|
||||
}
|
||||
|
||||
void KeyboardController::CheckRemoteTimeouts() {
|
||||
#ifdef _WIN32
|
||||
bool pending = false;
|
||||
{
|
||||
std::lock_guard lock(remote_states_mutex_);
|
||||
pending = !pending_privacy_releases_.empty();
|
||||
}
|
||||
// A key-up rejected on the secure desktop is retained until the ordinary
|
||||
// desktop returns. Never route cleanup through the security UI helper.
|
||||
if (pending && IsWindowsPrivacyDesktopAvailable()) {
|
||||
std::unordered_map<int, PressedKey> releases;
|
||||
{
|
||||
std::lock_guard lock(remote_states_mutex_);
|
||||
releases.swap(pending_privacy_releases_);
|
||||
}
|
||||
for (const auto& [code, key] : releases) {
|
||||
if (!owner_.devices_.SendKeyboardCommand(code, false, key.scan_code, key.extended)) {
|
||||
std::lock_guard lock(remote_states_mutex_);
|
||||
pending_privacy_releases_[code] = key;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
const uint32_t now = static_cast<uint32_t>(SDL_GetTicks());
|
||||
std::vector<std::string> timed_out_remotes;
|
||||
{
|
||||
|
||||
@@ -30,6 +30,7 @@ public:
|
||||
const RemoteAction &remote_action);
|
||||
void ReleaseRemotePressedKeys(const std::string &remote_id,
|
||||
const char *reason);
|
||||
void ReleaseAllRemotePressedKeys(const char* reason);
|
||||
void CheckRemoteTimeouts();
|
||||
|
||||
private:
|
||||
@@ -58,6 +59,9 @@ private:
|
||||
uint32_t last_heartbeat_tick_ = 0;
|
||||
std::unordered_map<std::string, RemoteState> remote_states_;
|
||||
std::mutex remote_states_mutex_;
|
||||
#ifdef _WIN32
|
||||
std::unordered_map<int, PressedKey> pending_privacy_releases_;
|
||||
#endif
|
||||
};
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
@@ -25,6 +25,16 @@ constexpr auto kPresenceRefreshInterval = std::chrono::seconds(30);
|
||||
constexpr auto kPresenceRetryInterval = std::chrono::seconds(1);
|
||||
} // namespace
|
||||
|
||||
bool GuiRuntime::HasActiveSession() {
|
||||
if (stream_window_inited_) return true;
|
||||
std::shared_lock lock(connection_status_mutex_);
|
||||
return std::any_of(connection_status_.begin(), connection_status_.end(),
|
||||
[](const auto& entry) {
|
||||
return entry.second == ConnectionStatus::Connecting ||
|
||||
entry.second == ConnectionStatus::Connected;
|
||||
});
|
||||
}
|
||||
|
||||
void GuiRuntime::HandleConnectionStatusChange() {
|
||||
if (!signal_connected_ || !peer_) {
|
||||
return;
|
||||
@@ -121,6 +131,9 @@ void GuiRuntime::HandlePresenceProbeTimeout() {
|
||||
void GuiRuntime::HandleServerControllerDisconnected(
|
||||
const std::string& remote_id, const char* reason) {
|
||||
keyboard_.ReleaseRemotePressedKeys(remote_id, reason);
|
||||
#ifdef _WIN32
|
||||
devices_.ReleaseRemoteMouseButtons();
|
||||
#endif
|
||||
{
|
||||
std::lock_guard lock(remote_pointer_input_mutex_);
|
||||
last_remote_pointer_input_time_.erase(remote_id);
|
||||
@@ -151,6 +164,10 @@ void GuiRuntime::HandleServerControllerDisconnected(
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_INFO("Last remote controller disconnected; recovering privacy, reason={}",
|
||||
reason ? reason : "unknown");
|
||||
privacy_.Disconnected();
|
||||
|
||||
need_to_create_server_window_.store(false, std::memory_order_release);
|
||||
need_to_destroy_server_window_.store(true, std::memory_order_release);
|
||||
is_server_mode_ = false;
|
||||
@@ -168,14 +185,11 @@ void GuiRuntime::HandleServerControllerDisconnected(
|
||||
#else
|
||||
start_screen_capturer_ = false;
|
||||
#endif
|
||||
start_speaker_capturer_ = false;
|
||||
devices_.StopSpeakerCapturer();
|
||||
start_mouse_controller_ = false;
|
||||
start_keyboard_capturer_ = false;
|
||||
remote_client_id_.clear();
|
||||
if (audio_capture_) {
|
||||
devices_.StopSpeakerCapturer();
|
||||
audio_capture_ = false;
|
||||
}
|
||||
audio_capture_ = false;
|
||||
devices_.ResetToInitialDisplay();
|
||||
}
|
||||
|
||||
|
||||
@@ -34,12 +34,10 @@ int GuiRuntime::CreateConnectionPeer() {
|
||||
|
||||
std::string signal_server_ip;
|
||||
int signal_server_port;
|
||||
int coturn_server_port;
|
||||
|
||||
if (config_center_->IsSelfHosted()) {
|
||||
signal_server_ip = config_center_->GetSignalServerHost();
|
||||
signal_server_port = config_center_->GetSignalServerPort();
|
||||
coturn_server_port = config_center_->GetCoturnServerPort();
|
||||
|
||||
std::string current_self_hosted_ip = config_center_->GetSignalServerHost();
|
||||
const bool use_cached_id = settings_.LoadCachedSelfHostedIdentity();
|
||||
@@ -64,7 +62,6 @@ int GuiRuntime::CreateConnectionPeer() {
|
||||
} else {
|
||||
signal_server_ip = config_center_->GetDefaultServerHost();
|
||||
signal_server_port = config_center_->GetDefaultSignalServerPort();
|
||||
coturn_server_port = config_center_->GetDefaultCoturnServerPort();
|
||||
settings_.ActivateCachedPublicIdentity();
|
||||
params_.user_id = client_id_with_password_;
|
||||
}
|
||||
@@ -115,34 +112,11 @@ int GuiRuntime::CreateConnectionPeer() {
|
||||
} else {
|
||||
signal_server_port_self_[0] = '\0';
|
||||
}
|
||||
int coturn_port = config_center_->GetCoturnServerPort();
|
||||
if (coturn_port > 0) {
|
||||
strncpy(coturn_server_port_self_, std::to_string(coturn_port).c_str(),
|
||||
sizeof(coturn_server_port_self_) - 1);
|
||||
coturn_server_port_self_[sizeof(coturn_server_port_self_) - 1] = '\0';
|
||||
} else {
|
||||
coturn_server_port_self_[0] = '\0';
|
||||
}
|
||||
|
||||
// peer config
|
||||
strncpy((char *)params_.signal_server_ip, signal_server_ip.c_str(),
|
||||
sizeof(params_.signal_server_ip) - 1);
|
||||
params_.signal_server_ip[sizeof(params_.signal_server_ip) - 1] = '\0';
|
||||
params_.signal_server_port = signal_server_port;
|
||||
strncpy((char *)params_.stun_server_ip, signal_server_ip.c_str(),
|
||||
sizeof(params_.stun_server_ip) - 1);
|
||||
params_.stun_server_ip[sizeof(params_.stun_server_ip) - 1] = '\0';
|
||||
params_.stun_server_port = coturn_server_port;
|
||||
strncpy((char *)params_.turn_server_ip, signal_server_ip.c_str(),
|
||||
sizeof(params_.turn_server_ip) - 1);
|
||||
params_.turn_server_ip[sizeof(params_.turn_server_ip) - 1] = '\0';
|
||||
params_.turn_server_port = coturn_server_port;
|
||||
// TURN credentials are issued by the signaling server after login. Keep the
|
||||
// initial values empty so a reusable static password is never embedded in
|
||||
// the client binary.
|
||||
params_.turn_server_username[0] = '\0';
|
||||
params_.turn_server_password[0] = '\0';
|
||||
|
||||
strncpy(params_.log_path, dll_log_path_.c_str(),
|
||||
sizeof(params_.log_path) - 1);
|
||||
params_.log_path[sizeof(params_.log_path) - 1] = '\0';
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#define _GUI_RUNTIME_H_
|
||||
|
||||
#include <atomic>
|
||||
#include <deque>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
@@ -16,6 +17,7 @@
|
||||
#include "features/file_transfer/file_transfer_manager.h"
|
||||
#include "features/input/keyboard_controller.h"
|
||||
#include "features/settings/settings_manager.h"
|
||||
#include "privacy_controller.h"
|
||||
#include "runtime/gui_state.h"
|
||||
#include "runtime/peer_event_handler.h"
|
||||
|
||||
@@ -55,9 +57,13 @@ class GuiRuntime : protected gui_detail::GuiState {
|
||||
void HandleConnectionStatusChange();
|
||||
void HandlePendingPresenceProbe();
|
||||
void HandlePresenceProbeTimeout();
|
||||
bool HasActiveSession();
|
||||
void HandleServerControllerDisconnected(const std::string& remote_id,
|
||||
const char* reason);
|
||||
void HandleWindowsServiceIntegration();
|
||||
void HandlePrivacy();
|
||||
bool IsAuthorizedController(const std::string& remote_id);
|
||||
void QueuePrivacyCommand(const std::string& remote_id, const PrivacyCommand& command);
|
||||
|
||||
void CloseRemoteSession(std::shared_ptr<RemoteSession> props);
|
||||
void CloseAllRemoteSessions();
|
||||
@@ -87,6 +93,12 @@ class GuiRuntime : protected gui_detail::GuiState {
|
||||
bool EnsureMacAccessibilityPermission();
|
||||
#endif
|
||||
|
||||
PrivacyController privacy_;
|
||||
std::mutex privacy_commands_mutex_;
|
||||
std::deque<std::pair<std::string, PrivacyCommand>> privacy_commands_;
|
||||
uint32_t last_privacy_revision_ = 0;
|
||||
uint64_t last_privacy_status_tick_ = 0;
|
||||
bool privacy_was_paused_ = false;
|
||||
ClipboardController clipboard_;
|
||||
SessionDeviceManager devices_;
|
||||
FileTransferManager transfers_;
|
||||
|
||||
@@ -88,6 +88,8 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
||||
}
|
||||
|
||||
std::string source_id = std::string(src_id, src_id_size);
|
||||
if (!runtime->privacy_.RemoteAllowed() &&
|
||||
(source_id == runtime->clipboard_label_ || source_id == runtime->file_label_)) return;
|
||||
if (source_id == runtime->file_label_) {
|
||||
std::string remote_user_id = std::string(user_id, user_id_size);
|
||||
|
||||
@@ -147,6 +149,48 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
||||
}
|
||||
|
||||
std::string remote_id(user_id, user_id_size);
|
||||
if (remote_action.type == ControlType::privacy_status) {
|
||||
if (auto props = runtime->FindRemoteSession(remote_id)) {
|
||||
if (source_id != props->control_data_label_) return;
|
||||
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) {
|
||||
props->privacy_status_ = remote_action.ps;
|
||||
props->privacy_status_received_ = true;
|
||||
props->privacy_status_tick_ = SDL_GetTicks();
|
||||
if (static_cast<int32_t>(remote_action.ps.revision - props->privacy_request_revision_) > 0 &&
|
||||
remote_action.ps.state != PrivacyState::starting &&
|
||||
remote_action.ps.state != PrivacyState::stopping)
|
||||
props->privacy_command_pending_ = false;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (remote_action.type == ControlType::privacy_command) {
|
||||
if (source_id == runtime->control_data_label_)
|
||||
runtime->QueuePrivacyCommand(remote_id, remote_action.pc);
|
||||
return;
|
||||
}
|
||||
const bool operates_host = remote_action.type == ControlType::mouse ||
|
||||
remote_action.type == ControlType::keyboard || remote_action.type == ControlType::keyboard_state ||
|
||||
remote_action.type == ControlType::service_command || remote_action.type == ControlType::display_id ||
|
||||
remote_action.type == ControlType::audio_capture;
|
||||
if (operates_host) {
|
||||
if (!runtime->IsAuthorizedController(remote_id)) return;
|
||||
#ifdef _WIN32
|
||||
// KeyboardController checks the desktop immediately before each native
|
||||
// key injection, including state reconciliation and cleanup. Avoid a
|
||||
// second synchronous session query for every received keyboard packet.
|
||||
const bool keyboard_input = remote_action.type == ControlType::keyboard ||
|
||||
remote_action.type == ControlType::keyboard_state;
|
||||
if (!keyboard_input && runtime->privacy_.Engaged() &&
|
||||
!IsWindowsPrivacyDesktopAvailable()) {
|
||||
runtime->privacy_.Fail("Secure desktop or lock screen; privacy cannot cover system security UI. Remote input paused.");
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
if (!runtime->privacy_.RemoteAllowed()) return;
|
||||
}
|
||||
if (remote_action.type == ControlType::service_status) {
|
||||
if (auto props = runtime->FindRemoteSession(remote_id)) {
|
||||
runtime->ApplyRemoteServiceStatus(*props, remote_action.ss);
|
||||
@@ -155,6 +199,10 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
||||
}
|
||||
|
||||
if (remote_action.type == ControlType::service_command) {
|
||||
if (runtime->privacy_.Engaged()) {
|
||||
runtime->privacy_.Fail("Security shortcuts require leaving privacy mode; remote operation paused. Turn privacy off first.");
|
||||
return;
|
||||
}
|
||||
#if _WIN32
|
||||
if (remote_action.c.flag == ServiceCommandFlag::send_sas) {
|
||||
runtime->pending_windows_service_sas_.store(true,
|
||||
@@ -258,7 +306,7 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
||||
std::chrono::steady_clock::now();
|
||||
}
|
||||
#if _WIN32
|
||||
if (runtime->local_service_status_received_ &&
|
||||
if (!runtime->privacy_.Engaged() && runtime->local_service_status_received_ &&
|
||||
IsSecureDesktopInteractionRequired(runtime->local_interactive_stage_) &&
|
||||
remote_action.type != ControlType::keyboard &&
|
||||
remote_action.type != ControlType::keyboard_state) {
|
||||
@@ -298,9 +346,9 @@ void PeerEventHandler::OnReceiveDataBuffer(
|
||||
runtime->devices_.SendMouseCommand(remote_action,
|
||||
runtime->selected_display_);
|
||||
} else if (remote_action.type == ControlType::audio_capture) {
|
||||
if (remote_action.a && !runtime->start_speaker_capturer_)
|
||||
if (remote_action.a)
|
||||
runtime->devices_.StartSpeakerCapturer();
|
||||
else if (!remote_action.a && runtime->start_speaker_capturer_)
|
||||
else
|
||||
runtime->devices_.StopSpeakerCapturer();
|
||||
} else if (remote_action.type == ControlType::keyboard) {
|
||||
runtime->keyboard_.ApplyRemoteEvent(remote_id, remote_action);
|
||||
|
||||
@@ -405,6 +405,20 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
|
||||
runtime->show_connection_status_window_ = true;
|
||||
{
|
||||
std::unique_lock lock(runtime->connection_status_mutex_);
|
||||
#ifdef _WIN32
|
||||
if (status == ConnectionStatus::Connected &&
|
||||
runtime->config_center_->IsEnablePrivacyScreen() &&
|
||||
std::none_of(runtime->connection_status_.begin(),
|
||||
runtime->connection_status_.end(), [](const auto& entry) {
|
||||
return entry.second == ConnectionStatus::Connected;
|
||||
})) {
|
||||
// Pause before Connected becomes observable by capture/input threads.
|
||||
// Only the first controller takes over local input. A later join must
|
||||
// not release an existing controller's keys or re-enable privacy that
|
||||
// the current session explicitly turned off.
|
||||
runtime->privacy_.EnableOnConnection();
|
||||
}
|
||||
#endif
|
||||
runtime->connection_status_[remote_id] = status;
|
||||
}
|
||||
|
||||
@@ -468,7 +482,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
|
||||
std::memory_order_release);
|
||||
runtime->is_server_mode_ = true;
|
||||
runtime->start_screen_capturer_ = true;
|
||||
runtime->start_speaker_capturer_ = true;
|
||||
runtime->devices_.StartSpeakerCapturer();
|
||||
runtime->remote_client_id_ = remote_id;
|
||||
runtime->start_mouse_controller_ = true;
|
||||
{
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-12
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _PRIVACY_CONTROL_STATE_H_
|
||||
#define _PRIVACY_CONTROL_STATE_H_
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include "remote_action.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
struct PrivacyControlState {
|
||||
bool active = false;
|
||||
bool enabled = false;
|
||||
bool off = false;
|
||||
bool unsupported = false;
|
||||
bool warning = false;
|
||||
bool stale = false;
|
||||
bool timeout = false;
|
||||
bool can_toggle = false;
|
||||
};
|
||||
|
||||
// Use the same host-confirmed state for presentation and command admission.
|
||||
// An engaged or failed screen can still be disabled when its status goes stale.
|
||||
inline PrivacyControlState GetPrivacyControlState(
|
||||
const PrivacyStatus& status, bool received, bool command_pending,
|
||||
uint64_t status_age_ms, uint64_t command_age_ms) {
|
||||
PrivacyControlState result;
|
||||
result.timeout = command_pending && command_age_ms > 15000;
|
||||
result.stale = received && status_age_ms > 5000;
|
||||
const bool request_pending = command_pending && !result.timeout;
|
||||
const bool fresh = received && !result.stale;
|
||||
result.active = status.overlay_active || status.remote_paused ||
|
||||
status.state == PrivacyState::on ||
|
||||
status.state == PrivacyState::starting ||
|
||||
status.state == PrivacyState::stopping ||
|
||||
status.state == PrivacyState::failed;
|
||||
// A command in flight only gates repeated clicks; it adds no display state.
|
||||
result.enabled = fresh && !result.timeout &&
|
||||
status.state == PrivacyState::on && status.overlay_active &&
|
||||
!status.remote_paused;
|
||||
result.off = fresh && !result.timeout &&
|
||||
status.state == PrivacyState::off && !result.active;
|
||||
result.unsupported = received && !status.supported;
|
||||
result.warning = result.timeout || result.stale ||
|
||||
(received && status.state == PrivacyState::failed);
|
||||
result.can_toggle = !request_pending &&
|
||||
(result.active || (fresh && status.supported));
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-11
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#include <shared_mutex>
|
||||
|
||||
#include "rd_log.h"
|
||||
#include "runtime/gui_runtime.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
bool GuiRuntime::IsAuthorizedController(const std::string& remote_id) {
|
||||
// Connections appear here only after the existing password/signalling/RTC
|
||||
// admission path. Outgoing/viewer sessions live in remote_sessions_ instead.
|
||||
std::shared_lock lock(connection_status_mutex_);
|
||||
const auto found = connection_status_.find(remote_id);
|
||||
return found != connection_status_.end() &&
|
||||
found->second == ConnectionStatus::Connected;
|
||||
}
|
||||
|
||||
void GuiRuntime::QueuePrivacyCommand(const std::string& remote_id,
|
||||
const PrivacyCommand& command) {
|
||||
if (!IsAuthorizedController(remote_id)) return;
|
||||
std::lock_guard lock(privacy_commands_mutex_);
|
||||
if (privacy_commands_.size() < 32)
|
||||
privacy_commands_.emplace_back(remote_id, command);
|
||||
}
|
||||
|
||||
void GuiRuntime::HandlePrivacy() {
|
||||
std::deque<std::pair<std::string, PrivacyCommand>> commands;
|
||||
{
|
||||
std::lock_guard lock(privacy_commands_mutex_);
|
||||
commands.swap(privacy_commands_);
|
||||
}
|
||||
for (const auto& [remote_id, command] : commands) {
|
||||
if (!IsAuthorizedController(remote_id)) continue;
|
||||
if (command.flag == PrivacyCommandFlag::enable) {
|
||||
privacy_.Enable(command.block_local_input);
|
||||
} else if (command.flag == PrivacyCommandFlag::disable) {
|
||||
privacy_.Disable();
|
||||
}
|
||||
last_privacy_status_tick_ = 0;
|
||||
}
|
||||
const auto status = privacy_.Snapshot();
|
||||
if (status.remote_paused && !privacy_was_paused_) {
|
||||
keyboard_.ReleaseAllRemotePressedKeys("privacy_paused");
|
||||
}
|
||||
#ifdef _WIN32
|
||||
if (status.remote_paused && IsWindowsPrivacyDesktopAvailable())
|
||||
devices_.ReleaseRemoteMouseButtons();
|
||||
#endif
|
||||
if (privacy_was_paused_ && !status.remote_paused &&
|
||||
status.state == PrivacyState::off && start_screen_capturer_) {
|
||||
// A capture failure may have stopped the underlying capturer. Reuse the
|
||||
// normal start/retry path when the user explicitly leaves privacy mode.
|
||||
screen_capturer_is_started_ = devices_.StartScreenCapturer() == 0;
|
||||
}
|
||||
privacy_was_paused_ = status.remote_paused;
|
||||
const uint64_t now = SDL_GetTicks();
|
||||
if (!peer_ || (last_privacy_status_tick_ != 0 &&
|
||||
status.revision == last_privacy_revision_ &&
|
||||
now - last_privacy_status_tick_ < 1000))
|
||||
return;
|
||||
std::vector<std::string> controllers;
|
||||
{
|
||||
std::shared_lock lock(connection_status_mutex_);
|
||||
for (const auto& [id, state] : connection_status_)
|
||||
if (state == ConnectionStatus::Connected) controllers.push_back(id);
|
||||
}
|
||||
RemoteAction action{};
|
||||
action.type = ControlType::privacy_status;
|
||||
action.ps = status;
|
||||
const auto message = action.to_json();
|
||||
bool sent = true;
|
||||
for (const auto& id : controllers) {
|
||||
const int result = SendReliableDataFrameToPeer(
|
||||
peer_, message.data(), message.size(), control_data_label_.c_str(),
|
||||
id.data(), id.size());
|
||||
if (result != 0) {
|
||||
sent = false;
|
||||
LOG_WARN("Privacy status send failed, peer={}, ret={}", id, result);
|
||||
}
|
||||
}
|
||||
if (sent) {
|
||||
last_privacy_revision_ = status.revision;
|
||||
last_privacy_status_tick_ = now;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
@@ -157,6 +157,13 @@ struct RemoteSession {
|
||||
std::string audio_capture_button_label_ = "Audio Capture";
|
||||
std::string remote_host_name_;
|
||||
bool remote_service_status_received_ = false;
|
||||
std::mutex privacy_status_mutex_;
|
||||
PrivacyStatus privacy_status_{};
|
||||
bool privacy_status_received_ = false;
|
||||
bool privacy_command_pending_ = false;
|
||||
uint64_t privacy_command_tick_ = 0;
|
||||
uint32_t privacy_request_revision_ = 0;
|
||||
uint64_t privacy_status_tick_ = 0;
|
||||
bool remote_service_available_ = false;
|
||||
std::string remote_interactive_stage_;
|
||||
std::vector<DisplayInfo> display_info_list_;
|
||||
@@ -184,4 +191,4 @@ using RemoteSessionPtr = std::shared_ptr<RemoteSession>;
|
||||
|
||||
} // namespace crossdesk::gui_detail
|
||||
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -139,7 +139,6 @@ struct UserSettingsState {
|
||||
bool enable_srtp_ = false;
|
||||
char signal_server_ip_[256] = "api.crossdesk.cn";
|
||||
char signal_server_port_[6] = "9099";
|
||||
char coturn_server_port_[6] = "3478";
|
||||
bool enable_self_hosted_ = false;
|
||||
int language_button_value_last_ = 0;
|
||||
int video_quality_button_value_last_ = 0;
|
||||
@@ -158,7 +157,6 @@ struct UserSettingsState {
|
||||
std::string file_transfer_save_path_last_;
|
||||
char signal_server_ip_self_[256] = "";
|
||||
char signal_server_port_self_[6] = "";
|
||||
char coturn_server_port_self_[6] = "";
|
||||
bool settings_window_pos_reset_ = true;
|
||||
bool self_hosted_server_config_window_pos_reset_ = true;
|
||||
std::string selected_current_file_path_;
|
||||
|
||||
@@ -45,6 +45,7 @@ export global FontAwesomeIcons {
|
||||
out property <string> shield-halved: "\u{f3ed}";
|
||||
out property <string> square-full: "\u{f45c}";
|
||||
out property <string> trash-can: "\u{f2ed}";
|
||||
out property <string> user-secret: "\u{f21b}";
|
||||
out property <string> volume-high: "\u{f028}";
|
||||
out property <string> volume-xmark: "\u{f6a9}";
|
||||
out property <string> xmark: "\u{f00d}";
|
||||
|
||||
@@ -39,6 +39,8 @@ export global UiStrings {
|
||||
in-out property <string> language: "Language";
|
||||
in-out property <string> video-quality: "Video quality";
|
||||
in-out property <string> frame-rate: "Frame rate";
|
||||
in-out property <string> capture-method: "Capture method";
|
||||
in-out property <string> capture-method-auto: "Auto";
|
||||
in-out property <string> adaptation-policy: "Video preference";
|
||||
in-out property <string> priority-frame-rate: "Frame rate";
|
||||
in-out property <string> priority-quality: "Quality";
|
||||
@@ -50,11 +52,11 @@ export global UiStrings {
|
||||
in-out property <string> self-hosted: "Self-hosted server";
|
||||
in-out property <string> autostart: "Start with system";
|
||||
in-out property <string> daemon: "Run in background";
|
||||
in-out property <string> privacy-on-connect: "Privacy screen on connect";
|
||||
in-out property <string> file-save-path: "File save path";
|
||||
in-out property <string> default-desktop: "Desktop";
|
||||
in-out property <string> server-host: "Server host";
|
||||
in-out property <string> server-port: "Signal port";
|
||||
in-out property <string> coturn-port: "TURN port";
|
||||
in-out property <string> version: "Version";
|
||||
in-out property <string> copyright: "© 2026 by JUNKUN DI. All right reserved.";
|
||||
in-out property <string> license: "Licensed under GNU GPL v3.";
|
||||
@@ -186,6 +188,7 @@ component ConnectionDialogButton inherits Rectangle {
|
||||
|
||||
export component MainWindow inherits Window {
|
||||
title: "CrossDesk";
|
||||
icon: @image-url("../../../resources/linux/crossdesk_256x256.png");
|
||||
preferred-width: 640px;
|
||||
preferred-height: 450px + root.titlebar-height;
|
||||
min-width: 640px;
|
||||
@@ -238,6 +241,8 @@ export component MainWindow inherits Window {
|
||||
in-out property <int> language-index: 0;
|
||||
in-out property <int> video-quality-index: 2;
|
||||
in-out property <int> frame-rate-index: 1;
|
||||
in property <bool> capture-method-visible: false;
|
||||
in-out property <int> capture-method-index: 0;
|
||||
in-out property <int> adaptation-policy-index: 1;
|
||||
in-out property <int> codec-index: 0;
|
||||
in-out property <bool> hardware-codec-enabled: true;
|
||||
@@ -246,10 +251,12 @@ export component MainWindow inherits Window {
|
||||
in-out property <bool> self-hosted-enabled: false;
|
||||
in-out property <bool> autostart-enabled: false;
|
||||
in-out property <bool> daemon-enabled: false;
|
||||
// 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 <string> file-save-path: "";
|
||||
in-out property <string> server-host: "";
|
||||
in-out property <string> server-port: "";
|
||||
in-out property <string> coturn-port: "";
|
||||
in property <bool> settings-session-active: false;
|
||||
// Keep the setting visible on every platform. Unsupported builds expose it
|
||||
// as disabled so the fixed settings layout never turns into a blank row.
|
||||
@@ -1446,6 +1453,11 @@ export component MainWindow inherits Window {
|
||||
Text { x: 8px; y: 3px; width: root.compact-language ? 94px : 162px; height: 24px; text: UiStrings.language; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; }
|
||||
CompactComboBox { x: root.compact-language ? 106px : 174px; y: 2px; model: ["中文", "English", "Русский"]; current-index <=> root.language-index; }
|
||||
}
|
||||
if root.capture-method-visible: Rectangle {
|
||||
height: 30px;
|
||||
Text { x: 8px; y: 3px; width: root.compact-language ? 94px : 162px; height: 24px; text: UiStrings.capture-method; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; }
|
||||
CompactComboBox { x: root.compact-language ? 106px : 174px; y: 2px; enabled: !root.settings-session-active; model: [UiStrings.capture-method-auto, "DXGI", "WGC", "GDI"]; current-index <=> root.capture-method-index; }
|
||||
}
|
||||
Rectangle {
|
||||
height: 30px;
|
||||
Text { x: 8px; y: 3px; width: root.compact-language ? 94px : 162px; height: 24px; text: UiStrings.video-quality; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; }
|
||||
@@ -1481,6 +1493,11 @@ export component MainWindow inherits Window {
|
||||
Text { x: 8px; y: 3px; width: root.compact-language ? 145px : 212px; height: 24px; text: UiStrings.srtp; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; }
|
||||
ImGuiCheckBox { x: root.compact-language ? 157px : 224px; y: 3px; checked <=> root.srtp-enabled; enabled: !root.settings-session-active; }
|
||||
}
|
||||
Rectangle {
|
||||
height: 30px;
|
||||
Text { x: 8px; y: 3px; width: root.compact-language ? 145px : 212px; height: 24px; text: UiStrings.privacy-on-connect; color: root.privacy-setting-available ? #202124 : #8b8f95; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; }
|
||||
ImGuiCheckBox { x: root.compact-language ? 157px : 224px; y: 3px; checked <=> root.privacy-on-connect-enabled; enabled: root.privacy-setting-available; }
|
||||
}
|
||||
Rectangle {
|
||||
height: 30px;
|
||||
CompactButton { x: 8px; y: 3px; width: root.compact-language ? 70px : 206px; text: UiStrings.self-hosted; enabled: !root.settings-session-active; clicked => { root.self-host-settings-open = true; } }
|
||||
@@ -1518,7 +1535,7 @@ export component MainWindow inherits Window {
|
||||
x: root.compact-language ? 190px : 140px;
|
||||
y: 105px;
|
||||
width: root.compact-language ? 260px : 360px;
|
||||
height: 168px;
|
||||
height: 138px;
|
||||
border-radius: 7px;
|
||||
|
||||
Rectangle {
|
||||
@@ -1558,21 +1575,8 @@ export component MainWindow inherits Window {
|
||||
LineEdit { x: 1px; y: 1.5px; width: parent.width - 2px; height: parent.height - 2px; text <=> root.server-port; input-type: InputType.number; font-size: ImGuiFontStyle.body; }
|
||||
LineEditFocusUnderlineMask { x: 5px; y: parent.height - 3px; width: parent.width - 10px; }
|
||||
}
|
||||
Text { x: 8px; y: 91px; width: root.compact-language ? 88px : 165px; height: 24px; text: UiStrings.coturn-port; font-size: ImGuiFontStyle.body; overflow: elide; vertical-alignment: center; }
|
||||
Rectangle {
|
||||
x: root.compact-language ? 100px : 173px;
|
||||
y: 90px;
|
||||
width: root.compact-language ? 152px : 179px;
|
||||
height: 24px;
|
||||
background: white;
|
||||
border-width: 1px;
|
||||
border-color: ImGuiLineStyle.border;
|
||||
border-radius: 4px;
|
||||
LineEdit { x: 1px; y: 1.5px; width: parent.width - 2px; height: parent.height - 2px; text <=> root.coturn-port; input-type: InputType.number; font-size: ImGuiFontStyle.body; }
|
||||
LineEditFocusUnderlineMask { x: 5px; y: parent.height - 3px; width: parent.width - 10px; }
|
||||
}
|
||||
CompactButton { x: root.compact-language ? 93px : 135px; y: 130px; primary: true; text: UiStrings.ok; clicked => { root.save-self-hosted-settings(); root.self-host-settings-open = false; } }
|
||||
CompactButton { x: root.compact-language ? 134px : 176px; y: 130px; width: root.compact-language ? 34px : 56px; text: UiStrings.cancel; clicked => { root.cancel-self-hosted-settings(); root.self-host-settings-open = false; } }
|
||||
CompactButton { x: root.compact-language ? 93px : 135px; y: 100px; primary: true; text: UiStrings.ok; clicked => { root.save-self-hosted-settings(); root.self-host-settings-open = false; } }
|
||||
CompactButton { x: root.compact-language ? 134px : 176px; y: 100px; width: root.compact-language ? 34px : 56px; text: UiStrings.cancel; clicked => { root.cancel-self-hosted-settings(); root.self-host-settings-open = false; } }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ export struct ControllerEntry {
|
||||
|
||||
export component ServerWindow inherits Window {
|
||||
title: "CrossDesk";
|
||||
icon: @image-url("../../../resources/linux/crossdesk_256x256.png");
|
||||
preferred-width: root.language-index == 0 ? 250px : root.language-index == 1 ? 330px : 430px;
|
||||
preferred-height: 150px;
|
||||
min-width: root.language-index == 0 ? 250px : root.language-index == 1 ? 330px : 430px;
|
||||
|
||||
@@ -25,6 +25,8 @@ export struct NetworkStatsRow {
|
||||
export global StreamStrings {
|
||||
in-out property <string> select-display: "Select Display";
|
||||
in-out property <string> send-shortcut: "Send Shortcut";
|
||||
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";
|
||||
in-out property <string> release-mouse: "Release Mouse";
|
||||
in-out property <string> audio: "Audio Capture";
|
||||
@@ -54,19 +56,23 @@ component ControlBarButton inherits Rectangle {
|
||||
in property <string> tooltip: "";
|
||||
in property <bool> selected: false;
|
||||
in property <bool> slashed: false;
|
||||
in property <bool> action-enabled: true;
|
||||
// Capability and temporary command availability have different visuals.
|
||||
in property <bool> muted: false;
|
||||
in property <length> icon-size: 16px;
|
||||
out property <bool> has-hover: touch.has-hover;
|
||||
callback clicked;
|
||||
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: touch.pressed ? #0f87fa
|
||||
background: root.muted ? #eef0f3 : touch.pressed && root.action-enabled ? #0f87fa
|
||||
: touch.has-hover ? #4296fa
|
||||
: root.selected ? #4296fa : #4296fa66;
|
||||
border-radius: 3px;
|
||||
|
||||
Text {
|
||||
text: root.icon;
|
||||
color: #24272d;
|
||||
color: root.muted ? #8a8f96 : #24272d;
|
||||
font-family: "Font Awesome 6 Free";
|
||||
font-weight: 900;
|
||||
font-size: root.icon-size;
|
||||
@@ -105,12 +111,14 @@ component ControlBarButton inherits Rectangle {
|
||||
}
|
||||
|
||||
touch := TouchArea {
|
||||
clicked => { root.clicked(); }
|
||||
// Keep disabled controls hit-testable so clicks cannot reach the peer.
|
||||
clicked => { if root.action-enabled { root.clicked(); } }
|
||||
}
|
||||
}
|
||||
|
||||
export component StreamWindow inherits Window {
|
||||
title: "CrossDesk";
|
||||
icon: @image-url("../../../resources/linux/crossdesk_256x256.png");
|
||||
preferred-width: 1280px;
|
||||
preferred-height: 720px + (root.custom-titlebar ? 32px : 0px);
|
||||
min-width: 640px;
|
||||
@@ -133,6 +141,12 @@ export component StreamWindow inherits Window {
|
||||
in property <[string]> displays;
|
||||
in-out property <int> selected-display: 0;
|
||||
in property <bool> mouse-control-enabled: true;
|
||||
in property <bool> privacy-can-toggle: false;
|
||||
in property <bool> privacy-active: false;
|
||||
in property <bool> privacy-off: false;
|
||||
in property <bool> privacy-unsupported: false;
|
||||
// Privacy feedback stays on its toolbar button and hover tooltip.
|
||||
in property <string> privacy-status-text: "Privacy screen";
|
||||
// Numeric values mirror crossdesk::RemoteCursorShape and Slint's MouseCursor.
|
||||
in property <bool> remote-cursor-active: false;
|
||||
in property <int> remote-cursor-shape: 0;
|
||||
@@ -153,6 +167,7 @@ export component StreamWindow inherits Window {
|
||||
callback switch-display(int);
|
||||
callback send-shortcut(string);
|
||||
callback toggle-mouse-control;
|
||||
callback toggle-privacy;
|
||||
callback toggle-audio;
|
||||
callback select-file;
|
||||
callback close-file-transfer;
|
||||
@@ -175,6 +190,13 @@ export component StreamWindow inherits Window {
|
||||
private property <bool> display-menu-animation-enabled: false;
|
||||
private property <float> display-menu-progress: 0.0;
|
||||
private property <bool> shortcut-menu-open: false;
|
||||
private property <bool> privacy-tooltip-visible: false;
|
||||
private property <bool> privacy-hovered: root.control-expanded
|
||||
&& privacy-button.has-hover && !root.control-dragging;
|
||||
private property <length> privacy-tooltip-width: min(320px, video.width);
|
||||
private property <length> privacy-tooltip-x: min(max(0px,
|
||||
control.x + (root.control-docked-left ? 105px : 138px)),
|
||||
video.width - root.privacy-tooltip-width);
|
||||
// A popup can remove itself from the scene in its clicked callback before
|
||||
// the video input layer observes the same pointer release. Remember that
|
||||
// the press belonged to local UI so that release cannot reach the peer.
|
||||
@@ -214,6 +236,13 @@ export component StreamWindow inherits Window {
|
||||
}
|
||||
}
|
||||
|
||||
changed privacy-hovered => { root.privacy-tooltip-visible = false; }
|
||||
privacy-tooltip-timer := Timer {
|
||||
interval: 450ms;
|
||||
running: root.privacy-hovered && !root.privacy-tooltip-visible;
|
||||
triggered => { root.privacy-tooltip-visible = true; }
|
||||
}
|
||||
|
||||
is-local-control-area(pointer-x, pointer-y) => {
|
||||
let over-control = pointer-x >= control.x
|
||||
&& pointer-x <= control.x + control.width
|
||||
@@ -231,7 +260,7 @@ export component StreamWindow inherits Window {
|
||||
let shortcut-menu-y = control.y + 40px;
|
||||
let over-shortcut-menu = root.shortcut-menu-open
|
||||
&& pointer-x >= shortcut-menu-x
|
||||
&& pointer-x <= shortcut-menu-x + 150px
|
||||
&& pointer-x <= shortcut-menu-x + 180px
|
||||
&& pointer-y >= shortcut-menu-y
|
||||
&& pointer-y <= shortcut-menu-y + 68px;
|
||||
return over-control || over-display-menu || over-shortcut-menu;
|
||||
@@ -638,7 +667,7 @@ export component StreamWindow inherits Window {
|
||||
control := Rectangle {
|
||||
x: root.control-dragging ? root.control-drag-x : root.control-docked-left ? 0px : parent.width - self.width;
|
||||
y: min(max(0px, root.control-pos-y), parent.height - self.height);
|
||||
width: root.control-expanded ? min(300px, parent.width) : 20px;
|
||||
width: root.control-expanded ? min(332px, parent.width) : 20px;
|
||||
height: root.stats-visible && root.control-expanded ? min(180px, parent.height) : 38px;
|
||||
background: white;
|
||||
border-width: 1px;
|
||||
@@ -690,40 +719,40 @@ export component StreamWindow inherits Window {
|
||||
clicked => { root.toggle-mouse-control(); }
|
||||
}
|
||||
if root.control-expanded: audio-button := ControlBarButton {
|
||||
x: root.control-docked-left ? 105px : 138px; y: 7px;
|
||||
x: root.control-docked-left ? 137px : 170px; y: 7px;
|
||||
icon: FontAwesomeIcons.volume-high; slashed: !root.audio-enabled;
|
||||
tooltip: root.audio-enabled ? StreamStrings.mute : StreamStrings.audio;
|
||||
clicked => { root.toggle-audio(); }
|
||||
}
|
||||
if root.control-expanded: file-button := ControlBarButton {
|
||||
x: root.control-docked-left ? 137px : 170px; y: 7px;
|
||||
x: root.control-docked-left ? 169px : 202px; y: 7px;
|
||||
icon: FontAwesomeIcons.folder-open; tooltip: StreamStrings.select-file;
|
||||
clicked => { root.select-file(); }
|
||||
}
|
||||
if root.control-expanded: stats-button := ControlBarButton {
|
||||
x: root.control-docked-left ? 169px : 202px; y: 7px;
|
||||
x: root.control-docked-left ? 201px : 234px; y: 7px;
|
||||
icon: FontAwesomeIcons.signal; selected: root.stats-visible;
|
||||
tooltip: root.stats-visible ? StreamStrings.hide-stats : StreamStrings.show-stats;
|
||||
clicked => { root.toggle-stats(); }
|
||||
}
|
||||
if root.control-expanded: fullscreen-button := ControlBarButton {
|
||||
x: root.control-docked-left ? 201px : 234px; y: 7px;
|
||||
x: root.control-docked-left ? 233px : 266px; y: 7px;
|
||||
icon: root.fullscreen-enabled ? FontAwesomeIcons.compress : FontAwesomeIcons.expand;
|
||||
tooltip: root.fullscreen-enabled ? StreamStrings.exit-fullscreen : StreamStrings.fullscreen;
|
||||
clicked => { root.toggle-fullscreen(); }
|
||||
}
|
||||
if root.control-expanded: close-button := ControlBarButton {
|
||||
x: root.control-docked-left ? 233px : 266px; y: 7px;
|
||||
x: root.control-docked-left ? 265px : 298px; y: 7px;
|
||||
icon: FontAwesomeIcons.xmark; tooltip: StreamStrings.disconnect;
|
||||
clicked => { root.disconnect(); }
|
||||
}
|
||||
|
||||
if root.control-expanded && root.control-docked-left: Rectangle {
|
||||
x: 267px; y: 12px; width: 2px; height: 15px;
|
||||
x: 299px; y: 12px; width: 2px; height: 15px;
|
||||
background: ImGuiLineStyle.control-separator;
|
||||
}
|
||||
collapse-button := ControlBarButton {
|
||||
x: root.control-expanded ? (root.control-docked-left ? 275px : 10px) : 2px;
|
||||
x: root.control-expanded ? (root.control-docked-left ? 307px : 10px) : 2px;
|
||||
y: 7px;
|
||||
width: 15px;
|
||||
icon: root.control-expanded
|
||||
@@ -733,6 +762,17 @@ export component StreamWindow inherits Window {
|
||||
clicked => { root.control-expanded = !root.control-expanded; }
|
||||
}
|
||||
|
||||
privacy-button := ControlBarButton {
|
||||
visible: root.control-expanded;
|
||||
x: root.control-docked-left ? 105px : 138px; y: 7px;
|
||||
icon: FontAwesomeIcons.user-secret;
|
||||
slashed: root.privacy-off && !root.privacy-unsupported;
|
||||
muted: root.privacy-unsupported;
|
||||
action-enabled: root.privacy-can-toggle;
|
||||
tooltip: root.privacy-status-text;
|
||||
clicked => { root.toggle-privacy(); }
|
||||
}
|
||||
|
||||
if root.stats-visible && root.control-expanded: stats-table := Rectangle {
|
||||
x: 14px;
|
||||
y: 38px;
|
||||
@@ -942,7 +982,7 @@ export component StreamWindow inherits Window {
|
||||
}
|
||||
|
||||
if root.shortcut-menu-open: Rectangle {
|
||||
x: control.x + (root.control-docked-left ? 41px : 74px); y: control.y + 40px; width: 150px; height: 68px;
|
||||
x: control.x + (root.control-docked-left ? 41px : 74px); y: control.y + 40px; width: 180px; height: 68px;
|
||||
background: white; border-width: 1px; border-color: ImGuiLineStyle.border; border-radius: 5px; z: 11;
|
||||
VerticalLayout {
|
||||
padding: 4px; spacing: 1px;
|
||||
@@ -961,6 +1001,22 @@ export component StreamWindow inherits Window {
|
||||
}
|
||||
}
|
||||
|
||||
if root.privacy-hovered && root.privacy-tooltip-visible: Rectangle {
|
||||
x: root.privacy-tooltip-x;
|
||||
width: root.privacy-tooltip-width;
|
||||
height: privacy-tip.preferred-height + 16px;
|
||||
y: control.y + control.height + self.height <= video.height
|
||||
? control.y + control.height : max(0px, control.y - self.height);
|
||||
background: #30343bf5; border-radius: 5px; z: 12;
|
||||
privacy-tip := Text {
|
||||
x: 8px; y: 8px; width: parent.width - 16px;
|
||||
text: root.privacy-status-text
|
||||
+ (root.privacy-can-toggle ? "\n" + (root.privacy-active
|
||||
? StreamStrings.privacy-disable : StreamStrings.privacy-enable) : "");
|
||||
color: white; font-size: ImGuiFontStyle.body; wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
// Transfer progress overlay at bottom-left.
|
||||
if root.file-transfer-visible: Rectangle {
|
||||
x: 18px; y: parent.height - min(210px, parent.height * 0.35); width: min(430px, parent.width * 0.6); height: min(190px, parent.height * 0.32);
|
||||
|
||||
@@ -54,6 +54,9 @@ inline int ApplyMainWindowStrings(
|
||||
strings.set_language(Text(localization::language[language]));
|
||||
strings.set_video_quality(Text(localization::video_quality[language]));
|
||||
strings.set_frame_rate(Text(localization::video_frame_rate[language]));
|
||||
strings.set_capture_method(Text(localization::screen_capture_method[language]));
|
||||
strings.set_capture_method_auto(
|
||||
Text(localization::screen_capture_method_auto[language]));
|
||||
strings.set_adaptation_policy(
|
||||
Text(localization::video_adaptation_policy[language]));
|
||||
strings.set_priority_frame_rate(
|
||||
@@ -71,6 +74,7 @@ inline int ApplyMainWindowStrings(
|
||||
Text(localization::self_hosted_server_config[language]));
|
||||
strings.set_autostart(Text(localization::enable_autostart[language]));
|
||||
strings.set_daemon(Text(localization::enable_daemon[language]));
|
||||
strings.set_privacy_on_connect(Text(localization::privacy_on_connect[language]));
|
||||
strings.set_file_save_path(
|
||||
Text(localization::file_transfer_save_path[language]));
|
||||
strings.set_default_desktop(Text(localization::default_desktop[language]));
|
||||
@@ -78,8 +82,6 @@ inline int ApplyMainWindowStrings(
|
||||
Text(localization::self_hosted_server_address[language]));
|
||||
strings.set_server_port(
|
||||
Text(localization::self_hosted_server_port[language]));
|
||||
strings.set_coturn_port(
|
||||
Text(localization::self_hosted_server_coturn_server_port[language]));
|
||||
strings.set_version(Text(localization::version[language]));
|
||||
strings.set_signal_connected(Text(localization::signal_connected[language]));
|
||||
strings.set_signal_disconnected(
|
||||
@@ -140,6 +142,8 @@ inline int ApplyStreamWindowStrings(
|
||||
auto& strings = window->template global<ui::StreamStrings>();
|
||||
strings.set_select_display(Text(localization::select_display[language]));
|
||||
strings.set_send_shortcut(Text(localization::send_shortcut[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]));
|
||||
strings.set_release_mouse(Text(localization::release_mouse[language]));
|
||||
strings.set_audio(Text(localization::audio_capture[language]));
|
||||
@@ -169,4 +173,4 @@ inline int ApplyStreamWindowStrings(
|
||||
} // namespace crossdesk::ui_localization
|
||||
|
||||
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -36,12 +36,12 @@ int GuiApplication::SelfHostedServerWindow() {
|
||||
ImGui::SetNextWindowPos(
|
||||
ImVec2(io.DisplaySize.x * 0.298f, io.DisplaySize.y * 0.25f));
|
||||
ImGui::SetNextWindowSize(
|
||||
ImVec2(io.DisplaySize.x * 0.407f, io.DisplaySize.y * 0.35f));
|
||||
ImVec2(io.DisplaySize.x * 0.407f, io.DisplaySize.y * 0.29f));
|
||||
} else {
|
||||
ImGui::SetNextWindowPos(
|
||||
ImVec2(io.DisplaySize.x * 0.27f, io.DisplaySize.y * 0.3f));
|
||||
ImGui::SetNextWindowSize(
|
||||
ImVec2(io.DisplaySize.x * 0.465f, io.DisplaySize.y * 0.35f));
|
||||
ImVec2(io.DisplaySize.x * 0.465f, io.DisplaySize.y * 0.29f));
|
||||
}
|
||||
|
||||
self_hosted_server_config_window_pos_reset_ = false;
|
||||
@@ -101,25 +101,6 @@ int GuiApplication::SelfHostedServerWindow() {
|
||||
IM_ARRAYSIZE(signal_server_port_self_));
|
||||
}
|
||||
|
||||
ImGui::Separator();
|
||||
|
||||
{
|
||||
ImGui::AlignTextToFramePadding();
|
||||
ImGui::Text("%s", localization::self_hosted_server_coturn_server_port
|
||||
[localization_language_index_]
|
||||
.c_str());
|
||||
ImGui::SameLine();
|
||||
if (ConfigCenter::LANGUAGE::CHINESE == localization_language_) {
|
||||
ImGui::SetCursorPosX(title_bar_button_width_ * 2.5f);
|
||||
} else {
|
||||
ImGui::SetCursorPosX(title_bar_button_width_ * 3.43f);
|
||||
}
|
||||
ImGui::SetNextItemWidth(title_bar_button_width_ * 3.8f);
|
||||
|
||||
ImGui::InputText("##coturn_server_port_self_", coturn_server_port_self_,
|
||||
IM_ARRAYSIZE(coturn_server_port_self_));
|
||||
}
|
||||
|
||||
if (stream_window_inited_) {
|
||||
ImGui::EndDisabled();
|
||||
}
|
||||
@@ -141,17 +122,12 @@ int GuiApplication::SelfHostedServerWindow() {
|
||||
|
||||
config_center_->SetServerHost(signal_server_ip_self_);
|
||||
config_center_->SetServerPort(atoi(signal_server_port_self_));
|
||||
config_center_->SetCoturnServerPort(atoi(coturn_server_port_self_));
|
||||
strncpy(signal_server_ip_, signal_server_ip_self_,
|
||||
sizeof(signal_server_ip_) - 1);
|
||||
signal_server_ip_[sizeof(signal_server_ip_) - 1] = '\0';
|
||||
strncpy(signal_server_port_, signal_server_port_self_,
|
||||
sizeof(signal_server_port_) - 1);
|
||||
signal_server_port_[sizeof(signal_server_port_) - 1] = '\0';
|
||||
strncpy(coturn_server_port_, coturn_server_port_self_,
|
||||
sizeof(coturn_server_port_) - 1);
|
||||
coturn_server_port_[sizeof(coturn_server_port_) - 1] = '\0';
|
||||
|
||||
self_hosted_server_config_window_pos_reset_ = true;
|
||||
}
|
||||
|
||||
@@ -173,14 +149,6 @@ int GuiApplication::SelfHostedServerWindow() {
|
||||
} else {
|
||||
signal_server_port_self_[0] = '\0';
|
||||
}
|
||||
int coturn_port = config_center_->GetCoturnServerPort();
|
||||
if (coturn_port > 0) {
|
||||
strncpy(coturn_server_port_self_, std::to_string(coturn_port).c_str(),
|
||||
sizeof(coturn_server_port_self_) - 1);
|
||||
coturn_server_port_self_[sizeof(coturn_server_port_self_) - 1] = '\0';
|
||||
} else {
|
||||
coturn_server_port_self_[0] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::SetWindowFontScale(1.0f);
|
||||
|
||||
@@ -122,6 +122,11 @@ struct OpenGlFunctions {
|
||||
PFNGLUNIFORM4FPROC uniform_4f = nullptr;
|
||||
PFNGLUSEPROGRAMPROC use_program = nullptr;
|
||||
PFNGLVERTEXATTRIBPOINTERPROC vertex_attrib_pointer = nullptr;
|
||||
PFNGLGENFRAMEBUFFERSPROC gen_framebuffers = nullptr;
|
||||
PFNGLDELETEFRAMEBUFFERSPROC delete_framebuffers = nullptr;
|
||||
PFNGLBINDFRAMEBUFFERPROC bind_framebuffer = nullptr;
|
||||
PFNGLFRAMEBUFFERTEXTURE2DPROC framebuffer_texture_2d = nullptr;
|
||||
PFNGLCHECKFRAMEBUFFERSTATUSPROC check_framebuffer_status = nullptr;
|
||||
#if CROSSDESK_OPENGL_CUDA_INTEROP
|
||||
PFNGLFENCESYNCPROC fence_sync = nullptr;
|
||||
PFNGLCLIENTWAITSYNCPROC client_wait_sync = nullptr;
|
||||
@@ -163,6 +168,15 @@ struct OpenGlFunctions {
|
||||
LoadOpenGlFunction(&vertex_attrib_pointer, "glVertexAttribPointer");
|
||||
}
|
||||
|
||||
bool LoadFramebufferFunctions() {
|
||||
return LoadOpenGlFunction(&gen_framebuffers, "glGenFramebuffers") &&
|
||||
LoadOpenGlFunction(&delete_framebuffers, "glDeleteFramebuffers") &&
|
||||
LoadOpenGlFunction(&bind_framebuffer, "glBindFramebuffer") &&
|
||||
LoadOpenGlFunction(&framebuffer_texture_2d, "glFramebufferTexture2D") &&
|
||||
LoadOpenGlFunction(&check_framebuffer_status,
|
||||
"glCheckFramebufferStatus");
|
||||
}
|
||||
|
||||
#if CROSSDESK_OPENGL_CUDA_INTEROP
|
||||
bool LoadSyncFunctions() {
|
||||
return LoadOpenGlFunction(&fence_sync, "glFenceSync") &&
|
||||
@@ -287,6 +301,17 @@ struct OpenGlVideoRenderer::Impl {
|
||||
GLint video_rect_location = -1;
|
||||
GLint corner_radius_location = -1;
|
||||
GLint video_enabled_location = -1;
|
||||
GLint source_size_location = -1;
|
||||
GLint filter_direction_location = -1;
|
||||
GLint packed_video_location = -1;
|
||||
GLuint scale_framebuffer = 0;
|
||||
std::array<GLuint, 2> scale_textures{};
|
||||
std::array<std::pair<int, int>, 2> scale_sizes{};
|
||||
uint64_t scaled_sequence = 0;
|
||||
bool scaling_available = false;
|
||||
GLenum framebuffer_target = GL_FRAMEBUFFER;
|
||||
GLenum framebuffer_binding = GL_FRAMEBUFFER_BINDING;
|
||||
std::array<int, 4> logged_render_size{};
|
||||
int texture_width = 0;
|
||||
int texture_height = 0;
|
||||
int cpu_texture_width = 0;
|
||||
@@ -504,6 +529,116 @@ struct OpenGlVideoRenderer::Impl {
|
||||
}
|
||||
#endif
|
||||
|
||||
// The vertex buffer and program are shared with the final underlay draw.
|
||||
void DrawVideo(GLuint y, GLuint uv, int source_width, int source_height,
|
||||
int target_width, int target_height, int video_x, int video_y,
|
||||
int video_width, int video_height, int corner_radius,
|
||||
bool enabled, bool packed, float filter_x = 0.0f,
|
||||
float filter_y = 0.0f) {
|
||||
gl.active_texture(GL_TEXTURE0);
|
||||
glBindTexture(GL_TEXTURE_2D, y);
|
||||
gl.active_texture(GL_TEXTURE1);
|
||||
glBindTexture(GL_TEXTURE_2D, uv);
|
||||
gl.uniform_2f(source_size_location, static_cast<GLfloat>(source_width),
|
||||
static_cast<GLfloat>(source_height));
|
||||
gl.uniform_2f(target_size_location, static_cast<GLfloat>(target_width),
|
||||
static_cast<GLfloat>(target_height));
|
||||
gl.uniform_4f(video_rect_location, static_cast<GLfloat>(video_x),
|
||||
static_cast<GLfloat>(video_y), static_cast<GLfloat>(video_width),
|
||||
static_cast<GLfloat>(video_height));
|
||||
gl.uniform_1f(corner_radius_location, static_cast<GLfloat>(corner_radius));
|
||||
gl.uniform_1f(video_enabled_location, enabled ? 1.0f : 0.0f);
|
||||
gl.uniform_1f(packed_video_location, packed ? 1.0f : 0.0f);
|
||||
gl.uniform_2f(filter_direction_location, filter_x, filter_y);
|
||||
glViewport(0, 0, target_width, target_height);
|
||||
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
|
||||
}
|
||||
|
||||
bool PrepareScaleTarget(size_t index, int width, int height) {
|
||||
gl.active_texture(GL_TEXTURE0);
|
||||
glBindTexture(GL_TEXTURE_2D, scale_textures[index]);
|
||||
if (scale_sizes[index] != std::pair{width, height}) {
|
||||
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA,
|
||||
GL_UNSIGNED_BYTE, nullptr);
|
||||
scale_sizes[index] = {width, height};
|
||||
}
|
||||
gl.framebuffer_texture_2d(framebuffer_target, GL_COLOR_ATTACHMENT0,
|
||||
GL_TEXTURE_2D, scale_textures[index], 0);
|
||||
return gl.check_framebuffer_status(framebuffer_target) ==
|
||||
GL_FRAMEBUFFER_COMPLETE &&
|
||||
glGetError() == GL_NO_ERROR;
|
||||
}
|
||||
|
||||
bool ScaleVideo(GLuint y, GLuint uv, int width, int height, int output_width,
|
||||
int output_height) {
|
||||
if (!scaling_available) {
|
||||
return false;
|
||||
}
|
||||
if (scaled_sequence == uploaded_sequence &&
|
||||
scale_sizes[1] == std::pair{output_width, output_height}) {
|
||||
return true;
|
||||
}
|
||||
|
||||
GLint previous_framebuffer = 0;
|
||||
glGetIntegerv(framebuffer_binding, &previous_framebuffer);
|
||||
if (scale_framebuffer == 0) {
|
||||
gl.gen_framebuffers(1, &scale_framebuffer);
|
||||
glGenTextures(static_cast<GLsizei>(scale_textures.size()),
|
||||
scale_textures.data());
|
||||
gl.active_texture(GL_TEXTURE0);
|
||||
for (const GLuint texture : scale_textures) {
|
||||
glBindTexture(GL_TEXTURE_2D, texture);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
|
||||
}
|
||||
}
|
||||
// A CUDA upload may have left its PBO bound: nullptr below allocates empty
|
||||
// render targets and must never be interpreted as a PBO byte offset.
|
||||
if (pixel_unpack_buffer_available) {
|
||||
gl.bind_buffer(GL_PIXEL_UNPACK_BUFFER, 0);
|
||||
}
|
||||
gl.bind_framebuffer(framebuffer_target, scale_framebuffer);
|
||||
bool success = scale_framebuffer != 0 && scale_textures[0] != 0 &&
|
||||
scale_textures[1] != 0;
|
||||
bool packed = false;
|
||||
while (success && (width != output_width || height != output_height)) {
|
||||
// Bound shader work without truncating the filter for very small windows.
|
||||
const int next_width = std::max(output_width, (width + 3) / 4);
|
||||
const int next_height = std::max(output_height, (height + 3) / 4);
|
||||
if (!PrepareScaleTarget(0, next_width, height)) {
|
||||
success = false;
|
||||
break;
|
||||
}
|
||||
DrawVideo(y, uv, width, height, next_width, height, 0, 0, next_width,
|
||||
height, 0, true, packed, 1.0f, 0.0f);
|
||||
if (!PrepareScaleTarget(1, next_width, next_height)) {
|
||||
success = false;
|
||||
break;
|
||||
}
|
||||
DrawVideo(scale_textures[0], uv, next_width, height, next_width,
|
||||
next_height, 0, 0, next_width, next_height, 0, true, true,
|
||||
0.0f, 1.0f);
|
||||
success = glGetError() == GL_NO_ERROR;
|
||||
y = scale_textures[1];
|
||||
width = next_width;
|
||||
height = next_height;
|
||||
packed = true;
|
||||
}
|
||||
gl.bind_framebuffer(framebuffer_target,
|
||||
static_cast<GLuint>(previous_framebuffer));
|
||||
if (!success) {
|
||||
scaling_available = false;
|
||||
while (glGetError() != GL_NO_ERROR) {
|
||||
}
|
||||
LOG_WARN("OpenGL video downsampling unavailable; using linear sampling");
|
||||
return false;
|
||||
}
|
||||
scaled_sequence = uploaded_sequence;
|
||||
return true;
|
||||
}
|
||||
|
||||
void ResetGlHandles() {
|
||||
program = 0;
|
||||
vertex_buffer = 0;
|
||||
@@ -517,6 +652,15 @@ struct OpenGlVideoRenderer::Impl {
|
||||
video_rect_location = -1;
|
||||
corner_radius_location = -1;
|
||||
video_enabled_location = -1;
|
||||
source_size_location = -1;
|
||||
filter_direction_location = -1;
|
||||
packed_video_location = -1;
|
||||
scale_framebuffer = 0;
|
||||
scale_textures = {};
|
||||
scale_sizes = {};
|
||||
scaled_sequence = 0;
|
||||
scaling_available = false;
|
||||
logged_render_size = {};
|
||||
texture_width = 0;
|
||||
texture_height = 0;
|
||||
cpu_texture_width = 0;
|
||||
@@ -559,6 +703,10 @@ bool OpenGlVideoRenderer::Setup() {
|
||||
HasOpenGlExtension(extensions, "GL_ARB_pixel_buffer_object")));
|
||||
impl_->unpack_subimage_available = !is_gles || major >= 3 ||
|
||||
HasOpenGlExtension(extensions, "GL_EXT_unpack_subimage");
|
||||
impl_->scaling_available = impl_->gl.LoadFramebufferFunctions();
|
||||
impl_->framebuffer_target = major >= 3 ? GL_DRAW_FRAMEBUFFER : GL_FRAMEBUFFER;
|
||||
impl_->framebuffer_binding =
|
||||
major >= 3 ? GL_DRAW_FRAMEBUFFER_BINDING : GL_FRAMEBUFFER_BINDING;
|
||||
#if CROSSDESK_OPENGL_CUDA_INTEROP
|
||||
// GLX may return entry points even if the current GLES 2 context cannot
|
||||
// use them. Gate CUDA interop on context capabilities as well as symbols.
|
||||
@@ -577,8 +725,8 @@ bool OpenGlVideoRenderer::Setup() {
|
||||
}
|
||||
#endif
|
||||
|
||||
static constexpr char kGlesVertexShader[] = R"glsl(
|
||||
#version 100
|
||||
const std::string vertex_source =
|
||||
std::string(is_gles ? "#version 100\n" : "#version 110\n") + R"glsl(
|
||||
attribute vec2 position;
|
||||
attribute vec2 texcoord;
|
||||
varying vec2 video_texcoord;
|
||||
@@ -587,20 +735,56 @@ void main() {
|
||||
gl_Position = vec4(position, 0.0, 1.0);
|
||||
}
|
||||
)glsl";
|
||||
static constexpr char kGlesFragmentShader[] = R"glsl(
|
||||
#version 100
|
||||
#ifdef GL_FRAGMENT_PRECISION_HIGH
|
||||
precision highp float;
|
||||
#else
|
||||
precision mediump float;
|
||||
#endif
|
||||
const std::string fragment_source =
|
||||
std::string(is_gles ? "#version 100\n"
|
||||
"#ifdef GL_FRAGMENT_PRECISION_HIGH\n"
|
||||
"precision highp float;\n"
|
||||
"#else\nprecision mediump float;\n#endif\n"
|
||||
: "#version 110\n") + R"glsl(
|
||||
uniform sampler2D y_texture;
|
||||
uniform sampler2D uv_texture;
|
||||
uniform vec2 target_size;
|
||||
uniform vec2 source_size;
|
||||
uniform vec2 filter_direction;
|
||||
uniform float packed_video;
|
||||
uniform vec4 video_rect;
|
||||
uniform float corner_radius;
|
||||
uniform float video_enabled;
|
||||
varying vec2 video_texcoord;
|
||||
// A separable Lanczos-2 low-pass filter. Each pass reduces by at most 4x,
|
||||
// so 16 taps cover its complete support even for non-integer scale ratios.
|
||||
float lanczos2(float x) {
|
||||
x = abs(x);
|
||||
if (x < 0.0001) return 1.0;
|
||||
if (x >= 2.0) return 0.0;
|
||||
float p = 3.14159265359 * x;
|
||||
return sin(p) * sin(p * 0.5) / (p * p * 0.5);
|
||||
}
|
||||
vec4 sample_plane(sampler2D plane, vec2 size, vec2 coord) {
|
||||
// FBO textures have the opposite vertical origin to the decoded NV12 planes.
|
||||
if (packed_video > 0.5) coord.y = 1.0 - coord.y;
|
||||
if (filter_direction.x + filter_direction.y < 0.5)
|
||||
return texture2D(plane, coord);
|
||||
float scale = max(1.0, dot(size / video_rect.zw, filter_direction));
|
||||
float center = dot(coord * size - vec2(0.5), filter_direction);
|
||||
float first = floor(center - 2.0 * scale) + 1.0;
|
||||
vec4 sum = vec4(0.0);
|
||||
float weight_sum = 0.0;
|
||||
for (int tap = 0; tap < 16; ++tap) {
|
||||
float offset = first + float(tap) - center;
|
||||
if (offset >= 2.0 * scale) break;
|
||||
float weight = lanczos2(offset / scale);
|
||||
sum += weight * texture2D(plane, coord + filter_direction * offset / size);
|
||||
weight_sum += weight;
|
||||
}
|
||||
return sum / weight_sum;
|
||||
}
|
||||
vec3 sample_video(vec2 coord) {
|
||||
vec4 y_sample = sample_plane(y_texture, source_size, coord);
|
||||
if (packed_video > 0.5) return y_sample.rgb;
|
||||
return vec3(y_sample.r,
|
||||
sample_plane(uv_texture, source_size * 0.5, coord).ra);
|
||||
}
|
||||
void main() {
|
||||
vec2 surface_point = video_texcoord * target_size;
|
||||
vec3 rgb = vec3(0.0);
|
||||
@@ -611,10 +795,14 @@ void main() {
|
||||
surface_point.y <= video_rect.y + video_rect.w) {
|
||||
vec2 frame_texcoord =
|
||||
(surface_point - video_rect.xy) / video_rect.zw;
|
||||
float y = 1.16438356 *
|
||||
(texture2D(y_texture, frame_texcoord).r - 16.0 / 255.0);
|
||||
vec2 uv =
|
||||
texture2D(uv_texture, frame_texcoord).ra - vec2(0.5, 0.5);
|
||||
vec3 yuv = sample_video(frame_texcoord);
|
||||
if (filter_direction.x + filter_direction.y > 0.5) {
|
||||
// Intermediate RGBA textures carry Y/U/V until the final conversion.
|
||||
gl_FragColor = vec4(yuv, 1.0);
|
||||
return;
|
||||
}
|
||||
float y = 1.16438356 * (yuv.x - 16.0 / 255.0);
|
||||
vec2 uv = yuv.yz - vec2(0.5);
|
||||
rgb = clamp(vec3(y + 1.59602678 * uv.y,
|
||||
y - 0.39176229 * uv.x - 0.81296764 * uv.y,
|
||||
y + 2.01723214 * uv.x),
|
||||
@@ -633,67 +821,11 @@ void main() {
|
||||
gl_FragColor = vec4(rgb * coverage, coverage);
|
||||
}
|
||||
)glsl";
|
||||
static constexpr char kDesktopVertexShader[] = R"glsl(
|
||||
#version 110
|
||||
attribute vec2 position;
|
||||
attribute vec2 texcoord;
|
||||
varying vec2 video_texcoord;
|
||||
void main() {
|
||||
video_texcoord = texcoord;
|
||||
gl_Position = vec4(position, 0.0, 1.0);
|
||||
}
|
||||
)glsl";
|
||||
static constexpr char kDesktopFragmentShader[] = R"glsl(
|
||||
#version 110
|
||||
uniform sampler2D y_texture;
|
||||
uniform sampler2D uv_texture;
|
||||
uniform vec2 target_size;
|
||||
uniform vec4 video_rect;
|
||||
uniform float corner_radius;
|
||||
uniform float video_enabled;
|
||||
varying vec2 video_texcoord;
|
||||
void main() {
|
||||
vec2 surface_point = video_texcoord * target_size;
|
||||
vec3 rgb = vec3(0.0);
|
||||
if (video_enabled > 0.5 &&
|
||||
surface_point.x >= video_rect.x &&
|
||||
surface_point.x <= video_rect.x + video_rect.z &&
|
||||
surface_point.y >= video_rect.y &&
|
||||
surface_point.y <= video_rect.y + video_rect.w) {
|
||||
vec2 frame_texcoord =
|
||||
(surface_point - video_rect.xy) / video_rect.zw;
|
||||
float y = 1.16438356 *
|
||||
(texture2D(y_texture, frame_texcoord).r - 16.0 / 255.0);
|
||||
vec2 uv =
|
||||
texture2D(uv_texture, frame_texcoord).ra - vec2(0.5, 0.5);
|
||||
rgb = clamp(vec3(y + 1.59602678 * uv.y,
|
||||
y - 0.39176229 * uv.x - 0.81296764 * uv.y,
|
||||
y + 2.01723214 * uv.x),
|
||||
0.0, 1.0);
|
||||
}
|
||||
float coverage = 1.0;
|
||||
if (corner_radius > 0.0) {
|
||||
vec2 half_size = target_size * 0.5;
|
||||
vec2 corner = abs(surface_point - half_size) -
|
||||
(half_size - vec2(corner_radius));
|
||||
float distance_to_edge =
|
||||
length(max(corner, vec2(0.0))) +
|
||||
min(max(corner.x, corner.y), 0.0) - corner_radius;
|
||||
coverage = clamp(0.5 - distance_to_edge, 0.0, 1.0);
|
||||
}
|
||||
gl_FragColor = vec4(rgb * coverage, coverage);
|
||||
}
|
||||
)glsl";
|
||||
|
||||
const char *vertex_source =
|
||||
is_gles ? kGlesVertexShader : kDesktopVertexShader;
|
||||
const char *fragment_source =
|
||||
is_gles ? kGlesFragmentShader : kDesktopFragmentShader;
|
||||
|
||||
const GLuint vertex_shader =
|
||||
CompileShader(impl_->gl, GL_VERTEX_SHADER, vertex_source);
|
||||
CompileShader(impl_->gl, GL_VERTEX_SHADER, vertex_source.c_str());
|
||||
const GLuint fragment_shader =
|
||||
CompileShader(impl_->gl, GL_FRAGMENT_SHADER, fragment_source);
|
||||
CompileShader(impl_->gl, GL_FRAGMENT_SHADER, fragment_source.c_str());
|
||||
if (vertex_shader == 0 || fragment_shader == 0) {
|
||||
if (vertex_shader != 0)
|
||||
impl_->gl.delete_shader(vertex_shader);
|
||||
@@ -743,11 +875,18 @@ void main() {
|
||||
impl_->gl.get_uniform_location(impl_->program, "corner_radius");
|
||||
impl_->video_enabled_location =
|
||||
impl_->gl.get_uniform_location(impl_->program, "video_enabled");
|
||||
impl_->source_size_location =
|
||||
impl_->gl.get_uniform_location(impl_->program, "source_size");
|
||||
impl_->filter_direction_location =
|
||||
impl_->gl.get_uniform_location(impl_->program, "filter_direction");
|
||||
impl_->packed_video_location =
|
||||
impl_->gl.get_uniform_location(impl_->program, "packed_video");
|
||||
if (impl_->position_location < 0 || impl_->texcoord_location < 0 ||
|
||||
impl_->y_texture_location < 0 || impl_->uv_texture_location < 0 ||
|
||||
impl_->target_size_location < 0 || impl_->video_rect_location < 0 ||
|
||||
impl_->corner_radius_location < 0 ||
|
||||
impl_->video_enabled_location < 0) {
|
||||
impl_->video_enabled_location < 0 || impl_->source_size_location < 0 ||
|
||||
impl_->filter_direction_location < 0 || impl_->packed_video_location < 0) {
|
||||
LOG_ERROR("OpenGL NV12 program is missing required shader bindings");
|
||||
impl_->gl.delete_program(impl_->program);
|
||||
impl_->ResetGlHandles();
|
||||
@@ -817,6 +956,11 @@ void main() {
|
||||
|
||||
void OpenGlVideoRenderer::Teardown() {
|
||||
impl_->ready.store(false, std::memory_order_release);
|
||||
if (impl_->scale_framebuffer != 0) {
|
||||
impl_->gl.delete_framebuffers(1, &impl_->scale_framebuffer);
|
||||
}
|
||||
glDeleteTextures(static_cast<GLsizei>(impl_->scale_textures.size()),
|
||||
impl_->scale_textures.data());
|
||||
#if CROSSDESK_OPENGL_CUDA_INTEROP
|
||||
impl_->ReleaseCudaInterop();
|
||||
#endif
|
||||
@@ -1356,24 +1500,8 @@ OpenGlVideoRenderer::RenderLatest(std::string_view remote_id,
|
||||
}
|
||||
#endif
|
||||
impl_->gl.use_program(impl_->program);
|
||||
impl_->gl.active_texture(GL_TEXTURE0);
|
||||
glBindTexture(GL_TEXTURE_2D, displayed_y_texture);
|
||||
impl_->gl.uniform_1i(impl_->y_texture_location, 0);
|
||||
impl_->gl.active_texture(GL_TEXTURE1);
|
||||
glBindTexture(GL_TEXTURE_2D, displayed_uv_texture);
|
||||
impl_->gl.uniform_1i(impl_->uv_texture_location, 1);
|
||||
impl_->gl.uniform_2f(impl_->target_size_location,
|
||||
static_cast<GLfloat>(target_width),
|
||||
static_cast<GLfloat>(target_height));
|
||||
impl_->gl.uniform_4f(
|
||||
impl_->video_rect_location, static_cast<GLfloat>(video_x),
|
||||
static_cast<GLfloat>(video_y), static_cast<GLfloat>(video_width),
|
||||
static_cast<GLfloat>(video_height));
|
||||
impl_->gl.uniform_1f(
|
||||
impl_->corner_radius_location,
|
||||
static_cast<GLfloat>(std::clamp(
|
||||
corner_radius_pixels, 0, std::min(target_width, target_height) / 2)));
|
||||
impl_->gl.uniform_1f(impl_->video_enabled_location, has_video ? 1.0f : 0.0f);
|
||||
impl_->gl.bind_buffer(GL_ARRAY_BUFFER, impl_->vertex_buffer);
|
||||
impl_->gl.vertex_attrib_pointer(
|
||||
static_cast<GLuint>(impl_->position_location), 2, GL_FLOAT, GL_FALSE,
|
||||
@@ -1386,7 +1514,25 @@ OpenGlVideoRenderer::RenderLatest(std::string_view remote_id,
|
||||
static_cast<GLuint>(impl_->position_location));
|
||||
impl_->gl.enable_vertex_attrib_array(
|
||||
static_cast<GLuint>(impl_->texcoord_location));
|
||||
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
|
||||
const bool downscaled = has_video &&
|
||||
(source_width > video_width || source_height > video_height) &&
|
||||
impl_->ScaleVideo(displayed_y_texture, displayed_uv_texture, source_width,
|
||||
source_height, video_width, video_height);
|
||||
impl_->DrawVideo(
|
||||
downscaled ? impl_->scale_textures[1] : displayed_y_texture,
|
||||
displayed_uv_texture, downscaled ? video_width : source_width,
|
||||
downscaled ? video_height : source_height, target_width, target_height,
|
||||
video_x, video_y, video_width, video_height,
|
||||
std::clamp(corner_radius_pixels, 0, std::min(target_width, target_height) / 2),
|
||||
has_video, downscaled);
|
||||
const std::array<int, 4> render_size{
|
||||
source_width, source_height, video_width, video_height};
|
||||
if (has_video && impl_->logged_render_size != render_size) {
|
||||
LOG_INFO("OpenGL video presentation: source={}x{}, display={}x{} physical "
|
||||
"pixels, filter={}", source_width, source_height, video_width,
|
||||
video_height, downscaled ? "Lanczos2" : "linear");
|
||||
impl_->logged_render_size = render_size;
|
||||
}
|
||||
#if CROSSDESK_OPENGL_CUDA_INTEROP
|
||||
if (impl_->displayed_cuda_upload_slot < impl_->cuda_upload_slots.size()) {
|
||||
auto &cuda_slot =
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
namespace crossdesk {
|
||||
|
||||
void GuiRuntime::ResetRemoteServiceStatus(RemoteSession& props) {
|
||||
{
|
||||
std::lock_guard lock(props.privacy_status_mutex_);
|
||||
props.privacy_status_ = {};
|
||||
props.privacy_status_received_ = false;
|
||||
props.privacy_command_pending_ = false;
|
||||
props.privacy_status_tick_ = 0;
|
||||
}
|
||||
props.remote_service_status_received_ = false;
|
||||
props.remote_service_available_ = false;
|
||||
props.remote_interactive_stage_.clear();
|
||||
|
||||
@@ -3,269 +3,240 @@
|
||||
#include <pulse/error.h>
|
||||
#include <pulse/introspect.h>
|
||||
|
||||
#include <condition_variable>
|
||||
#include <iostream>
|
||||
#include <thread>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
#include "rd_log.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
constexpr int kSampleRate = 48000;
|
||||
constexpr pa_sample_format_t kFormat = PA_SAMPLE_S16LE;
|
||||
constexpr int kChannels = 1;
|
||||
namespace {
|
||||
constexpr size_t kFrameSizeBytes = 480 * sizeof(int16_t);
|
||||
constexpr auto kStartupTimeout = std::chrono::seconds(2);
|
||||
constexpr auto kPollInterval = std::chrono::milliseconds(5);
|
||||
|
||||
SpeakerCapturerLinux::SpeakerCapturerLinux()
|
||||
: inited_(false), paused_(false), stop_flag_(false) {}
|
||||
SpeakerCapturerLinux::~SpeakerCapturerLinux() {
|
||||
Stop();
|
||||
Destroy();
|
||||
void ReleaseOperation(pa_operation* operation) {
|
||||
if (!operation) return;
|
||||
if (pa_operation_get_state(operation) == PA_OPERATION_RUNNING)
|
||||
pa_operation_cancel(operation);
|
||||
pa_operation_unref(operation);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
SpeakerCapturerLinux::~SpeakerCapturerLinux() { Destroy(); }
|
||||
|
||||
int SpeakerCapturerLinux::Init(speaker_data_cb cb) {
|
||||
if (inited_) return 0;
|
||||
cb_ = cb;
|
||||
cb_ = std::move(cb);
|
||||
inited_ = true;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int SpeakerCapturerLinux::Destroy() {
|
||||
inited_ = false;
|
||||
return 0;
|
||||
// Use nonblocking iterations instead of pa_threaded_mainloop_wait(): a server
|
||||
// that never replies, disappears, or never reaches STREAM_READY must not leave
|
||||
// startup/Stop waiting for a signal that will never be delivered.
|
||||
bool SpeakerCapturerLinux::PumpUntil(
|
||||
const std::function<bool()>& ready,
|
||||
std::chrono::steady_clock::time_point deadline) {
|
||||
while (!stop_flag_) {
|
||||
if (ready()) return true;
|
||||
if (std::chrono::steady_clock::now() >= deadline ||
|
||||
!PA_CONTEXT_IS_GOOD(pa_context_get_state(context_)))
|
||||
return false;
|
||||
if (pa_mainloop_iterate(mainloop_, 0, nullptr) < 0) return false;
|
||||
std::this_thread::sleep_for(kPollInterval);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string SpeakerCapturerLinux::GetDefaultMonitorSourceName() {
|
||||
std::string monitor_name;
|
||||
std::mutex mtx;
|
||||
std::condition_variable cv;
|
||||
bool ready = false;
|
||||
bool SpeakerCapturerLinux::Prepare() {
|
||||
const auto deadline = std::chrono::steady_clock::now() + kStartupTimeout;
|
||||
mainloop_ = pa_mainloop_new();
|
||||
if (!mainloop_) return false;
|
||||
context_ =
|
||||
pa_context_new(pa_mainloop_get_api(mainloop_), "CrossDesk system audio");
|
||||
if (!context_ ||
|
||||
pa_context_connect(context_, nullptr, PA_CONTEXT_NOFLAGS, nullptr) < 0)
|
||||
return false;
|
||||
if (!PumpUntil(
|
||||
[&] { return pa_context_get_state(context_) == PA_CONTEXT_READY; },
|
||||
deadline))
|
||||
return false;
|
||||
|
||||
pa_mainloop* mainloop = pa_mainloop_new();
|
||||
pa_mainloop_api* api = pa_mainloop_get_api(mainloop);
|
||||
pa_context* context = pa_context_new(api, "GetMonitor");
|
||||
|
||||
struct CallbackState {
|
||||
std::string* name;
|
||||
std::mutex* mtx;
|
||||
std::condition_variable* cv;
|
||||
bool* ready;
|
||||
} state{&monitor_name, &mtx, &cv, &ready};
|
||||
|
||||
pa_context_set_state_callback(
|
||||
context,
|
||||
[](pa_context* c, void* userdata) {
|
||||
auto* state = static_cast<CallbackState*>(userdata);
|
||||
if (pa_context_get_state(c) == PA_CONTEXT_READY) {
|
||||
pa_operation* operation = pa_context_get_server_info(
|
||||
c,
|
||||
[](pa_context*, const pa_server_info* info, void* userdata) {
|
||||
auto* state = static_cast<CallbackState*>(userdata);
|
||||
if (info && info->default_sink_name) {
|
||||
*(state->name) =
|
||||
std::string(info->default_sink_name) + ".monitor";
|
||||
}
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(*(state->mtx));
|
||||
*(state->ready) = true;
|
||||
}
|
||||
state->cv->notify_one();
|
||||
},
|
||||
userdata);
|
||||
if (operation) {
|
||||
pa_operation_unref(operation);
|
||||
}
|
||||
}
|
||||
struct ServerResult {
|
||||
bool done = false;
|
||||
std::string sink;
|
||||
} server;
|
||||
pa_operation* operation = pa_context_get_server_info(
|
||||
context_,
|
||||
[](pa_context*, const pa_server_info* info, void* data) {
|
||||
auto& result = *static_cast<ServerResult*>(data);
|
||||
if (info && info->default_sink_name)
|
||||
result.sink = info->default_sink_name;
|
||||
result.done = true;
|
||||
},
|
||||
&state);
|
||||
&server);
|
||||
const bool got_server =
|
||||
operation && PumpUntil([&] { return server.done; }, deadline);
|
||||
ReleaseOperation(operation);
|
||||
if (!got_server || server.sink.empty()) return false;
|
||||
|
||||
pa_context_connect(context, nullptr, PA_CONTEXT_NOFLAGS, nullptr);
|
||||
// Query the actual monitor name; a changed/default device need not follow
|
||||
// the "<sink>.monitor" naming convention.
|
||||
struct SinkResult {
|
||||
bool done = false;
|
||||
std::string monitor;
|
||||
} sink;
|
||||
operation = pa_context_get_sink_info_by_name(
|
||||
context_, server.sink.c_str(),
|
||||
[](pa_context*, const pa_sink_info* info, int eol, void* data) {
|
||||
auto& result = *static_cast<SinkResult*>(data);
|
||||
if (info && info->monitor_source_name)
|
||||
result.monitor = info->monitor_source_name;
|
||||
if (eol != 0) result.done = true;
|
||||
},
|
||||
&sink);
|
||||
const bool got_sink =
|
||||
operation && PumpUntil([&] { return sink.done; }, deadline);
|
||||
ReleaseOperation(operation);
|
||||
if (!got_sink || sink.monitor.empty()) return false;
|
||||
|
||||
std::thread loop_thread([&]() {
|
||||
int ret = 0;
|
||||
pa_mainloop_run(mainloop, &ret);
|
||||
});
|
||||
const pa_sample_spec spec{PA_SAMPLE_S16LE, 48000, 1};
|
||||
stream_ = pa_stream_new(context_, "System audio", &spec, nullptr);
|
||||
if (!stream_) return false;
|
||||
pa_stream_set_read_callback(
|
||||
stream_,
|
||||
[](pa_stream* stream, size_t, void* data) {
|
||||
static_cast<SpeakerCapturerLinux*>(data)->ReadAudio(stream);
|
||||
},
|
||||
this);
|
||||
pa_buffer_attr attr{};
|
||||
attr.maxlength = static_cast<uint32_t>(-1);
|
||||
attr.fragsize = static_cast<uint32_t>(kFrameSizeBytes);
|
||||
if (pa_stream_connect_record(stream_, sink.monitor.c_str(), &attr,
|
||||
PA_STREAM_ADJUST_LATENCY) < 0)
|
||||
return false;
|
||||
if (!PumpUntil(
|
||||
[&] {
|
||||
const auto state = pa_stream_get_state(stream_);
|
||||
return state == PA_STREAM_READY || !PA_STREAM_IS_GOOD(state);
|
||||
},
|
||||
deadline) ||
|
||||
pa_stream_get_state(stream_) != PA_STREAM_READY)
|
||||
return false;
|
||||
LOG_INFO("System audio monitor ready: {}", sink.monitor);
|
||||
return true;
|
||||
}
|
||||
|
||||
void SpeakerCapturerLinux::Run() {
|
||||
const bool ready = Prepare();
|
||||
running_ = ready && !stop_flag_;
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(mtx);
|
||||
cv.wait_for(lock, std::chrono::seconds(2), [&] { return ready; });
|
||||
std::lock_guard<std::mutex> lock(state_mtx_);
|
||||
startup_success_ = running_;
|
||||
startup_done_ = true;
|
||||
}
|
||||
|
||||
pa_context_disconnect(context);
|
||||
pa_context_unref(context);
|
||||
pa_mainloop_quit(mainloop, 0);
|
||||
loop_thread.join();
|
||||
pa_mainloop_free(mainloop);
|
||||
|
||||
return monitor_name;
|
||||
startup_wake_.notify_one();
|
||||
if (!ready && !stop_flag_) {
|
||||
LOG_ERROR(
|
||||
"PulseAudio capture startup failed or timed out: {}",
|
||||
context_ ? pa_strerror(pa_context_errno(context_)) : "no context");
|
||||
}
|
||||
while (running_ && !stop_flag_) {
|
||||
if (pa_mainloop_iterate(mainloop_, 0, nullptr) < 0 ||
|
||||
pa_context_get_state(context_) != PA_CONTEXT_READY ||
|
||||
pa_stream_get_state(stream_) != PA_STREAM_READY)
|
||||
break;
|
||||
std::this_thread::sleep_for(kPollInterval);
|
||||
}
|
||||
running_ = false;
|
||||
Cleanup();
|
||||
}
|
||||
|
||||
int SpeakerCapturerLinux::Start() {
|
||||
if (!inited_ || mainloop_thread_.joinable()) return -1;
|
||||
if (!inited_) return -1;
|
||||
if (IsRunning()) return 0;
|
||||
Stop();
|
||||
stop_flag_ = false;
|
||||
|
||||
mainloop_thread_ = std::thread([this]() {
|
||||
std::string monitor_name = GetDefaultMonitorSourceName();
|
||||
if (monitor_name.empty()) {
|
||||
LOG_ERROR("Failed to get monitor source");
|
||||
return;
|
||||
}
|
||||
|
||||
mainloop_ = pa_threaded_mainloop_new();
|
||||
pa_mainloop_api* api = pa_threaded_mainloop_get_api(mainloop_);
|
||||
context_ = pa_context_new(api, "SpeakerCapturer");
|
||||
|
||||
pa_context_set_state_callback(
|
||||
context_,
|
||||
[](pa_context* c, void* userdata) {
|
||||
auto self = static_cast<SpeakerCapturerLinux*>(userdata);
|
||||
pa_context_state_t state = pa_context_get_state(c);
|
||||
if (state == PA_CONTEXT_READY || state == PA_CONTEXT_FAILED ||
|
||||
state == PA_CONTEXT_TERMINATED) {
|
||||
pa_threaded_mainloop_signal(self->mainloop_, 0);
|
||||
}
|
||||
},
|
||||
this);
|
||||
|
||||
if (pa_threaded_mainloop_start(mainloop_) < 0) {
|
||||
LOG_ERROR("Failed to start mainloop");
|
||||
Cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
pa_threaded_mainloop_lock(mainloop_);
|
||||
|
||||
if (pa_context_connect(context_, nullptr, PA_CONTEXT_NOFLAGS, nullptr) <
|
||||
0) {
|
||||
LOG_ERROR("Failed to connect context");
|
||||
pa_threaded_mainloop_unlock(mainloop_);
|
||||
Cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
while (true) {
|
||||
pa_context_state_t state = pa_context_get_state(context_);
|
||||
if (state == PA_CONTEXT_READY) break;
|
||||
if (!PA_CONTEXT_IS_GOOD(state) || stop_flag_) {
|
||||
pa_threaded_mainloop_unlock(mainloop_);
|
||||
Cleanup();
|
||||
return;
|
||||
}
|
||||
pa_threaded_mainloop_wait(mainloop_);
|
||||
}
|
||||
|
||||
pa_sample_spec ss = {kFormat, kSampleRate, kChannels};
|
||||
stream_ = pa_stream_new(context_, "Capture", &ss, nullptr);
|
||||
|
||||
pa_stream_set_read_callback(
|
||||
stream_,
|
||||
[](pa_stream* s, size_t len, void* u) {
|
||||
auto self = static_cast<SpeakerCapturerLinux*>(u);
|
||||
if (self->paused_ || self->stop_flag_) return;
|
||||
|
||||
const void* data = nullptr;
|
||||
if (pa_stream_peek(s, &data, &len) < 0 || !data) return;
|
||||
|
||||
const uint8_t* p = static_cast<const uint8_t*>(data);
|
||||
self->frame_cache_.insert(self->frame_cache_.end(), p, p + len);
|
||||
|
||||
while (self->frame_cache_.size() >= kFrameSizeBytes) {
|
||||
std::vector<uint8_t> temp_frame(
|
||||
self->frame_cache_.begin(),
|
||||
self->frame_cache_.begin() + kFrameSizeBytes);
|
||||
self->cb_(temp_frame.data(), kFrameSizeBytes, "audio");
|
||||
self->frame_cache_.erase(
|
||||
self->frame_cache_.begin(),
|
||||
self->frame_cache_.begin() + kFrameSizeBytes);
|
||||
}
|
||||
|
||||
pa_stream_drop(s);
|
||||
},
|
||||
this);
|
||||
|
||||
pa_buffer_attr attr = {.maxlength = (uint32_t)-1,
|
||||
.tlength = 0,
|
||||
.prebuf = 0,
|
||||
.minreq = 0,
|
||||
.fragsize = (uint32_t)kFrameSizeBytes};
|
||||
|
||||
if (pa_stream_connect_record(stream_, monitor_name.c_str(), &attr,
|
||||
PA_STREAM_ADJUST_LATENCY) < 0) {
|
||||
LOG_ERROR("Failed to connect stream");
|
||||
pa_threaded_mainloop_unlock(mainloop_);
|
||||
Cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
while (true) {
|
||||
pa_stream_state_t s = pa_stream_get_state(stream_);
|
||||
if (s == PA_STREAM_READY) break;
|
||||
if (!PA_STREAM_IS_GOOD(s) || stop_flag_) {
|
||||
pa_threaded_mainloop_unlock(mainloop_);
|
||||
Cleanup();
|
||||
return;
|
||||
}
|
||||
pa_threaded_mainloop_wait(mainloop_);
|
||||
}
|
||||
|
||||
pa_threaded_mainloop_unlock(mainloop_);
|
||||
|
||||
while (!stop_flag_)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
});
|
||||
|
||||
paused_ = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state_mtx_);
|
||||
startup_done_ = false;
|
||||
startup_success_ = false;
|
||||
}
|
||||
mainloop_thread_ = std::thread([this] { Run(); });
|
||||
std::unique_lock<std::mutex> lock(state_mtx_);
|
||||
const bool started =
|
||||
startup_wake_.wait_for(lock, kStartupTimeout + std::chrono::seconds(1),
|
||||
[&] { return startup_done_; }) &&
|
||||
startup_success_;
|
||||
lock.unlock();
|
||||
if (!started) {
|
||||
Stop();
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int SpeakerCapturerLinux::Stop() {
|
||||
stop_flag_ = true;
|
||||
|
||||
if (mainloop_) {
|
||||
pa_threaded_mainloop_lock(mainloop_);
|
||||
pa_threaded_mainloop_signal(mainloop_, 0);
|
||||
pa_threaded_mainloop_unlock(mainloop_);
|
||||
}
|
||||
|
||||
if (mainloop_thread_.joinable()) {
|
||||
mainloop_thread_.join();
|
||||
}
|
||||
|
||||
Cleanup();
|
||||
if (mainloop_thread_.joinable()) mainloop_thread_.join();
|
||||
running_ = false;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int SpeakerCapturerLinux::Destroy() {
|
||||
Stop();
|
||||
cb_ = nullptr;
|
||||
inited_ = false;
|
||||
return 0;
|
||||
}
|
||||
|
||||
void SpeakerCapturerLinux::ReadAudio(pa_stream* stream) {
|
||||
const void* data = nullptr;
|
||||
size_t size = 0;
|
||||
if (pa_stream_peek(stream, &data, &size) < 0 || size == 0) return;
|
||||
if (data && !paused_ && !stop_flag_ && cb_) {
|
||||
const auto* bytes = static_cast<const uint8_t*>(data);
|
||||
frame_cache_.insert(frame_cache_.end(), bytes, bytes + size);
|
||||
size_t consumed = 0;
|
||||
while (!stop_flag_ && frame_cache_.size() - consumed >= kFrameSizeBytes) {
|
||||
cb_(frame_cache_.data() + consumed, kFrameSizeBytes, "audio");
|
||||
consumed += kFrameSizeBytes;
|
||||
}
|
||||
frame_cache_.erase(frame_cache_.begin(), frame_cache_.begin() + consumed);
|
||||
} else {
|
||||
frame_cache_.clear();
|
||||
}
|
||||
// Drop holes and paused data too, otherwise the read queue cannot advance.
|
||||
pa_stream_drop(stream);
|
||||
}
|
||||
|
||||
void SpeakerCapturerLinux::Cleanup() {
|
||||
if (stream_) {
|
||||
pa_stream_set_read_callback(stream_, nullptr, nullptr);
|
||||
pa_stream_disconnect(stream_);
|
||||
pa_stream_unref(stream_);
|
||||
stream_ = nullptr;
|
||||
}
|
||||
if (context_) {
|
||||
pa_context_disconnect(context_);
|
||||
pa_context_unref(context_);
|
||||
context_ = nullptr;
|
||||
}
|
||||
if (mainloop_) {
|
||||
pa_threaded_mainloop_stop(mainloop_);
|
||||
pa_threaded_mainloop_lock(mainloop_);
|
||||
|
||||
if (stream_) {
|
||||
pa_stream_disconnect(stream_);
|
||||
pa_stream_unref(stream_);
|
||||
stream_ = nullptr;
|
||||
}
|
||||
|
||||
if (context_) {
|
||||
pa_context_disconnect(context_);
|
||||
pa_context_unref(context_);
|
||||
context_ = nullptr;
|
||||
}
|
||||
|
||||
pa_threaded_mainloop_unlock(mainloop_);
|
||||
pa_threaded_mainloop_free(mainloop_);
|
||||
pa_mainloop_free(mainloop_);
|
||||
mainloop_ = nullptr;
|
||||
}
|
||||
|
||||
frame_cache_.clear();
|
||||
}
|
||||
|
||||
bool SpeakerCapturerLinux::IsRunning() const { return running_; }
|
||||
int SpeakerCapturerLinux::Pause() {
|
||||
paused_ = true;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int SpeakerCapturerLinux::Resume() {
|
||||
paused_ = false;
|
||||
return 0;
|
||||
}
|
||||
} // namespace crossdesk
|
||||
} // namespace crossdesk
|
||||
|
||||
@@ -4,53 +4,59 @@
|
||||
* Copyright (c) 2025 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _SPEAKER_CAPTURER_LINUX_H_
|
||||
#define _SPEAKER_CAPTURER_LINUX_H_
|
||||
#ifndef CROSSDESK_SPEAKER_CAPTURER_LINUX_H_
|
||||
#define CROSSDESK_SPEAKER_CAPTURER_LINUX_H_
|
||||
|
||||
#include <pulse/pulseaudio.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include "speaker_capturer.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
class SpeakerCapturerLinux : public SpeakerCapturer {
|
||||
public:
|
||||
SpeakerCapturerLinux();
|
||||
~SpeakerCapturerLinux();
|
||||
|
||||
SpeakerCapturerLinux() = default;
|
||||
~SpeakerCapturerLinux() override;
|
||||
int Init(speaker_data_cb cb) override;
|
||||
int Destroy() override;
|
||||
int Start() override;
|
||||
int Stop() override;
|
||||
|
||||
bool IsRunning() const override;
|
||||
int Pause();
|
||||
int Resume();
|
||||
|
||||
private:
|
||||
std::string GetDefaultMonitorSourceName();
|
||||
void Run();
|
||||
bool Prepare();
|
||||
bool PumpUntil(const std::function<bool()>& ready,
|
||||
std::chrono::steady_clock::time_point deadline);
|
||||
void Cleanup();
|
||||
void ReadAudio(pa_stream* stream);
|
||||
|
||||
private:
|
||||
speaker_data_cb cb_ = nullptr;
|
||||
|
||||
std::atomic<bool> inited_;
|
||||
std::atomic<bool> paused_;
|
||||
std::atomic<bool> stop_flag_;
|
||||
|
||||
speaker_data_cb cb_;
|
||||
bool inited_ = false;
|
||||
std::atomic<bool> paused_{false};
|
||||
std::atomic<bool> stop_flag_{false};
|
||||
std::atomic<bool> running_{false};
|
||||
std::thread mainloop_thread_;
|
||||
pa_threaded_mainloop* mainloop_ = nullptr;
|
||||
std::mutex state_mtx_;
|
||||
std::condition_variable startup_wake_;
|
||||
bool startup_done_ = false;
|
||||
bool startup_success_ = false;
|
||||
|
||||
// These objects are created, driven and freed exclusively by Run(). Stop
|
||||
// only changes an atomic flag and joins, never races PulseAudio callbacks.
|
||||
pa_mainloop* mainloop_ = nullptr;
|
||||
pa_context* context_ = nullptr;
|
||||
pa_stream* stream_ = nullptr;
|
||||
|
||||
std::mutex state_mtx_;
|
||||
std::vector<uint8_t> frame_cache_;
|
||||
};
|
||||
} // namespace crossdesk
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -17,6 +17,9 @@ namespace crossdesk {
|
||||
|
||||
struct MacTrayImpl;
|
||||
|
||||
// Activates the app and focuses the restored Slint window's current NSView.
|
||||
void MacActivateWindow(void* appkit_view);
|
||||
|
||||
class MacTray {
|
||||
public:
|
||||
MacTray(::SDL_Window* app_window, const std::string& tooltip,
|
||||
|
||||
@@ -57,6 +57,8 @@ struct MacTrayImpl {
|
||||
} else if (app_window) {
|
||||
SDL_HideWindow(app_window);
|
||||
}
|
||||
// Keep running through the menu-bar tray without a Dock application icon.
|
||||
[NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory];
|
||||
}
|
||||
|
||||
void RemoveTrayIcon() {
|
||||
@@ -69,6 +71,8 @@ struct MacTrayImpl {
|
||||
}
|
||||
|
||||
void ShowWindow() {
|
||||
// Restore normal application behavior before showing or focusing a window.
|
||||
[NSApp setActivationPolicy:NSApplicationActivationPolicyRegular];
|
||||
if (show_window) {
|
||||
show_window();
|
||||
} else if (app_window) {
|
||||
@@ -142,6 +146,7 @@ struct MacTrayImpl {
|
||||
}
|
||||
|
||||
void OpenSettings() {
|
||||
[NSApp setActivationPolicy:NSApplicationActivationPolicyRegular];
|
||||
if (open_settings) {
|
||||
open_settings();
|
||||
} else {
|
||||
@@ -279,6 +284,13 @@ MacTray::MacTray(std::function<void()> show_window,
|
||||
|
||||
MacTray::~MacTray() = default;
|
||||
|
||||
void MacActivateWindow(void *appkit_view) {
|
||||
NSView *view = (__bridge NSView *)appkit_view;
|
||||
NSWindow *window = [view window];
|
||||
[NSApp activateIgnoringOtherApps:YES];
|
||||
[window makeKeyAndOrderFront:nil];
|
||||
}
|
||||
|
||||
void MacTray::MinimizeToTray() { impl_->MinimizeToTray(); }
|
||||
|
||||
void MacTray::RemoveTrayIcon() { impl_->RemoveTrayIcon(); }
|
||||
|
||||
@@ -17,21 +17,19 @@ namespace crossdesk {
|
||||
class SpeakerCapturerMacosx : public SpeakerCapturer {
|
||||
public:
|
||||
SpeakerCapturerMacosx();
|
||||
~SpeakerCapturerMacosx();
|
||||
~SpeakerCapturerMacosx() override;
|
||||
|
||||
public:
|
||||
virtual int Init(speaker_data_cb cb);
|
||||
virtual int Destroy();
|
||||
virtual int Start();
|
||||
virtual int Stop();
|
||||
int Init(speaker_data_cb cb) override;
|
||||
int Destroy() override;
|
||||
int Start() override;
|
||||
int Stop() override;
|
||||
bool IsRunning() const override;
|
||||
|
||||
int Pause();
|
||||
int Resume();
|
||||
|
||||
public:
|
||||
speaker_data_cb cb_ = nullptr;
|
||||
bool inited_ = false;
|
||||
|
||||
private:
|
||||
class Impl;
|
||||
Impl* impl_ = nullptr;
|
||||
};
|
||||
|
||||
@@ -2,47 +2,98 @@
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <ScreenCaptureKit/ScreenCaptureKit.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <utility>
|
||||
|
||||
#include "rd_log.h"
|
||||
#include "speaker_capturer_macosx.h"
|
||||
|
||||
namespace crossdesk {
|
||||
class SpeakerCapturerMacosx;
|
||||
namespace {
|
||||
constexpr auto kCaptureTimeout = std::chrono::seconds(2);
|
||||
|
||||
std::string NSErrorToString(NSError* error) {
|
||||
if (!error) return "";
|
||||
NSString* message = [NSString
|
||||
stringWithFormat:@"%@ (%@:%ld), reason=%@", error.localizedDescription, error.domain,
|
||||
(long)error.code, error.localizedFailureReason ?: @""];
|
||||
return message.UTF8String ?: "";
|
||||
}
|
||||
|
||||
namespace {
|
||||
std::string NSErrorToString(NSError* error) {
|
||||
if (!error) {
|
||||
return "";
|
||||
// Late system replies own only their result, never the capturer or stack data.
|
||||
// Complete() also tells a late successful start to stop its abandoned stream.
|
||||
template <typename T>
|
||||
class CaptureReply {
|
||||
public:
|
||||
bool Complete(T value) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (expired_) return false;
|
||||
value_ = std::move(value);
|
||||
ready_ = true;
|
||||
wake_.notify_one();
|
||||
return true;
|
||||
}
|
||||
bool Wait(T& value) {
|
||||
std::unique_lock<std::mutex> lock(mutex_);
|
||||
if (!wake_.wait_for(lock, kCaptureTimeout, [&] { return ready_; })) {
|
||||
expired_ = true;
|
||||
return false;
|
||||
}
|
||||
value = value_;
|
||||
return true;
|
||||
}
|
||||
|
||||
const char* description = [error.localizedDescription UTF8String];
|
||||
return description ? description : "";
|
||||
}
|
||||
private:
|
||||
std::mutex mutex_;
|
||||
std::condition_variable wake_;
|
||||
bool ready_ = false;
|
||||
bool expired_ = false;
|
||||
T value_{};
|
||||
};
|
||||
|
||||
struct ContentReply {
|
||||
SCShareableContent* content = nil;
|
||||
NSError* error = nil;
|
||||
};
|
||||
struct StreamReply {
|
||||
NSError* error = nil;
|
||||
};
|
||||
struct AudioCallbackState {
|
||||
std::mutex mutex;
|
||||
crossdesk::SpeakerCapturer::speaker_data_cb callback;
|
||||
std::atomic<bool> running{false};
|
||||
};
|
||||
} // namespace
|
||||
|
||||
@interface SpeakerCaptureDelegate : NSObject <SCStreamDelegate, SCStreamOutput>
|
||||
@property(nonatomic, assign) crossdesk::SpeakerCapturerMacosx* owner;
|
||||
- (instancetype)initWithOwner:(crossdesk::SpeakerCapturerMacosx*)owner;
|
||||
@interface SpeakerCaptureDelegate : NSObject <SCStreamDelegate, SCStreamOutput> {
|
||||
std::shared_ptr<AudioCallbackState> state_;
|
||||
}
|
||||
- (instancetype)initWithState:(std::shared_ptr<AudioCallbackState>)state;
|
||||
@end
|
||||
|
||||
@implementation SpeakerCaptureDelegate
|
||||
- (instancetype)initWithOwner:(crossdesk::SpeakerCapturerMacosx*)owner {
|
||||
- (instancetype)initWithState:(std::shared_ptr<AudioCallbackState>)state {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_owner = owner;
|
||||
}
|
||||
if (self) state_ = std::move(state);
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)stream:(SCStream*)stream didStopWithError:(NSError*)error {
|
||||
state_->running = false;
|
||||
SPDLOG_LOGGER_WARN(crossdesk::get_logger(), "System audio stream stopped: {}",
|
||||
NSErrorToString(error));
|
||||
}
|
||||
|
||||
- (void)stream:(SCStream*)stream
|
||||
didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer
|
||||
ofType:(SCStreamOutputType)type {
|
||||
if (type != SCStreamOutputTypeAudio) return;
|
||||
|
||||
crossdesk::SpeakerCapturerMacosx* owner = _owner;
|
||||
if (!owner || !owner->cb_) {
|
||||
return;
|
||||
}
|
||||
const auto state = state_;
|
||||
std::lock_guard<std::mutex> lock(state->mutex);
|
||||
if (!state->callback || !state->running) return;
|
||||
|
||||
CMBlockBufferRef blockBuffer = CMSampleBufferGetDataBuffer(sampleBuffer);
|
||||
if (!blockBuffer) {
|
||||
@@ -51,8 +102,7 @@ std::string NSErrorToString(NSError* error) {
|
||||
|
||||
size_t length = CMBlockBufferGetDataLength(blockBuffer);
|
||||
char* dataPtr = NULL;
|
||||
OSStatus dataStatus =
|
||||
CMBlockBufferGetDataPointer(blockBuffer, 0, NULL, NULL, &dataPtr);
|
||||
OSStatus dataStatus = CMBlockBufferGetDataPointer(blockBuffer, 0, NULL, NULL, &dataPtr);
|
||||
if (dataStatus != noErr || dataPtr == nullptr || length == 0) {
|
||||
return;
|
||||
}
|
||||
@@ -68,7 +118,7 @@ std::string NSErrorToString(NSError* error) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (owner->cb_) {
|
||||
if (state->callback) {
|
||||
std::vector<short> out_pcm16;
|
||||
if (asbd->mFormatFlags & kAudioFormatFlagIsFloat) {
|
||||
int channels = asbd->mChannelsPerFrame;
|
||||
@@ -118,191 +168,156 @@ std::string NSErrorToString(NSError* error) {
|
||||
size_t total_bytes = out_pcm16.size() * sizeof(short);
|
||||
unsigned char* p = (unsigned char*)out_pcm16.data();
|
||||
for (size_t offset = 0; offset + frame_bytes <= total_bytes; offset += frame_bytes) {
|
||||
if (!owner->cb_) {
|
||||
if (!state->callback) {
|
||||
return;
|
||||
}
|
||||
owner->cb_(p + offset, frame_bytes, "audio");
|
||||
state->callback(p + offset, frame_bytes, "audio");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
class SpeakerCapturerMacosx::Impl {
|
||||
public:
|
||||
SCStreamConfiguration* config = nil;
|
||||
speaker_data_cb callback;
|
||||
SCStream* stream = nil;
|
||||
SpeakerCaptureDelegate* delegate = nil;
|
||||
dispatch_queue_t queue = nil;
|
||||
SCShareableContent* content = nil;
|
||||
SCDisplay* mainDisplay = nil;
|
||||
|
||||
~Impl() {
|
||||
if (stream) {
|
||||
[stream stopCaptureWithCompletionHandler:^(NSError* _Nullable error){
|
||||
}];
|
||||
stream = nil;
|
||||
}
|
||||
delegate = nil;
|
||||
if (queue) {
|
||||
queue = nil;
|
||||
}
|
||||
content = nil;
|
||||
mainDisplay = nil;
|
||||
config = nil;
|
||||
}
|
||||
std::shared_ptr<AudioCallbackState> state;
|
||||
};
|
||||
|
||||
SpeakerCapturerMacosx::SpeakerCapturerMacosx() {
|
||||
impl_ = new Impl();
|
||||
inited_ = false;
|
||||
cb_ = nullptr;
|
||||
}
|
||||
|
||||
SpeakerCapturerMacosx::SpeakerCapturerMacosx() : impl_(new Impl) {}
|
||||
SpeakerCapturerMacosx::~SpeakerCapturerMacosx() {
|
||||
Destroy();
|
||||
delete impl_;
|
||||
impl_ = nullptr;
|
||||
}
|
||||
|
||||
int SpeakerCapturerMacosx::Init(speaker_data_cb cb) {
|
||||
if (inited_) {
|
||||
return 0;
|
||||
}
|
||||
cb_ = cb;
|
||||
|
||||
impl_->config = [[SCStreamConfiguration alloc] init];
|
||||
impl_->config.capturesAudio = YES;
|
||||
impl_->config.sampleRate = 48000;
|
||||
impl_->config.channelCount = 1;
|
||||
|
||||
dispatch_semaphore_t sema = dispatch_semaphore_create(0);
|
||||
__block NSError* error = nil;
|
||||
[SCShareableContent
|
||||
getShareableContentWithCompletionHandler:^(SCShareableContent* c, NSError* e) {
|
||||
impl_->content = c;
|
||||
error = e;
|
||||
dispatch_semaphore_signal(sema);
|
||||
}];
|
||||
dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER);
|
||||
|
||||
if (error || !impl_->content) {
|
||||
LOG_ERROR("Failed to get shareable content: {}",
|
||||
NSErrorToString(error));
|
||||
return -1;
|
||||
}
|
||||
|
||||
CGDirectDisplayID mainDisplayId = CGMainDisplayID();
|
||||
impl_->mainDisplay = nil;
|
||||
for (SCDisplay* d in impl_->content.displays) {
|
||||
if (d.displayID == mainDisplayId) {
|
||||
impl_->mainDisplay = d;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!impl_->mainDisplay) {
|
||||
LOG_ERROR("Main display not found");
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (!impl_->queue) {
|
||||
impl_->callback = std::move(cb);
|
||||
if (!impl_->queue)
|
||||
impl_->queue = dispatch_queue_create("SpeakerAudio.Queue", DISPATCH_QUEUE_SERIAL);
|
||||
}
|
||||
|
||||
inited_ = true;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int SpeakerCapturerMacosx::Start() {
|
||||
if (!inited_) {
|
||||
return -1;
|
||||
}
|
||||
@autoreleasepool {
|
||||
if (!impl_->callback) return -1;
|
||||
if (IsRunning()) return 0;
|
||||
if (Stop() != 0) return -1;
|
||||
|
||||
if (impl_->stream) {
|
||||
dispatch_semaphore_t semaStop = dispatch_semaphore_create(0);
|
||||
[impl_->stream stopCaptureWithCompletionHandler:^(NSError* error) {
|
||||
dispatch_semaphore_signal(semaStop);
|
||||
}];
|
||||
dispatch_semaphore_wait(semaStop, DISPATCH_TIME_FOREVER);
|
||||
impl_->stream = nil;
|
||||
impl_->delegate = nil;
|
||||
}
|
||||
|
||||
impl_->delegate = [[SpeakerCaptureDelegate alloc] initWithOwner:this];
|
||||
SCContentFilter* filter = [[SCContentFilter alloc] initWithDisplay:impl_->mainDisplay
|
||||
excludingWindows:@[]];
|
||||
impl_->stream = [[SCStream alloc] initWithFilter:filter
|
||||
configuration:impl_->config
|
||||
delegate:impl_->delegate];
|
||||
|
||||
NSError* addOutputError = nil;
|
||||
BOOL ok = [impl_->stream addStreamOutput:impl_->delegate
|
||||
type:SCStreamOutputTypeAudio
|
||||
sampleHandlerQueue:impl_->queue
|
||||
error:&addOutputError];
|
||||
if (!ok || addOutputError) {
|
||||
LOG_ERROR("addStreamOutput error: {}",
|
||||
NSErrorToString(addOutputError));
|
||||
impl_->stream = nil;
|
||||
impl_->delegate = nil;
|
||||
return -1;
|
||||
}
|
||||
|
||||
dispatch_semaphore_t semaStart = dispatch_semaphore_create(0);
|
||||
__block int ret = 0;
|
||||
[impl_->stream startCaptureWithCompletionHandler:^(NSError* _Nullable error) {
|
||||
if (error) {
|
||||
LOG_ERROR("startCaptureWithCompletionHandler error: {}",
|
||||
NSErrorToString(error));
|
||||
ret = -1;
|
||||
// A cached SCDisplay becomes invalid after hotplug, sleep or a display
|
||||
// reconfiguration. Obtain a fresh snapshot on every start/reconnection.
|
||||
auto content_reply = std::make_shared<CaptureReply<ContentReply>>();
|
||||
[SCShareableContent
|
||||
getShareableContentWithCompletionHandler:^(SCShareableContent* content, NSError* error) {
|
||||
content_reply->Complete({content, error});
|
||||
}];
|
||||
ContentReply content;
|
||||
if (!content_reply->Wait(content)) {
|
||||
LOG_ERROR("Timed out refreshing system audio capture displays");
|
||||
return -1;
|
||||
}
|
||||
dispatch_semaphore_signal(semaStart);
|
||||
}];
|
||||
dispatch_semaphore_wait(semaStart, DISPATCH_TIME_FOREVER);
|
||||
if (content.error || !content.content) {
|
||||
LOG_ERROR("Failed to refresh system audio capture displays: {}",
|
||||
NSErrorToString(content.error));
|
||||
return -1;
|
||||
}
|
||||
SCDisplay* display = nil;
|
||||
const auto main_id = CGMainDisplayID();
|
||||
for (SCDisplay* candidate in content.content.displays) {
|
||||
if (candidate.displayID == main_id) {
|
||||
display = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!display) display = content.content.displays.firstObject;
|
||||
if (!display) {
|
||||
LOG_ERROR("No display available for system audio capture");
|
||||
return -1;
|
||||
}
|
||||
LOG_INFO("Starting system audio capture with current display {}", display.displayID);
|
||||
|
||||
return ret;
|
||||
SCStreamConfiguration* config = [[SCStreamConfiguration alloc] init];
|
||||
config.capturesAudio = YES;
|
||||
config.sampleRate = 48000;
|
||||
config.channelCount = 1;
|
||||
SCContentFilter* filter = [[SCContentFilter alloc] initWithDisplay:display
|
||||
excludingWindows:@[]];
|
||||
impl_->state = std::make_shared<AudioCallbackState>();
|
||||
impl_->state->callback = impl_->callback;
|
||||
impl_->delegate = [[SpeakerCaptureDelegate alloc] initWithState:impl_->state];
|
||||
impl_->stream = [[SCStream alloc] initWithFilter:filter
|
||||
configuration:config
|
||||
delegate:impl_->delegate];
|
||||
NSError* output_error = nil;
|
||||
if (!impl_->stream || ![impl_->stream addStreamOutput:impl_->delegate
|
||||
type:SCStreamOutputTypeAudio
|
||||
sampleHandlerQueue:impl_->queue
|
||||
error:&output_error]) {
|
||||
LOG_ERROR("Failed to attach system audio output: {}", NSErrorToString(output_error));
|
||||
Stop();
|
||||
return -1;
|
||||
}
|
||||
|
||||
auto start_reply = std::make_shared<CaptureReply<StreamReply>>();
|
||||
SCStream* stream = impl_->stream;
|
||||
impl_->state->running = true;
|
||||
[stream startCaptureWithCompletionHandler:^(NSError* error) {
|
||||
if (!start_reply->Complete({error}) && !error) {
|
||||
[stream stopCaptureWithCompletionHandler:nil];
|
||||
}
|
||||
}];
|
||||
StreamReply started;
|
||||
if (!start_reply->Wait(started)) {
|
||||
LOG_ERROR("Timed out starting system audio capture");
|
||||
Stop();
|
||||
return -1;
|
||||
}
|
||||
if (started.error || !impl_->state->running) {
|
||||
LOG_ERROR("Failed to start system audio capture: {}", NSErrorToString(started.error));
|
||||
Stop();
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
int SpeakerCapturerMacosx::Stop() {
|
||||
if (!inited_) return -1;
|
||||
if (!impl_->stream) return -1;
|
||||
|
||||
dispatch_semaphore_t sema = dispatch_semaphore_create(0);
|
||||
[impl_->stream stopCaptureWithCompletionHandler:^(NSError* error) {
|
||||
if (error) {
|
||||
LOG_ERROR("stopCaptureWithCompletionHandler error: {}",
|
||||
NSErrorToString(error));
|
||||
@autoreleasepool {
|
||||
if (auto state = std::exchange(impl_->state, {})) {
|
||||
std::lock_guard<std::mutex> lock(state->mutex);
|
||||
state->running = false;
|
||||
state->callback = nullptr;
|
||||
}
|
||||
dispatch_semaphore_signal(sema);
|
||||
}];
|
||||
dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER);
|
||||
|
||||
impl_->stream = nil;
|
||||
impl_->delegate.owner = nullptr;
|
||||
impl_->delegate = nil;
|
||||
|
||||
return 0;
|
||||
SCStream* stream = impl_->stream;
|
||||
impl_->stream = nil;
|
||||
impl_->delegate = nil;
|
||||
if (!stream) return 0;
|
||||
auto stop_reply = std::make_shared<CaptureReply<StreamReply>>();
|
||||
[stream stopCaptureWithCompletionHandler:^(NSError* error) {
|
||||
stop_reply->Complete({error});
|
||||
}];
|
||||
StreamReply stopped;
|
||||
if (!stop_reply->Wait(stopped)) {
|
||||
LOG_ERROR("Timed out stopping system audio capture; callbacks revoked");
|
||||
return -1;
|
||||
}
|
||||
// A start failure or daemon restart can leave an already stopped stream.
|
||||
// Its references have been discarded, so future starts can recover.
|
||||
if (stopped.error) LOG_WARN("System audio stop reply: {}", NSErrorToString(stopped.error));
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
bool SpeakerCapturerMacosx::IsRunning() const { return impl_->state && impl_->state->running; }
|
||||
|
||||
int SpeakerCapturerMacosx::Destroy() {
|
||||
Stop();
|
||||
cb_ = nullptr;
|
||||
|
||||
if (impl_) {
|
||||
impl_->config = nil;
|
||||
impl_->content = nil;
|
||||
impl_->mainDisplay = nil;
|
||||
if (impl_->queue) {
|
||||
impl_->queue = nil;
|
||||
}
|
||||
}
|
||||
inited_ = false;
|
||||
return 0;
|
||||
const int result = Stop();
|
||||
impl_->callback = nullptr;
|
||||
impl_->queue = nil;
|
||||
return result;
|
||||
}
|
||||
|
||||
int SpeakerCapturerMacosx::Pause() { return 0; }
|
||||
|
||||
int SpeakerCapturerMacosx::Pause() { return Stop(); }
|
||||
int SpeakerCapturerMacosx::Resume() { return Start(); }
|
||||
} // namespace crossdesk
|
||||
|
||||
@@ -114,6 +114,10 @@ void GuiRuntime::HandleWindowsServiceIntegration() {
|
||||
|
||||
bool force_broadcast = false;
|
||||
if (pending_windows_service_sas_.exchange(false, std::memory_order_relaxed)) {
|
||||
if (privacy_.Engaged()) {
|
||||
privacy_.Fail("Queued security shortcut cancelled during privacy mode; remote operation paused");
|
||||
return;
|
||||
}
|
||||
const std::string response =
|
||||
QueryCrossDeskService("sas", kWindowsServiceSasTimeoutMs);
|
||||
auto json = nlohmann::json::parse(response, nullptr, false);
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
#include "mouse_controller.h"
|
||||
|
||||
#include <Windows.h>
|
||||
#include <remote_action.h>
|
||||
|
||||
#include <Windows.h>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
#include "../../windows_thread_dpi.h"
|
||||
#include "rd_log.h"
|
||||
#include "windows_input_marker.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
PlatformMouseController::PlatformMouseController() {}
|
||||
|
||||
PlatformMouseController::~PlatformMouseController() {}
|
||||
PlatformMouseController::~PlatformMouseController() { Destroy(); }
|
||||
|
||||
int PlatformMouseController::Init(std::vector<DisplayInfo> display_info_list) {
|
||||
display_info_list_ = display_info_list;
|
||||
@@ -18,10 +22,27 @@ int PlatformMouseController::Init(std::vector<DisplayInfo> display_info_list) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int PlatformMouseController::Destroy() { return 0; }
|
||||
int PlatformMouseController::Destroy() { return ReleasePressedButtons(); }
|
||||
|
||||
int PlatformMouseController::ReleasePressedButtons() {
|
||||
const unsigned pressed = pressed_buttons_.load();
|
||||
if (!pressed) return 0;
|
||||
INPUT input{};
|
||||
input.type = INPUT_MOUSE;
|
||||
input.mi.dwExtraInfo = kInjectedMouseInputMarker;
|
||||
if (pressed & 1) input.mi.dwFlags |= MOUSEEVENTF_LEFTUP;
|
||||
if (pressed & 2) input.mi.dwFlags |= MOUSEEVENTF_RIGHTUP;
|
||||
if (pressed & 4) input.mi.dwFlags |= MOUSEEVENTF_MIDDLEUP;
|
||||
if (SendInput(1, &input, sizeof(INPUT)) != 1) {
|
||||
LOG_WARN("Release remote mouse buttons failed, error={}", GetLastError());
|
||||
return -1;
|
||||
}
|
||||
pressed_buttons_.fetch_and(~pressed);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int PlatformMouseController::SendMouseCommand(RemoteAction remote_action,
|
||||
int display_index) {
|
||||
int display_index) {
|
||||
if (display_index < 0 ||
|
||||
display_index >= static_cast<int>(display_info_list_.size())) {
|
||||
LOG_WARN("Mouse command skipped, invalid display_index={}, displays={}",
|
||||
@@ -30,69 +51,83 @@ int PlatformMouseController::SendMouseCommand(RemoteAction remote_action,
|
||||
}
|
||||
|
||||
INPUT ip = {0};
|
||||
ScopedWindowsPhysicalCoordinates physical_coordinates;
|
||||
|
||||
if (remote_action.type == ControlType::mouse) {
|
||||
ip.type = INPUT_MOUSE;
|
||||
ip.mi.dx =
|
||||
(LONG)(remote_action.m.x * display_info_list_[display_index].width) +
|
||||
display_info_list_[display_index].left;
|
||||
ip.mi.dy =
|
||||
(LONG)(remote_action.m.y * display_info_list_[display_index].height) +
|
||||
display_info_list_[display_index].top;
|
||||
if (remote_action.type != ControlType::mouse) return 0;
|
||||
if (!std::isfinite(remote_action.m.x) || !std::isfinite(remote_action.m.y))
|
||||
return -1;
|
||||
ip.type = INPUT_MOUSE;
|
||||
ip.mi.dx = (LONG)(std::clamp(remote_action.m.x, 0.0f, 1.0f) *
|
||||
(display_info_list_[display_index].width - 1)) +
|
||||
display_info_list_[display_index].left;
|
||||
ip.mi.dy = (LONG)(std::clamp(remote_action.m.y, 0.0f, 1.0f) *
|
||||
(display_info_list_[display_index].height - 1)) +
|
||||
display_info_list_[display_index].top;
|
||||
|
||||
switch (remote_action.m.flag) {
|
||||
case MouseFlag::left_down:
|
||||
ip.mi.dwFlags = MOUSEEVENTF_LEFTDOWN | MOUSEEVENTF_ABSOLUTE;
|
||||
break;
|
||||
case MouseFlag::left_up:
|
||||
ip.mi.dwFlags = MOUSEEVENTF_LEFTUP | MOUSEEVENTF_ABSOLUTE;
|
||||
break;
|
||||
case MouseFlag::right_down:
|
||||
ip.mi.dwFlags = MOUSEEVENTF_RIGHTDOWN | MOUSEEVENTF_ABSOLUTE;
|
||||
break;
|
||||
case MouseFlag::right_up:
|
||||
ip.mi.dwFlags = MOUSEEVENTF_RIGHTUP | MOUSEEVENTF_ABSOLUTE;
|
||||
break;
|
||||
case MouseFlag::middle_down:
|
||||
ip.mi.dwFlags = MOUSEEVENTF_MIDDLEDOWN | MOUSEEVENTF_ABSOLUTE;
|
||||
break;
|
||||
case MouseFlag::middle_up:
|
||||
ip.mi.dwFlags = MOUSEEVENTF_MIDDLEUP | MOUSEEVENTF_ABSOLUTE;
|
||||
break;
|
||||
case MouseFlag::wheel_vertical:
|
||||
ip.mi.dwFlags = MOUSEEVENTF_WHEEL;
|
||||
ip.mi.mouseData = remote_action.m.s * 120;
|
||||
break;
|
||||
case MouseFlag::wheel_horizontal:
|
||||
ip.mi.dwFlags = MOUSEEVENTF_HWHEEL;
|
||||
ip.mi.mouseData = remote_action.m.s * 120;
|
||||
break;
|
||||
default:
|
||||
ip.mi.dwFlags = MOUSEEVENTF_MOVE;
|
||||
break;
|
||||
}
|
||||
|
||||
ip.mi.time = 0;
|
||||
|
||||
if (!SetCursorPos(ip.mi.dx, ip.mi.dy)) {
|
||||
LOG_WARN("SetCursorPos failed for mouse x={}, y={}, flag={}, err={}",
|
||||
ip.mi.dx, ip.mi.dy, static_cast<int>(remote_action.m.flag),
|
||||
GetLastError());
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (ip.mi.dwFlags != MOUSEEVENTF_MOVE) {
|
||||
UINT sent = SendInput(1, &ip, sizeof(INPUT));
|
||||
if (sent != 1) {
|
||||
LOG_WARN(
|
||||
"SendInput failed for mouse x={}, y={}, wheel={}, flag={}, err={}",
|
||||
ip.mi.dx, ip.mi.dy, remote_action.m.s,
|
||||
static_cast<int>(remote_action.m.flag), GetLastError());
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
switch (remote_action.m.flag) {
|
||||
case MouseFlag::left_down:
|
||||
ip.mi.dwFlags = MOUSEEVENTF_LEFTDOWN;
|
||||
break;
|
||||
case MouseFlag::left_up:
|
||||
ip.mi.dwFlags = MOUSEEVENTF_LEFTUP;
|
||||
break;
|
||||
case MouseFlag::right_down:
|
||||
ip.mi.dwFlags = MOUSEEVENTF_RIGHTDOWN;
|
||||
break;
|
||||
case MouseFlag::right_up:
|
||||
ip.mi.dwFlags = MOUSEEVENTF_RIGHTUP;
|
||||
break;
|
||||
case MouseFlag::middle_down:
|
||||
ip.mi.dwFlags = MOUSEEVENTF_MIDDLEDOWN;
|
||||
break;
|
||||
case MouseFlag::middle_up:
|
||||
ip.mi.dwFlags = MOUSEEVENTF_MIDDLEUP;
|
||||
break;
|
||||
case MouseFlag::wheel_vertical:
|
||||
ip.mi.dwFlags = MOUSEEVENTF_WHEEL;
|
||||
ip.mi.mouseData = remote_action.m.s * 120;
|
||||
break;
|
||||
case MouseFlag::wheel_horizontal:
|
||||
ip.mi.dwFlags = MOUSEEVENTF_HWHEEL;
|
||||
ip.mi.mouseData = remote_action.m.s * 120;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
const int virtual_left = GetSystemMetrics(SM_XVIRTUALSCREEN);
|
||||
const int virtual_top = GetSystemMetrics(SM_YVIRTUALSCREEN);
|
||||
const int virtual_width = GetSystemMetrics(SM_CXVIRTUALSCREEN);
|
||||
const int virtual_height = GetSystemMetrics(SM_CYVIRTUALSCREEN);
|
||||
if (virtual_width <= 0 || virtual_height <= 0) {
|
||||
LOG_WARN("Remote mouse skipped: invalid virtual desktop geometry");
|
||||
return -1;
|
||||
}
|
||||
// Pixel centres in the virtual desktop, including negative monitor origins.
|
||||
// SetCursorPos cannot carry dwExtraInfo through the privacy mouse hook.
|
||||
ip.mi.dx = static_cast<LONG>(
|
||||
(static_cast<int64_t>(ip.mi.dx - virtual_left) * 65536 + 32768) /
|
||||
virtual_width);
|
||||
ip.mi.dy = static_cast<LONG>(
|
||||
(static_cast<int64_t>(ip.mi.dy - virtual_top) * 65536 + 32768) /
|
||||
virtual_height);
|
||||
ip.mi.dwFlags |=
|
||||
MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK;
|
||||
ip.mi.dwExtraInfo = kInjectedMouseInputMarker;
|
||||
const UINT sent = SendInput(1, &ip, sizeof(INPUT));
|
||||
if (sent != 1) {
|
||||
LOG_WARN("SendInput failed for mouse x={}, y={}, wheel={}, flag={}, err={}",
|
||||
ip.mi.dx, ip.mi.dy, remote_action.m.s,
|
||||
static_cast<int>(remote_action.m.flag), GetLastError());
|
||||
return -1;
|
||||
}
|
||||
if (ip.mi.dwFlags & MOUSEEVENTF_LEFTDOWN) pressed_buttons_.fetch_or(1);
|
||||
if (ip.mi.dwFlags & MOUSEEVENTF_RIGHTDOWN) pressed_buttons_.fetch_or(2);
|
||||
if (ip.mi.dwFlags & MOUSEEVENTF_MIDDLEDOWN) pressed_buttons_.fetch_or(4);
|
||||
if (ip.mi.dwFlags & MOUSEEVENTF_LEFTUP) pressed_buttons_.fetch_and(~1u);
|
||||
if (ip.mi.dwFlags & MOUSEEVENTF_RIGHTUP) pressed_buttons_.fetch_and(~2u);
|
||||
if (ip.mi.dwFlags & MOUSEEVENTF_MIDDLEUP) pressed_buttons_.fetch_and(~4u);
|
||||
|
||||
return 0;
|
||||
}
|
||||
} // namespace crossdesk
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#define _MOUSE_CONTROLLER_H_
|
||||
|
||||
#include <vector>
|
||||
#include <atomic>
|
||||
|
||||
#include <remote_action.h>
|
||||
|
||||
@@ -25,9 +26,11 @@ class PlatformMouseController final : public MouseController {
|
||||
virtual int Init(std::vector<DisplayInfo> display_info_list);
|
||||
virtual int Destroy();
|
||||
virtual int SendMouseCommand(RemoteAction remote_action, int display_index);
|
||||
int ReleasePressedButtons();
|
||||
|
||||
private:
|
||||
std::vector<DisplayInfo> display_info_list_;
|
||||
std::atomic<unsigned> pressed_buttons_{0};
|
||||
};
|
||||
} // namespace crossdesk
|
||||
#endif
|
||||
|
||||
@@ -9,6 +9,7 @@ namespace crossdesk {
|
||||
// keyboard input so the controller hook can ignore only its own injections
|
||||
// while still accepting input from accessibility tools or remote sessions.
|
||||
inline constexpr std::uintptr_t kInjectedKeyboardInputMarker = 0x4353444B;
|
||||
inline constexpr std::uintptr_t kInjectedMouseInputMarker = 0x4353444D;
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-10
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#include <Windows.h>
|
||||
#include <dwmapi.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cwchar>
|
||||
#include <vector>
|
||||
|
||||
#include "../../windows_thread_dpi.h"
|
||||
#include "../privacy_band_ipc.h"
|
||||
#include "privacy_cover_renderer.h"
|
||||
|
||||
namespace {
|
||||
using namespace crossdesk;
|
||||
constexpr wchar_t kWindowClass[] = L"CrossDesk.HighBandPrivacy";
|
||||
constexpr DWORD kCoverStyles = WS_EX_TOPMOST | WS_EX_NOACTIVATE |
|
||||
WS_EX_TOOLWINDOW | WS_EX_LAYERED |
|
||||
WS_EX_TRANSPARENT;
|
||||
HMODULE module = nullptr;
|
||||
|
||||
bool SameRect(const RECT& a, const RECT& b) {
|
||||
return a.left == b.left && a.top == b.top && a.right == b.right &&
|
||||
a.bottom == b.bottom;
|
||||
}
|
||||
|
||||
class Windows {
|
||||
public:
|
||||
explicit Windows(PrivacyBandShared* shared) : shared_(shared) {}
|
||||
~Windows() {
|
||||
for (const auto& window : windows_)
|
||||
DestroyWindow(reinterpret_cast<HWND>(window.hwnd));
|
||||
if (atom_) UnregisterClassW(kWindowClass, module);
|
||||
}
|
||||
bool Fail(const wchar_t* operation, DWORD code = GetLastError()) {
|
||||
wcsncpy_s(shared_->operation, operation, _TRUNCATE);
|
||||
InterlockedExchange(&shared_->error, code ? code : ERROR_GEN_FAILURE);
|
||||
return false;
|
||||
}
|
||||
bool Initialize() {
|
||||
const HMODULE user = GetModuleHandleW(L"user32.dll");
|
||||
create_ = reinterpret_cast<CreatePrivacyWindowInBand>(
|
||||
GetProcAddress(user, "CreateWindowInBand"));
|
||||
get_band_ = reinterpret_cast<GetPrivacyWindowBand>(
|
||||
GetProcAddress(user, "GetWindowBand"));
|
||||
if (!create_ || !get_band_)
|
||||
return Fail(L"Resolve high-band APIs", ERROR_PROC_NOT_FOUND);
|
||||
WNDCLASSEXW cls{sizeof(cls)};
|
||||
cls.hInstance = module;
|
||||
cls.lpfnWndProc = WindowProc;
|
||||
cls.lpszClassName = kWindowClass;
|
||||
cls.hbrBackground = static_cast<HBRUSH>(GetStockObject(BLACK_BRUSH));
|
||||
atom_ = RegisterClassExW(&cls);
|
||||
return atom_ ? Reconcile() : Fail(L"Register high-band window class");
|
||||
}
|
||||
bool Reconcile() {
|
||||
std::vector<RECT> monitors;
|
||||
if (!EnumDisplayMonitors(
|
||||
nullptr, nullptr,
|
||||
[](HMONITOR, HDC, LPRECT rect, LPARAM data) -> BOOL {
|
||||
auto& result = *reinterpret_cast<std::vector<RECT>*>(data);
|
||||
if (result.size() >= kPrivacyMaxMonitors) return FALSE;
|
||||
result.push_back(*rect);
|
||||
return TRUE;
|
||||
},
|
||||
reinterpret_cast<LPARAM>(&monitors)) ||
|
||||
monitors.empty())
|
||||
return Fail(L"Enumerate high-band displays");
|
||||
bool changed = monitors.size() != windows_.size();
|
||||
for (const auto& rect : monitors) {
|
||||
const auto found =
|
||||
std::find_if(windows_.begin(), windows_.end(),
|
||||
[&](const auto& w) { return SameRect(w.rect, rect); });
|
||||
if (found != windows_.end()) continue;
|
||||
changed = true;
|
||||
HWND hwnd = create_(kCoverStyles, atom_, L"CrossDesk Privacy", WS_POPUP,
|
||||
rect.left, rect.top, rect.right - rect.left,
|
||||
rect.bottom - rect.top, nullptr, nullptr, module,
|
||||
this, kPrivacyWindowBand);
|
||||
if (!hwnd) return Fail(L"CreateWindowInBand ZBID_ABOVELOCK_UX");
|
||||
windows_.push_back({reinterpret_cast<uint64_t>(hwnd), rect});
|
||||
if (!SetLayeredWindowAttributes(hwnd, 0, 255, LWA_ALPHA))
|
||||
return Fail(L"Make high-band cover opaque");
|
||||
if (!SetWindowDisplayAffinity(hwnd, 0x11))
|
||||
return Fail(L"Exclude high-band cover from capture");
|
||||
const BOOL yes = TRUE;
|
||||
const HRESULT peek = DwmSetWindowAttribute(hwnd, DWMWA_EXCLUDED_FROM_PEEK,
|
||||
&yes, sizeof(yes));
|
||||
if (FAILED(peek)) return Fail(L"Exclude high-band cover from Peek", peek);
|
||||
if (!SetWindowPos(hwnd, nullptr, rect.left, rect.top,
|
||||
rect.right - rect.left, rect.bottom - rect.top,
|
||||
SWP_NOZORDER | SWP_NOACTIVATE | SWP_SHOWWINDOW) ||
|
||||
!RedrawWindow(hwnd, nullptr, nullptr, RDW_INVALIDATE | RDW_UPDATENOW))
|
||||
return Fail(L"Present high-band cover");
|
||||
}
|
||||
if (changed) {
|
||||
const HRESULT result = DwmFlush();
|
||||
if (FAILED(result))
|
||||
return Fail(L"Flush high-band cover presentation", result);
|
||||
// New areas are covered before old windows are removed.
|
||||
for (auto it = windows_.begin(); it != windows_.end();) {
|
||||
if (std::none_of(
|
||||
monitors.begin(), monitors.end(),
|
||||
[&](const auto& rect) { return SameRect(rect, it->rect); })) {
|
||||
if (!DestroyWindow(reinterpret_cast<HWND>(it->hwnd)))
|
||||
return Fail(L"Destroy obsolete high-band cover");
|
||||
it = windows_.erase(it);
|
||||
} else
|
||||
++it;
|
||||
}
|
||||
}
|
||||
if (!Validate()) return false;
|
||||
if (changed) {
|
||||
InterlockedIncrement(&shared_->sequence);
|
||||
shared_->count = static_cast<uint32_t>(windows_.size());
|
||||
std::copy(windows_.begin(), windows_.end(), shared_->windows);
|
||||
InterlockedIncrement(&shared_->sequence);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
bool Validate() {
|
||||
if (paint_failed_) return false;
|
||||
for (const auto& w : windows_) {
|
||||
HWND hwnd = reinterpret_cast<HWND>(w.hwnd);
|
||||
DWORD band = 0, affinity = 0, flags = 0, cloaked = 0;
|
||||
BYTE alpha = 0;
|
||||
RECT rect{};
|
||||
if ((GetWindowLongPtrW(hwnd, GWL_EXSTYLE) & kCoverStyles) !=
|
||||
kCoverStyles ||
|
||||
!get_band_(hwnd, &band) || band != kPrivacyWindowBand ||
|
||||
!GetWindowDisplayAffinity(hwnd, &affinity) || affinity != 0x11 ||
|
||||
!GetLayeredWindowAttributes(hwnd, nullptr, &alpha, &flags) ||
|
||||
alpha != 255 || flags != LWA_ALPHA || !IsWindowVisible(hwnd) ||
|
||||
!GetWindowRect(hwnd, &rect) || !SameRect(rect, w.rect) ||
|
||||
FAILED(DwmGetWindowAttribute(hwnd, DWMWA_CLOAKED, &cloaked,
|
||||
sizeof(cloaked))) ||
|
||||
cloaked)
|
||||
return Fail(L"High-band cover lost style/band/affinity/visibility",
|
||||
ERROR_INVALID_STATE);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
bool changed = false;
|
||||
|
||||
private:
|
||||
static LRESULT CALLBACK WindowProc(HWND hwnd, UINT message, WPARAM wp,
|
||||
LPARAM lp) {
|
||||
if (message == WM_NCCREATE) {
|
||||
auto create = reinterpret_cast<CREATESTRUCTW*>(lp);
|
||||
SetWindowLongPtrW(hwnd, GWLP_USERDATA,
|
||||
reinterpret_cast<LONG_PTR>(create->lpCreateParams));
|
||||
}
|
||||
auto self =
|
||||
reinterpret_cast<Windows*>(GetWindowLongPtrW(hwnd, GWLP_USERDATA));
|
||||
if (message == WM_MOUSEACTIVATE) return MA_NOACTIVATE;
|
||||
if (message == WM_NCHITTEST) return HTTRANSPARENT;
|
||||
if (message == WM_SETCURSOR) {
|
||||
SetCursor(nullptr);
|
||||
return TRUE;
|
||||
}
|
||||
if (message == WM_CLOSE) return 0;
|
||||
if ((message == WM_DISPLAYCHANGE || message == WM_DPICHANGED) && self) {
|
||||
self->changed = true;
|
||||
InvalidateRect(hwnd, nullptr, TRUE);
|
||||
return 0;
|
||||
}
|
||||
if (message == WM_PAINT) {
|
||||
PAINTSTRUCT paint{};
|
||||
HDC dc = BeginPaint(hwnd, &paint);
|
||||
RECT rect{};
|
||||
GetClientRect(hwnd, &rect);
|
||||
if (self && !DrawPrivacyCover(dc, rect, PrivacyWindowDpi(hwnd), module,
|
||||
self->shared_->unlock_hint)) {
|
||||
self->paint_failed_ = true;
|
||||
self->Fail(L"Paint privacy logo and unlock instructions");
|
||||
}
|
||||
EndPaint(hwnd, &paint);
|
||||
return 0;
|
||||
}
|
||||
return DefWindowProcW(hwnd, message, wp, lp);
|
||||
}
|
||||
PrivacyBandShared* shared_;
|
||||
bool paint_failed_ = false;
|
||||
std::vector<PrivacyBandWindow> windows_;
|
||||
ATOM atom_ = 0;
|
||||
CreatePrivacyWindowInBand create_ = nullptr;
|
||||
GetPrivacyWindowBand get_band_ = nullptr;
|
||||
};
|
||||
|
||||
DWORD WINAPI Run(void*) {
|
||||
wchar_t bootstrap[256]{};
|
||||
if (!GetEnvironmentVariableW(kPrivacyBandBootstrap, bootstrap, 256))
|
||||
return ERROR_INVALID_PARAMETER;
|
||||
SetEnvironmentVariableW(kPrivacyBandBootstrap, nullptr);
|
||||
std::array<HANDLE, 4> handles{};
|
||||
wchar_t* item = bootstrap;
|
||||
for (size_t i = 0; i < handles.size(); ++i) {
|
||||
wchar_t* end = nullptr;
|
||||
const auto value = wcstoull(item, &end, 10);
|
||||
if (!value || end == item ||
|
||||
(i + 1 == handles.size() ? *end != 0 : *end != L','))
|
||||
return ERROR_INVALID_PARAMETER;
|
||||
handles[i] = reinterpret_cast<HANDLE>(value);
|
||||
item = end + 1;
|
||||
}
|
||||
HANDLE parent = handles[0], stop = handles[1], ready = handles[2],
|
||||
mapping = handles[3];
|
||||
auto shared = static_cast<PrivacyBandShared*>(MapViewOfFile(
|
||||
mapping, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(PrivacyBandShared)));
|
||||
if (!shared) return GetLastError();
|
||||
if (shared->magic != kPrivacyBandMagic ||
|
||||
shared->version != kPrivacyBandVersion ||
|
||||
shared->size != sizeof(PrivacyBandShared) ||
|
||||
shared->broker_pid != GetCurrentProcessId() ||
|
||||
shared->parent_pid != GetProcessId(parent) ||
|
||||
WaitForSingleObject(parent, 0) != WAIT_TIMEOUT) {
|
||||
UnmapViewOfFile(shared);
|
||||
return ERROR_INVALID_DATA;
|
||||
}
|
||||
DWORD exit_code = ERROR_SUCCESS;
|
||||
{
|
||||
ScopedWindowsPhysicalCoordinates dpi;
|
||||
Windows windows(shared);
|
||||
try {
|
||||
bool healthy = dpi.active() && windows.Initialize();
|
||||
const bool initialized = healthy;
|
||||
if (!dpi.active()) windows.Fail(L"Set high-band thread DPI context");
|
||||
InterlockedExchange64(&shared->heartbeat, GetTickCount64());
|
||||
if (healthy) {
|
||||
InterlockedExchange(&shared->error, ERROR_SUCCESS);
|
||||
InterlockedExchange(&shared->active, 1);
|
||||
}
|
||||
SetEvent(ready);
|
||||
auto last_check = GetTickCount64();
|
||||
HANDLE watched[] = {parent, stop};
|
||||
// Once enabled, retain surviving covers on a validation/layout fault.
|
||||
// The parent pauses the session after reading the latched error. Only
|
||||
// an explicit stop or parent death tears the remaining windows down.
|
||||
while (initialized) {
|
||||
const DWORD wait = MsgWaitForMultipleObjectsEx(
|
||||
2, watched, 100, QS_ALLINPUT, MWMO_INPUTAVAILABLE);
|
||||
if (wait == WAIT_OBJECT_0 || wait == WAIT_OBJECT_0 + 1) break;
|
||||
if (wait == WAIT_FAILED) {
|
||||
windows.Fail(L"Wait for high-band lifecycle");
|
||||
break;
|
||||
}
|
||||
MSG message{};
|
||||
while (PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE)) {
|
||||
if (message.message == WM_QUIT) healthy = false;
|
||||
TranslateMessage(&message);
|
||||
DispatchMessageW(&message);
|
||||
}
|
||||
const auto now = GetTickCount64();
|
||||
if (healthy && (windows.changed || now - last_check >= 250)) {
|
||||
windows.changed = false;
|
||||
last_check = now;
|
||||
healthy = windows.Reconcile();
|
||||
}
|
||||
InterlockedExchange64(&shared->heartbeat, now);
|
||||
}
|
||||
} catch (...) {
|
||||
windows.Fail(L"High-band window thread exception",
|
||||
ERROR_UNHANDLED_EXCEPTION);
|
||||
SetEvent(ready);
|
||||
}
|
||||
exit_code = InterlockedCompareExchange(&shared->error, 0, 0);
|
||||
InterlockedExchange(&shared->active, 0);
|
||||
}
|
||||
UnmapViewOfFile(shared);
|
||||
for (HANDLE handle : handles) CloseHandle(handle);
|
||||
// This module runs only in the dedicated process created for this session.
|
||||
// Exit outside DllMain/loader lock, after HWND destruction on their UI
|
||||
// thread.
|
||||
ExitProcess(exit_code);
|
||||
return exit_code;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
BOOL WINAPI DllMain(HINSTANCE instance, DWORD reason, LPVOID) {
|
||||
if (reason == DLL_PROCESS_ATTACH) {
|
||||
module = instance;
|
||||
// Keep thread notifications enabled: this module uses the static CRT.
|
||||
HANDLE thread = CreateThread(nullptr, 0, Run, nullptr, 0, nullptr);
|
||||
if (!thread) return FALSE;
|
||||
CloseHandle(thread);
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-11
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#include "privacy_cover_renderer.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
|
||||
namespace crossdesk {
|
||||
namespace {
|
||||
|
||||
constexpr size_t kInstructionLines = 2;
|
||||
constexpr size_t kBrandLine = kInstructionLines;
|
||||
|
||||
struct PaintResources {
|
||||
HDC dc;
|
||||
int saved;
|
||||
HICON logo = nullptr;
|
||||
std::array<HFONT, kInstructionLines + 1> fonts{};
|
||||
~PaintResources() {
|
||||
// Deselect every owned GDI object before releasing it.
|
||||
if (saved) RestoreDC(dc, saved);
|
||||
for (auto font : fonts)
|
||||
if (font) DeleteObject(font);
|
||||
if (logo) DestroyIcon(logo);
|
||||
}
|
||||
};
|
||||
|
||||
bool Select(HDC dc, HGDIOBJ object) {
|
||||
const auto previous = SelectObject(dc, object);
|
||||
return previous && previous != HGDI_ERROR;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool DrawPrivacyCover(HDC dc, const RECT& rect, UINT dpi, HINSTANCE module,
|
||||
const wchar_t* unlock_hint) {
|
||||
if (!FillRect(dc, &rect, static_cast<HBRUSH>(GetStockObject(BLACK_BRUSH))))
|
||||
return false;
|
||||
const int width = rect.right - rect.left;
|
||||
const int height = rect.bottom - rect.top;
|
||||
if (width <= 0 || height <= 0) return false;
|
||||
// Scale with the monitor DPI, shrinking the whole group on small displays.
|
||||
// All coordinates remain in the owning window's physical-pixel context.
|
||||
const double scale = (std::min)({dpi / 96.0, width / 640.0, height / 200.0});
|
||||
const auto px = [scale](int value) {
|
||||
return (std::max)(1, static_cast<int>(value * scale + 0.5));
|
||||
};
|
||||
const int margin = px(32);
|
||||
const int text_width = px(398);
|
||||
PaintResources resources{dc, SaveDC(dc)};
|
||||
if (!resources.saved || !SetBkMode(dc, TRANSPARENT)) return false;
|
||||
|
||||
struct Line {
|
||||
const wchar_t* text;
|
||||
int size;
|
||||
int weight;
|
||||
COLORREF color;
|
||||
RECT bounds{};
|
||||
};
|
||||
std::array<Line, kInstructionLines + 1> lines{{
|
||||
{unlock_hint, 16, FW_NORMAL, RGB(181, 190, 204)},
|
||||
{L"Ctrl + Alt + Shift + F12", 24, FW_SEMIBOLD, RGB(245, 247, 250)},
|
||||
{L"CrossDesk", 14, FW_SEMIBOLD, RGB(245, 247, 250)},
|
||||
}};
|
||||
constexpr UINT flags = DT_LEFT | DT_WORDBREAK | DT_NOPREFIX;
|
||||
for (size_t i = 0; i < lines.size(); ++i) {
|
||||
auto& line = lines[i];
|
||||
resources.fonts[i] =
|
||||
CreateFontW(-px(line.size), 0, 0, 0, line.weight, FALSE, FALSE, FALSE,
|
||||
DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS,
|
||||
ANTIALIASED_QUALITY, DEFAULT_PITCH, L"Microsoft YaHei UI");
|
||||
if (!resources.fonts[i] || !Select(dc, resources.fonts[i])) return false;
|
||||
line.bounds = {0, 0, text_width, 0};
|
||||
if (!DrawTextW(dc, line.text, -1, &line.bounds, flags | DT_CALCRECT) ||
|
||||
line.bounds.right > text_width)
|
||||
return false;
|
||||
}
|
||||
const int content_height =
|
||||
static_cast<int>(lines[0].bounds.bottom + lines[1].bounds.bottom) +
|
||||
px(12);
|
||||
// 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);
|
||||
const int logo_size = brand_width;
|
||||
const int brand_height =
|
||||
logo_size + brand_gap + static_cast<int>(lines[kBrandLine].bounds.bottom);
|
||||
if (logo_size <= 0 ||
|
||||
(std::max)(brand_height, content_height) + 2 * margin > height ||
|
||||
brand_width + px(24) + text_width + 2 * margin > width)
|
||||
return false;
|
||||
resources.logo = static_cast<HICON>(LoadImageW(
|
||||
module, L"IDI_CROSSDESK_LOGO", IMAGE_ICON, logo_size, logo_size, 0));
|
||||
if (!resources.logo) return false;
|
||||
const int brand_left = rect.left + margin;
|
||||
const int left = brand_left + brand_width + px(24);
|
||||
const int brand_top = rect.bottom - margin - brand_height;
|
||||
int top = rect.bottom - margin - content_height;
|
||||
if (!DrawIconEx(dc, brand_left, brand_top, resources.logo, logo_size,
|
||||
logo_size, 0, nullptr, DI_NORMAL))
|
||||
return false;
|
||||
if (!Select(dc, resources.fonts[kBrandLine])) return false;
|
||||
RECT brand_bounds{brand_left, brand_top + logo_size + brand_gap,
|
||||
brand_left + brand_width, rect.bottom - margin};
|
||||
SetTextColor(dc, lines[kBrandLine].color);
|
||||
if (!DrawTextW(dc, lines[kBrandLine].text, -1, &brand_bounds,
|
||||
DT_CENTER | DT_SINGLELINE | DT_NOPREFIX))
|
||||
return false;
|
||||
for (size_t i = 0; i < kInstructionLines; ++i) {
|
||||
const auto& line = lines[i];
|
||||
if (!Select(dc, resources.fonts[i])) return false;
|
||||
RECT bounds{left, top, left + text_width, top + line.bounds.bottom};
|
||||
SetTextColor(dc, line.color);
|
||||
if (!DrawTextW(dc, line.text, -1, &bounds, flags)) return false;
|
||||
top += line.bounds.bottom + px(12);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-11
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _PRIVACY_COVER_RENDERER_H_
|
||||
#define _PRIVACY_COVER_RENDERER_H_
|
||||
|
||||
#include <Windows.h>
|
||||
|
||||
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);
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif // _PRIVACY_COVER_RENDERER_H_
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-11
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#include <Windows.h>
|
||||
|
||||
#include <cerrno>
|
||||
#include <cstdint>
|
||||
#include <cwchar>
|
||||
#include <limits>
|
||||
|
||||
#include "../privacy_cursor_api.h"
|
||||
|
||||
namespace {
|
||||
HANDLE ParseHandle(const wchar_t* text) {
|
||||
wchar_t* end = nullptr;
|
||||
errno = 0;
|
||||
const auto value = std::wcstoull(text, &end, 10);
|
||||
if (errno || !text[0] || *end || text[0] == L'-' || value == 0 ||
|
||||
value > (std::numeric_limits<uintptr_t>::max)())
|
||||
return nullptr;
|
||||
return reinterpret_cast<HANDLE>(static_cast<uintptr_t>(value));
|
||||
}
|
||||
DWORD FailureCode() {
|
||||
const DWORD code = GetLastError();
|
||||
return code ? code : ERROR_GEN_FAILURE;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int wmain(int argc, wchar_t** argv) {
|
||||
if (argc != 5) return ERROR_INVALID_PARAMETER;
|
||||
HANDLE parent = ParseHandle(argv[1]);
|
||||
HANDLE stop = ParseHandle(argv[2]);
|
||||
HANDLE ready = ParseHandle(argv[3]);
|
||||
HANDLE mapping = ParseHandle(argv[4]);
|
||||
// Observe the inherited process OBJECT, never a reused PID or a process name.
|
||||
if (!parent || !stop || !ready || !mapping ||
|
||||
WaitForSingleObject(parent, 0) != WAIT_TIMEOUT ||
|
||||
WaitForSingleObject(stop, 0) != WAIT_TIMEOUT)
|
||||
return ERROR_INVALID_HANDLE;
|
||||
auto shared = static_cast<crossdesk::PrivacyCursorReady*>(MapViewOfFile(
|
||||
mapping, FILE_MAP_WRITE, 0, 0, sizeof(crossdesk::PrivacyCursorReady)));
|
||||
if (!shared) return static_cast<int>(FailureCode());
|
||||
|
||||
crossdesk::PrivacyCursorApi cursor;
|
||||
DWORD error = ERROR_SUCCESS;
|
||||
if (!cursor.Initialize() || !cursor.Hide()) error = FailureCode();
|
||||
InterlockedExchange(&shared->error, static_cast<LONG>(error));
|
||||
if (!SetEvent(ready)) error = FailureCode();
|
||||
if (!error) {
|
||||
HANDLE watched[] = {parent, stop};
|
||||
for (;;) {
|
||||
const DWORD result =
|
||||
MsgWaitForMultipleObjects(2, watched, FALSE, 250, QS_ALLINPUT);
|
||||
if (result == WAIT_OBJECT_0 || result == WAIT_OBJECT_0 + 1) break;
|
||||
if (result == WAIT_OBJECT_0 + 2) {
|
||||
MSG message{};
|
||||
while (PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE)) {
|
||||
TranslateMessage(&message);
|
||||
DispatchMessageW(&message);
|
||||
}
|
||||
} else if (result != WAIT_TIMEOUT) {
|
||||
error = FailureCode();
|
||||
break;
|
||||
}
|
||||
if (!cursor.Hide()) {
|
||||
error = FailureCode();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also runs when the parent is force-terminated or crashes. The helper does
|
||||
// not own any input hooks or privacy HWNDs and never changes magnification.
|
||||
if (!cursor.RestoreAndClose()) error = FailureCode();
|
||||
UnmapViewOfFile(shared);
|
||||
CloseHandle(mapping);
|
||||
CloseHandle(ready);
|
||||
CloseHandle(stop);
|
||||
CloseHandle(parent);
|
||||
return static_cast<int>(error);
|
||||
}
|
||||
@@ -0,0 +1,713 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-11
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#include <ShlObj.h>
|
||||
#include <Windows.h>
|
||||
#include <dwmapi.h>
|
||||
#include <winternl.h>
|
||||
#include <wtsapi32.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#include "../windows_thread_dpi.h"
|
||||
#include "privacy_backend.h"
|
||||
#include "privacy_band_guard.h"
|
||||
#include "privacy_cursor_guard.h"
|
||||
#include "privacy_input_guard.h"
|
||||
#include "privacy_probe_pattern.h"
|
||||
#include "privacy_probe_renderer.h"
|
||||
#include "privacy_window_visibility.h"
|
||||
#include "rd_log.h"
|
||||
|
||||
#ifndef WDA_EXCLUDEFROMCAPTURE
|
||||
#define WDA_EXCLUDEFROMCAPTURE 0x00000011
|
||||
#endif
|
||||
|
||||
namespace crossdesk {
|
||||
namespace {
|
||||
|
||||
constexpr wchar_t kClassName[] = L"CrossDesk.PrivacyWindow";
|
||||
constexpr UINT kEmergency = WM_APP + 71;
|
||||
constexpr int kHotkeyId = 0x4353;
|
||||
|
||||
std::string Error(const char* operation, DWORD code = GetLastError()) {
|
||||
const std::string result = std::string(operation) +
|
||||
" failed (Windows error " + std::to_string(code) +
|
||||
")";
|
||||
LOG_ERROR("Privacy: {}", result);
|
||||
return result;
|
||||
}
|
||||
|
||||
bool DesktopName(HDESK desktop, wchar_t (&name)[256]) {
|
||||
DWORD needed = 0;
|
||||
return desktop && GetUserObjectInformationW(desktop, UOI_NAME, name,
|
||||
sizeof(name), &needed);
|
||||
}
|
||||
|
||||
bool SameRect(const RECT& a, const RECT& b) {
|
||||
return a.left == b.left && a.top == b.top && a.right == b.right &&
|
||||
a.bottom == b.bottom;
|
||||
}
|
||||
|
||||
struct MonitorEnumeration {
|
||||
std::vector<RECT> rects;
|
||||
bool ok = true;
|
||||
};
|
||||
|
||||
BOOL CALLBACK EnumMonitor(HMONITOR monitor, HDC, LPRECT, LPARAM data) {
|
||||
auto& result = *reinterpret_cast<MonitorEnumeration*>(data);
|
||||
MONITORINFO info{sizeof(info)};
|
||||
if (!GetMonitorInfoW(monitor, &info)) {
|
||||
Error("GetMonitorInfoW");
|
||||
result.ok = false;
|
||||
return FALSE;
|
||||
}
|
||||
result.rects.push_back(info.rcMonitor);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
class WindowsPrivacyBackend final : public PrivacyBackend {
|
||||
public:
|
||||
WindowsPrivacyBackend() {
|
||||
wake_event_ = CreateEventW(nullptr, FALSE, FALSE, nullptr);
|
||||
if (!wake_event_) wake_error_ = Error("CreateEventW privacy commands");
|
||||
}
|
||||
|
||||
~WindowsPrivacyBackend() override {
|
||||
Recover();
|
||||
if (sink_) DestroyWindow(sink_);
|
||||
if (registered_) UnregisterClassW(kClassName, GetModuleHandleW(nullptr));
|
||||
if (wake_event_) CloseHandle(wake_event_);
|
||||
}
|
||||
|
||||
PrivacyCapabilities Query() override {
|
||||
if (!wake_event_) return {false, false, wake_error_};
|
||||
using RtlGetVersionFn = LONG(WINAPI*)(PRTL_OSVERSIONINFOW);
|
||||
const auto rtl = reinterpret_cast<RtlGetVersionFn>(
|
||||
GetProcAddress(GetModuleHandleW(L"ntdll.dll"), "RtlGetVersion"));
|
||||
RTL_OSVERSIONINFOW version{};
|
||||
version.dwOSVersionInfoSize = sizeof(version);
|
||||
if (!rtl || rtl(&version) != 0 || version.dwMajorVersion < 10 ||
|
||||
version.dwBuildNumber < 19041) {
|
||||
return {false, false, "Requires Windows 10 2004 (build 19041) or later"};
|
||||
}
|
||||
BOOL composed = FALSE;
|
||||
if (FAILED(DwmIsCompositionEnabled(&composed)) || !composed) {
|
||||
return {false, false, "Desktop composition is unavailable"};
|
||||
}
|
||||
if (GetSystemMetrics(SM_REMOTESESSION)) {
|
||||
return {false, false, "Privacy screen requires a local console session"};
|
||||
}
|
||||
if (!IsWindowsPrivacyDesktopAvailable()) {
|
||||
return {false, false,
|
||||
"Privacy screen requires the unlocked Default desktop"};
|
||||
}
|
||||
QUERY_USER_NOTIFICATION_STATE shell_state{};
|
||||
if (FAILED(SHQueryUserNotificationState(&shell_state)) ||
|
||||
shell_state == QUNS_RUNNING_D3D_FULL_SCREEN) {
|
||||
return {false, false,
|
||||
"Exclusive fullscreen or unknown display presentation cannot "
|
||||
"guarantee privacy coverage"};
|
||||
}
|
||||
std::string high_band_error;
|
||||
if (!PrivacyBandGuard::Available(high_band_error))
|
||||
return {false, false, high_band_error};
|
||||
return {true, true,
|
||||
"High-band privacy; live capture exclusion will be verified"};
|
||||
}
|
||||
|
||||
bool Enable(bool block_input, const PrivacyScreenText& text,
|
||||
std::string& error) override {
|
||||
const auto caps = Query();
|
||||
if (!caps.overlay) {
|
||||
error = caps.reason;
|
||||
return false;
|
||||
}
|
||||
if (!Initialize(error)) return false;
|
||||
emergency_ = false;
|
||||
health_error_.clear();
|
||||
if (!RegisterHotKey(sink_, kHotkeyId,
|
||||
MOD_CONTROL | MOD_ALT | MOD_SHIFT | MOD_NOREPEAT,
|
||||
VK_F12)) {
|
||||
error = Error("RegisterHotKey Ctrl+Alt+Shift+F12");
|
||||
return false;
|
||||
}
|
||||
hotkey_ = true;
|
||||
if (!cursor_guard_.Start(error)) return false;
|
||||
block_input_ = block_input;
|
||||
if (block_input && !input_guard_.Start(sink_, kEmergency, error))
|
||||
return false;
|
||||
if (!band_guard_.Start(text, error)) return false;
|
||||
if (!ReconcileMonitors(error)) return false;
|
||||
event_owner_ = this;
|
||||
for (DWORD event : {EVENT_OBJECT_SHOW, EVENT_OBJECT_REORDER,
|
||||
EVENT_OBJECT_LOCATIONCHANGE}) {
|
||||
auto hook = SetWinEventHook(event, event, nullptr, WindowEvent, 0, 0,
|
||||
WINEVENT_OUTOFCONTEXT);
|
||||
if (!hook) {
|
||||
error = Error("Watch privacy window coverage changes");
|
||||
return false;
|
||||
}
|
||||
window_events_.push_back(hook);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void Recover() override {
|
||||
for (auto& hook : window_events_) {
|
||||
if (UnhookWinEvent(hook))
|
||||
hook = nullptr;
|
||||
else
|
||||
Error("Unhook privacy window events");
|
||||
}
|
||||
window_events_.erase(
|
||||
std::remove(window_events_.begin(), window_events_.end(), nullptr),
|
||||
window_events_.end());
|
||||
if (window_events_.empty()) event_owner_ = nullptr;
|
||||
// Remove verification panels before exposing the local desktop.
|
||||
for (auto& window : windows_) {
|
||||
Destroy(window.probe);
|
||||
}
|
||||
std::string band_error;
|
||||
band_guard_.Stop(band_error);
|
||||
for (auto& window : windows_) {
|
||||
if (band_guard_.stopped()) window.cover = nullptr;
|
||||
}
|
||||
windows_.erase(
|
||||
std::remove_if(windows_.begin(), windows_.end(),
|
||||
[](const auto& w) { return !w.cover && !w.probe; }),
|
||||
windows_.end());
|
||||
input_guard_.Stop();
|
||||
block_input_ = false;
|
||||
if (hotkey_) {
|
||||
if (UnregisterHotKey(sink_, kHotkeyId) ||
|
||||
GetLastError() == ERROR_HOTKEY_NOT_REGISTERED)
|
||||
hotkey_ = false;
|
||||
else
|
||||
Error("UnregisterHotKey");
|
||||
}
|
||||
// The sink lives across sessions. Do not let an already-posted emergency
|
||||
// command from a closed session cancel a subsequent connection.
|
||||
if (sink_) {
|
||||
MSG discarded{};
|
||||
while (PeekMessageW(&discarded, sink_, WM_HOTKEY, WM_HOTKEY, PM_REMOVE)) {
|
||||
}
|
||||
while (
|
||||
PeekMessageW(&discarded, sink_, kEmergency, kEmergency, PM_REMOVE)) {
|
||||
}
|
||||
}
|
||||
last_check_ = 0;
|
||||
last_health_ = {};
|
||||
last_obstruction_ = nullptr;
|
||||
last_obstruction_log_ = 0;
|
||||
layout_changed_ = false;
|
||||
coverage_dirty_ = false;
|
||||
emergency_ = false;
|
||||
health_error_.clear();
|
||||
std::string cursor_error;
|
||||
cursor_guard_.Stop(cursor_error);
|
||||
}
|
||||
|
||||
bool IsRecovered() const override {
|
||||
return band_guard_.stopped() && windows_.empty() &&
|
||||
window_events_.empty() && input_guard_.stopped() && !hotkey_ &&
|
||||
cursor_guard_.stopped();
|
||||
}
|
||||
|
||||
void Wake() override {
|
||||
if (wake_event_) SetEvent(wake_event_);
|
||||
}
|
||||
|
||||
void WaitForEvents(unsigned timeout_ms) override {
|
||||
const DWORD result = MsgWaitForMultipleObjectsEx(
|
||||
wake_event_ ? 1 : 0, wake_event_ ? &wake_event_ : nullptr, timeout_ms,
|
||||
QS_ALLINPUT, MWMO_INPUTAVAILABLE);
|
||||
if (result == WAIT_FAILED)
|
||||
health_error_ = Error("MsgWaitForMultipleObjectsEx privacy thread");
|
||||
}
|
||||
|
||||
PrivacyHealth Poll(bool force_check = false) override {
|
||||
MSG message{};
|
||||
while (PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE)) {
|
||||
if (message.message == WM_QUIT)
|
||||
health_error_ =
|
||||
"Privacy message thread is exiting; remote operation paused";
|
||||
TranslateMessage(&message);
|
||||
DispatchMessageW(&message);
|
||||
}
|
||||
if (emergency_) {
|
||||
emergency_ = false;
|
||||
return {{}, true};
|
||||
}
|
||||
if (block_input_) {
|
||||
std::string error;
|
||||
if (!input_guard_.Healthy(error)) return {error, false};
|
||||
}
|
||||
if (windows_.empty()) return {health_error_, false};
|
||||
// Input messages wake the thread immediately; expensive health queries
|
||||
// must retain their own cadence instead of running once per input event.
|
||||
if (!force_check && !layout_changed_ &&
|
||||
GetTickCount64() - last_check_ < 100) {
|
||||
if (coverage_dirty_ && last_health_.failure.empty()) {
|
||||
coverage_dirty_ = false;
|
||||
last_health_ = CheckCoverage();
|
||||
}
|
||||
return last_health_;
|
||||
}
|
||||
coverage_dirty_ = false;
|
||||
last_check_ = GetTickCount64();
|
||||
last_health_ = CheckHealth();
|
||||
return last_health_;
|
||||
}
|
||||
|
||||
private:
|
||||
PrivacyHealth CheckHealth() {
|
||||
if (!health_error_.empty()) return {health_error_, false};
|
||||
if (!band_guard_.Healthy(health_error_)) return {health_error_, false};
|
||||
if (!IsWindowsPrivacyDesktopAvailable()) {
|
||||
std::string cursor_error;
|
||||
cursor_guard_.Stop(cursor_error);
|
||||
return {
|
||||
"Secure desktop, lock screen or unavailable input desktop; remote "
|
||||
"operation paused. Privacy windows cannot cover system security UI.",
|
||||
false};
|
||||
}
|
||||
std::string cursor_error;
|
||||
if (!cursor_guard_.Healthy(cursor_error)) return {cursor_error, false};
|
||||
QUERY_USER_NOTIFICATION_STATE shell_state{};
|
||||
if (FAILED(SHQueryUserNotificationState(&shell_state)) ||
|
||||
shell_state == QUNS_RUNNING_D3D_FULL_SCREEN) {
|
||||
return {
|
||||
"Exclusive fullscreen or unknown display presentation; remote "
|
||||
"operation paused",
|
||||
false};
|
||||
}
|
||||
MonitorEnumeration monitors;
|
||||
if (!EnumDisplayMonitors(nullptr, nullptr, EnumMonitor,
|
||||
reinterpret_cast<LPARAM>(&monitors)) ||
|
||||
!monitors.ok) {
|
||||
return {Error("EnumDisplayMonitors"), false};
|
||||
}
|
||||
const bool changed =
|
||||
monitors.rects.size() != windows_.size() ||
|
||||
std::any_of(monitors.rects.begin(), monitors.rects.end(),
|
||||
[this](const RECT& rect) {
|
||||
return std::none_of(windows_.begin(), windows_.end(),
|
||||
[&](const auto& w) {
|
||||
return SameRect(w.rect, rect);
|
||||
});
|
||||
});
|
||||
if (changed || layout_changed_) {
|
||||
layout_changed_ = false;
|
||||
// Retain old covers until new monitor regions are covered. Never
|
||||
// automatically resume remote work after a topology/DPI transition.
|
||||
std::string error;
|
||||
if (!ReconcileMonitors(error))
|
||||
health_error_ = error;
|
||||
else
|
||||
health_error_ =
|
||||
"Display layout/DPI changed; coverage rebuilt, remote operation "
|
||||
"paused. Turn privacy off and reconnect to refresh display "
|
||||
"mapping.";
|
||||
}
|
||||
if (!health_error_.empty()) return {health_error_, false};
|
||||
BOOL composed = FALSE;
|
||||
if (FAILED(DwmIsCompositionEnabled(&composed)) || !composed) {
|
||||
return {
|
||||
"Desktop composition stopped; privacy coverage cannot be guaranteed",
|
||||
false};
|
||||
}
|
||||
return CheckCoverage();
|
||||
}
|
||||
|
||||
PrivacyHealth CheckCoverage() {
|
||||
const auto get_band = reinterpret_cast<GetPrivacyWindowBand>(
|
||||
GetProcAddress(GetModuleHandleW(L"user32.dll"), "GetWindowBand"));
|
||||
if (!get_band) return {"High-band window API became unavailable", false};
|
||||
for (const auto& w : windows_) {
|
||||
DWORD band = 0;
|
||||
DWORD affinity = 0;
|
||||
BYTE alpha = 0;
|
||||
DWORD layered_flags = 0;
|
||||
RECT rect{};
|
||||
DWORD cloaked = 0;
|
||||
if (!get_band(w.cover, &band) || band != kPrivacyWindowBand ||
|
||||
!IsWindowVisible(w.cover) || !GetWindowRect(w.cover, &rect) ||
|
||||
!SameRect(rect, w.rect) ||
|
||||
!(GetWindowLongPtrW(w.cover, GWL_EXSTYLE) & WS_EX_TOPMOST) ||
|
||||
!GetLayeredWindowAttributes(w.cover, nullptr, &alpha,
|
||||
&layered_flags) ||
|
||||
alpha != 255 || layered_flags != LWA_ALPHA ||
|
||||
!GetWindowDisplayAffinity(w.cover, &affinity) ||
|
||||
affinity != WDA_EXCLUDEFROMCAPTURE ||
|
||||
FAILED(DwmGetWindowAttribute(w.cover, DWMWA_CLOAKED, &cloaked,
|
||||
sizeof(cloaked))) ||
|
||||
cloaked) {
|
||||
return {
|
||||
"Privacy window visibility, geometry or capture affinity was lost; "
|
||||
"remote operation paused",
|
||||
false};
|
||||
}
|
||||
}
|
||||
for (const auto& w : windows_) {
|
||||
// Detect windows in special bands above the cover. A high-band window
|
||||
// still cannot cover another desktop or guarantee exclusive fullscreen.
|
||||
for (HWND above = GetWindow(w.cover, GW_HWNDPREV); above;
|
||||
above = GetWindow(above, GW_HWNDPREV)) {
|
||||
if (!IsWindowVisible(above) || IsIconic(above)) continue;
|
||||
DWORD above_band = 0;
|
||||
if (!get_band(above, &above_band))
|
||||
return {"Could not inspect a window above the high-band cover",
|
||||
false};
|
||||
// HWND list order alone does not express compositor band order.
|
||||
// Only skip the two known ordinary desktop bands, never unknown bands.
|
||||
if (above_band == 0 || above_band == 1) continue;
|
||||
if (std::any_of(windows_.begin(), windows_.end(),
|
||||
[above](const auto& own) {
|
||||
return above == own.cover || above == own.probe;
|
||||
}))
|
||||
continue;
|
||||
RECT bounds{}, intersection{};
|
||||
DWORD hidden = 0;
|
||||
if (SUCCEEDED(DwmGetWindowAttribute(above, DWMWA_CLOAKED, &hidden,
|
||||
sizeof(hidden))) &&
|
||||
hidden)
|
||||
continue;
|
||||
if (GetWindowRect(above, &bounds) &&
|
||||
IntersectRect(&intersection, &bounds, &w.rect)) {
|
||||
if (IsNonObstructingPrivacyWindow(above, bounds)) continue;
|
||||
char window_class[128]{};
|
||||
DWORD process_id = 0;
|
||||
GetClassNameA(above, window_class, sizeof(window_class));
|
||||
GetWindowThreadProcessId(above, &process_id);
|
||||
const auto now = GetTickCount64();
|
||||
if (above != last_obstruction_ ||
|
||||
now - last_obstruction_log_ >= 1000) {
|
||||
LOG_WARN(
|
||||
"Potential privacy cover overlap: class={}, process={}, "
|
||||
"band={}, bounds=({}, {})-({}, {}), exstyle=0x{:x}; "
|
||||
"remote operation paused",
|
||||
window_class, process_id, above_band, bounds.left, bounds.top,
|
||||
bounds.right, bounds.bottom,
|
||||
GetWindowLongPtrW(above, GWL_EXSTYLE));
|
||||
last_obstruction_ = above;
|
||||
last_obstruction_log_ = now;
|
||||
}
|
||||
// HWND order cannot prove compositor order, but this window may
|
||||
// display content. Retain protection and require explicit recovery.
|
||||
return {
|
||||
"A visible window may overlap privacy coverage; "
|
||||
"remote operation paused. Turn privacy off to recover.",
|
||||
false};
|
||||
}
|
||||
}
|
||||
}
|
||||
return {health_error_, false};
|
||||
}
|
||||
|
||||
public:
|
||||
bool PaintChallenge(uint32_t value, const std::string& title,
|
||||
std::string& error) override {
|
||||
using Pattern = PrivacyProbePattern;
|
||||
for (auto& w : windows_) {
|
||||
if (!w.probe && !CreateProbe(w, error)) return false;
|
||||
PrivacyProbeImage image;
|
||||
if (!RenderPrivacyProbe(title, PrivacyWindowDpi(w.probe),
|
||||
w.rect.right - w.rect.left - 2 * Pattern::kOffset,
|
||||
w.rect.bottom - w.rect.top - 2 * Pattern::kOffset,
|
||||
value, image, error)) {
|
||||
LOG_ERROR("Privacy verification panel: {}", error);
|
||||
return false;
|
||||
}
|
||||
HDC screen = GetDC(nullptr);
|
||||
HDC memory = CreateCompatibleDC(screen);
|
||||
BITMAPINFO info{};
|
||||
info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
|
||||
info.bmiHeader.biWidth = image.width;
|
||||
info.bmiHeader.biHeight = -image.height;
|
||||
info.bmiHeader.biPlanes = 1;
|
||||
info.bmiHeader.biBitCount = 32;
|
||||
info.bmiHeader.biCompression = BI_RGB;
|
||||
void* bits = nullptr;
|
||||
HBITMAP bitmap =
|
||||
CreateDIBSection(screen, &info, DIB_RGB_COLORS, &bits, nullptr, 0);
|
||||
bool ok = screen && memory && bitmap && bits;
|
||||
if (ok) {
|
||||
std::memcpy(bits, image.pixels.data(),
|
||||
image.pixels.size() * sizeof(uint32_t));
|
||||
const auto old = SelectObject(memory, bitmap);
|
||||
POINT position{w.rect.left + Pattern::kOffset,
|
||||
w.rect.top + Pattern::kOffset},
|
||||
source{};
|
||||
SIZE size{image.width, image.height};
|
||||
ok = old && old != HGDI_ERROR;
|
||||
if (ok) {
|
||||
ok = UpdateLayeredWindow(w.probe, screen, &position, &size, memory,
|
||||
&source, 0, nullptr, ULW_OPAQUE);
|
||||
SelectObject(memory, old);
|
||||
}
|
||||
}
|
||||
if (!ok) error = Error("Present opaque privacy verification panel");
|
||||
if (bitmap) DeleteObject(bitmap);
|
||||
if (memory) DeleteDC(memory);
|
||||
if (screen) ReleaseDC(nullptr, screen);
|
||||
if (!ok) return false;
|
||||
// Present only after the complete text and square bitmap is ready.
|
||||
if (!IsWindowVisible(w.probe) &&
|
||||
(!SetWindowPos(
|
||||
w.probe, HWND_TOPMOST, 0, 0, 0, 0,
|
||||
SWP_NOACTIVATE | SWP_NOMOVE | SWP_NOSIZE | SWP_SHOWWINDOW) ||
|
||||
!IsWindowVisible(w.probe))) {
|
||||
error = Error("Show privacy verification panel beneath cover");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ClearChallenges(std::string& error) override {
|
||||
for (auto& w : windows_) {
|
||||
Destroy(w.probe);
|
||||
if (w.probe) {
|
||||
error = "Could not remove the temporary capture verification window";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool HasMonitor(int left, int top, int width, int height) const override {
|
||||
// Metadata only; also serialized against Poll by PrivacyController.
|
||||
return std::any_of(windows_.begin(), windows_.end(), [&](const auto& w) {
|
||||
return w.rect.left == left && w.rect.top == top &&
|
||||
w.rect.right - left == width && w.rect.bottom - top == height;
|
||||
});
|
||||
}
|
||||
|
||||
private:
|
||||
struct Window {
|
||||
RECT rect{};
|
||||
HWND cover = nullptr;
|
||||
HWND probe = nullptr;
|
||||
};
|
||||
|
||||
static void Destroy(HWND& hwnd) {
|
||||
if (hwnd && IsWindow(hwnd) && !DestroyWindow(hwnd)) {
|
||||
Error("DestroyWindow");
|
||||
return;
|
||||
}
|
||||
hwnd = nullptr;
|
||||
}
|
||||
|
||||
bool Initialize(std::string& error) {
|
||||
if (sink_) return true;
|
||||
// This dedicated thread owns no existing application windows. Never alter
|
||||
// process-wide or Slint/SDL DPI awareness.
|
||||
if (!dpi_scope_ || !dpi_scope_->active()) {
|
||||
dpi_scope_.reset();
|
||||
dpi_scope_ = std::make_unique<ScopedWindowsPhysicalCoordinates>();
|
||||
}
|
||||
if (!dpi_scope_->active()) {
|
||||
error = Error("SetThreadDpiAwarenessContext");
|
||||
return false;
|
||||
}
|
||||
WNDCLASSEXW cls{sizeof(cls)};
|
||||
cls.lpfnWndProc = WindowProc;
|
||||
cls.hInstance = GetModuleHandleW(nullptr);
|
||||
cls.lpszClassName = kClassName;
|
||||
cls.hCursor = nullptr;
|
||||
cls.hbrBackground = static_cast<HBRUSH>(GetStockObject(BLACK_BRUSH));
|
||||
if (!registered_ && !RegisterClassExW(&cls)) {
|
||||
error = Error("RegisterClassExW");
|
||||
return false;
|
||||
}
|
||||
registered_ = true;
|
||||
sink_ = CreateWindowExW(WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE, kClassName,
|
||||
L"", WS_POPUP, 0, 0, 0, 0, nullptr, nullptr,
|
||||
cls.hInstance, this);
|
||||
if (!sink_) {
|
||||
error = Error("CreateWindowExW message sink");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool CreateProbe(Window& w, std::string& error) {
|
||||
constexpr DWORD styles = WS_EX_TOPMOST | WS_EX_TOOLWINDOW |
|
||||
WS_EX_NOACTIVATE | WS_EX_LAYERED |
|
||||
WS_EX_TRANSPARENT;
|
||||
w.probe =
|
||||
CreateWindowExW(styles, kClassName, L"CrossDesk capture verification",
|
||||
WS_POPUP, w.rect.left + PrivacyProbePattern::kOffset,
|
||||
w.rect.top + PrivacyProbePattern::kOffset, 1, 1,
|
||||
nullptr, nullptr, GetModuleHandleW(nullptr), this);
|
||||
if (!w.probe) {
|
||||
error = Error("CreateWindowExW capture challenge");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ReconcileMonitors(std::string& error) {
|
||||
MonitorEnumeration monitors;
|
||||
if (!EnumDisplayMonitors(nullptr, nullptr, EnumMonitor,
|
||||
reinterpret_cast<LPARAM>(&monitors)) ||
|
||||
!monitors.ok || monitors.rects.empty()) {
|
||||
error = Error("EnumDisplayMonitors");
|
||||
return false;
|
||||
}
|
||||
std::vector<PrivacyBandWindow> covers;
|
||||
if (!band_guard_.Windows(covers, error)) return false;
|
||||
for (const RECT& rect : monitors.rects) {
|
||||
auto existing =
|
||||
std::find_if(windows_.begin(), windows_.end(),
|
||||
[&](const auto& w) { return SameRect(w.rect, rect); });
|
||||
if (existing != windows_.end()) continue;
|
||||
const auto cover = std::find_if(covers.begin(), covers.end(),
|
||||
[&](const auto& candidate) {
|
||||
return SameRect(candidate.rect, rect);
|
||||
});
|
||||
if (cover == covers.end()) {
|
||||
error = "High-band broker has not covered every physical monitor";
|
||||
return false;
|
||||
}
|
||||
windows_.push_back({rect, reinterpret_cast<HWND>(cover->hwnd), nullptr});
|
||||
LOG_INFO(
|
||||
"Privacy high-band cover ready: bounds=({}, {})-({}, {}), "
|
||||
"affinity=0x11, alpha=255",
|
||||
rect.left, rect.top, rect.right, rect.bottom);
|
||||
}
|
||||
for (auto it = windows_.begin(); it != windows_.end();) {
|
||||
if (std::none_of(monitors.rects.begin(), monitors.rects.end(),
|
||||
[&](const RECT& r) { return SameRect(it->rect, r); })) {
|
||||
Destroy(it->probe);
|
||||
it->cover = nullptr; // The broker owns cover destruction.
|
||||
if (!it->probe)
|
||||
it = windows_.erase(it);
|
||||
else {
|
||||
error = "Old privacy window could not be released";
|
||||
return false;
|
||||
}
|
||||
} else
|
||||
++it;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static LRESULT CALLBACK WindowProc(HWND hwnd, UINT msg, WPARAM wp,
|
||||
LPARAM lp) {
|
||||
if (msg == WM_NCCREATE) {
|
||||
const auto create = reinterpret_cast<CREATESTRUCTW*>(lp);
|
||||
SetWindowLongPtrW(hwnd, GWLP_USERDATA,
|
||||
reinterpret_cast<LONG_PTR>(create->lpCreateParams));
|
||||
}
|
||||
auto self = reinterpret_cast<WindowsPrivacyBackend*>(
|
||||
GetWindowLongPtrW(hwnd, GWLP_USERDATA));
|
||||
if (msg == WM_HOTKEY || msg == kEmergency) {
|
||||
if (self && hwnd == self->sink_) self->emergency_ = true;
|
||||
return 0;
|
||||
}
|
||||
if (msg == WM_MOUSEACTIVATE) return MA_NOACTIVATE;
|
||||
if (msg == WM_SETCURSOR) {
|
||||
SetCursor(nullptr);
|
||||
return TRUE;
|
||||
}
|
||||
if (msg == WM_NCHITTEST) return HTTRANSPARENT;
|
||||
if (msg == WM_CLOSE) return 0;
|
||||
if (msg == WM_PAINT) {
|
||||
// UpdateLayeredWindow owns the complete probe image; WM_PAINT only
|
||||
// validates the update region and never draws a second copy.
|
||||
PAINTSTRUCT paint{};
|
||||
BeginPaint(hwnd, &paint);
|
||||
EndPaint(hwnd, &paint);
|
||||
return 0;
|
||||
}
|
||||
if ((msg == WM_DISPLAYCHANGE || msg == WM_DPICHANGED) && self) {
|
||||
self->layout_changed_ = true;
|
||||
return 0;
|
||||
}
|
||||
return DefWindowProcW(hwnd, msg, wp, lp);
|
||||
}
|
||||
|
||||
static void CALLBACK WindowEvent(HWINEVENTHOOK, DWORD, HWND hwnd, LONG object,
|
||||
LONG child, DWORD, DWORD) {
|
||||
auto self = event_owner_;
|
||||
if (!self || !hwnd || object != OBJID_WINDOW || child != CHILDID_SELF ||
|
||||
hwnd == self->sink_ || GetAncestor(hwnd, GA_ROOT) != hwnd)
|
||||
return;
|
||||
for (const auto& w : self->windows_)
|
||||
if (hwnd == w.cover || hwnd == w.probe) return;
|
||||
// OUTOFCONTEXT delivers on this window thread. Only mark work here;
|
||||
// monitor/session queries and Z-order changes remain in the control loop.
|
||||
const auto get_band = reinterpret_cast<GetPrivacyWindowBand>(
|
||||
GetProcAddress(GetModuleHandleW(L"user32.dll"), "GetWindowBand"));
|
||||
DWORD band = 0;
|
||||
if (get_band && get_band(hwnd, &band) && (band == 0 || band == 1)) return;
|
||||
self->coverage_dirty_ = true;
|
||||
}
|
||||
|
||||
inline static thread_local WindowsPrivacyBackend* event_owner_ = nullptr;
|
||||
std::vector<HWINEVENTHOOK> window_events_;
|
||||
std::vector<Window> windows_;
|
||||
HWND sink_ = nullptr;
|
||||
HANDLE wake_event_ = nullptr;
|
||||
std::string wake_error_;
|
||||
PrivacyInputGuard input_guard_;
|
||||
bool block_input_ = false;
|
||||
PrivacyCursorGuard cursor_guard_;
|
||||
PrivacyBandGuard band_guard_;
|
||||
std::unique_ptr<ScopedWindowsPhysicalCoordinates> dpi_scope_;
|
||||
bool registered_ = false;
|
||||
bool hotkey_ = false;
|
||||
bool emergency_ = false;
|
||||
bool layout_changed_ = false;
|
||||
bool coverage_dirty_ = false;
|
||||
uint64_t last_check_ = 0;
|
||||
PrivacyHealth last_health_;
|
||||
HWND last_obstruction_ = nullptr;
|
||||
uint64_t last_obstruction_log_ = 0;
|
||||
std::string health_error_;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
bool IsWindowsPrivacyDesktopAvailable() {
|
||||
// LockApp can display the lock screen while OpenInputDesktop still reports
|
||||
// Default. Desktop-name checks alone are NOT a session-unlocked check.
|
||||
DWORD session = 0;
|
||||
if (!ProcessIdToSessionId(GetCurrentProcessId(), &session) ||
|
||||
session != WTSGetActiveConsoleSessionId())
|
||||
return false;
|
||||
LPWSTR buffer = nullptr;
|
||||
DWORD bytes = 0;
|
||||
if (!WTSQuerySessionInformationW(WTS_CURRENT_SERVER_HANDLE, session,
|
||||
WTSSessionInfoEx, &buffer, &bytes))
|
||||
return false;
|
||||
const auto info = reinterpret_cast<WTSINFOEXW*>(buffer);
|
||||
const bool unlocked =
|
||||
bytes >= sizeof(WTSINFOEXW) && info->Level == 1 &&
|
||||
info->Data.WTSInfoExLevel1.SessionFlags == WTS_SESSIONSTATE_UNLOCK;
|
||||
WTSFreeMemory(buffer);
|
||||
if (!unlocked) return false;
|
||||
HDESK input = OpenInputDesktop(0, FALSE, DESKTOP_READOBJECTS);
|
||||
if (!input) return false;
|
||||
wchar_t input_name[256]{}, thread_name[256]{};
|
||||
const bool ok =
|
||||
DesktopName(input, input_name) &&
|
||||
DesktopName(GetThreadDesktop(GetCurrentThreadId()), thread_name) &&
|
||||
_wcsicmp(input_name, L"Default") == 0 &&
|
||||
_wcsicmp(input_name, thread_name) == 0;
|
||||
CloseDesktop(input);
|
||||
return ok;
|
||||
}
|
||||
|
||||
std::unique_ptr<PrivacyBackend> CreateWindowsPrivacyBackend() {
|
||||
return std::make_unique<WindowsPrivacyBackend>();
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
@@ -0,0 +1,323 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-10
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#include "privacy_band_guard.h"
|
||||
|
||||
#include <objbase.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <iterator>
|
||||
#include <new>
|
||||
|
||||
#include "privacy_backend.h"
|
||||
#include "rd_log.h"
|
||||
|
||||
namespace crossdesk {
|
||||
namespace {
|
||||
struct ScopedHandle {
|
||||
HANDLE value = nullptr;
|
||||
~ScopedHandle() {
|
||||
if (value && value != INVALID_HANDLE_VALUE) CloseHandle(value);
|
||||
}
|
||||
};
|
||||
bool Failed(const char* operation, std::string& error,
|
||||
DWORD code = GetLastError()) {
|
||||
error =
|
||||
std::string(operation) + " (Windows error " + std::to_string(code) + ")";
|
||||
LOG_ERROR("Privacy high-band: {}", error);
|
||||
return false;
|
||||
}
|
||||
|
||||
std::filesystem::path ModuleDirectory() {
|
||||
std::vector<wchar_t> path(32768);
|
||||
const DWORD length = GetModuleFileNameW(nullptr, path.data(), path.size());
|
||||
if (!length || length >= path.size()) return {};
|
||||
return std::filesystem::path(path.data()).parent_path();
|
||||
}
|
||||
|
||||
std::vector<wchar_t> ChildEnvironment(const std::wstring& bootstrap) {
|
||||
std::vector<std::wstring> entries;
|
||||
LPWCH environment = GetEnvironmentStringsW();
|
||||
if (!environment) return {};
|
||||
const std::wstring prefix = std::wstring(kPrivacyBandBootstrap) + L"=";
|
||||
for (const wchar_t* item = environment; *item; item += wcslen(item) + 1) {
|
||||
if (_wcsnicmp(item, prefix.c_str(), prefix.size()) != 0)
|
||||
entries.emplace_back(item);
|
||||
}
|
||||
FreeEnvironmentStringsW(environment);
|
||||
entries.push_back(prefix + bootstrap);
|
||||
std::sort(entries.begin(), entries.end(), [](const auto& a, const auto& b) {
|
||||
return _wcsicmp(a.c_str(), b.c_str()) < 0;
|
||||
});
|
||||
std::vector<wchar_t> result;
|
||||
for (const auto& entry : entries) {
|
||||
result.insert(result.end(), entry.begin(), entry.end());
|
||||
result.push_back(0);
|
||||
}
|
||||
result.push_back(0);
|
||||
return result;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
PrivacyBandGuard::~PrivacyBandGuard() {
|
||||
std::string error;
|
||||
if (!Stop(error)) {
|
||||
// Last-resort teardown still targets only this object's dedicated job.
|
||||
if (job_) CloseHandle(job_);
|
||||
job_ = nullptr;
|
||||
Stop(error);
|
||||
}
|
||||
}
|
||||
|
||||
bool PrivacyBandGuard::Available(std::string& error) {
|
||||
#if !defined(_M_X64) && !defined(__x86_64__)
|
||||
error = "High-band privacy currently requires an x64 Windows process";
|
||||
return false;
|
||||
#else
|
||||
const HMODULE user = GetModuleHandleW(L"user32.dll");
|
||||
if (!GetProcAddress(user, "CreateWindowInBand") ||
|
||||
!GetProcAddress(user, "GetWindowBand")) {
|
||||
error = "Windows high-band window APIs are unavailable";
|
||||
return false;
|
||||
}
|
||||
const auto directory = ModuleDirectory();
|
||||
if (directory.empty())
|
||||
return Failed("Resolve privacy module directory", error);
|
||||
const auto dll = directory / kPrivacyBandDll;
|
||||
if (GetFileAttributesW(dll.c_str()) == INVALID_FILE_ATTRIBUTES)
|
||||
return Failed("Missing crossdesk_privacy_window.dll", error);
|
||||
return true;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool PrivacyBandGuard::Start(const PrivacyScreenText& text,
|
||||
std::string& error) {
|
||||
if (process_)
|
||||
return Failed("High-band broker is already running", error, ERROR_BUSY);
|
||||
if (!Available(error)) return false;
|
||||
const auto directory = ModuleDirectory();
|
||||
if (directory.empty())
|
||||
return Failed("Resolve privacy module directory", error);
|
||||
const auto dll = directory / kPrivacyBandDll;
|
||||
wchar_t system[MAX_PATH]{}, temporary[MAX_PATH]{}, guid_text[40]{};
|
||||
GUID guid{};
|
||||
if (!GetSystemDirectoryW(system, MAX_PATH) ||
|
||||
!GetTempPathW(MAX_PATH, temporary) || FAILED(CoCreateGuid(&guid)) ||
|
||||
!StringFromGUID2(guid, guid_text, 40))
|
||||
return Failed("Resolve high-band broker paths", error);
|
||||
broker_directory_ = std::filesystem::path(temporary) /
|
||||
(std::wstring(L"CrossDesk-privacy-") + guid_text);
|
||||
if (!CreateDirectoryW(broker_directory_.c_str(), nullptr))
|
||||
return Failed("Create private broker directory", error);
|
||||
broker_path_ = broker_directory_ / L"RuntimeBroker_crossdesk.exe";
|
||||
if (!CopyFileW((std::filesystem::path(system) / L"RuntimeBroker.exe").c_str(),
|
||||
broker_path_.c_str(), TRUE))
|
||||
return Failed("Copy system RuntimeBroker for this session", error);
|
||||
// Keep both images immutable throughout creation/injection and the session.
|
||||
image_lock_ = CreateFileW(broker_path_.c_str(), GENERIC_READ, FILE_SHARE_READ,
|
||||
nullptr, OPEN_EXISTING, 0, nullptr);
|
||||
dll_lock_ = CreateFileW(dll.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr,
|
||||
OPEN_EXISTING, 0, nullptr);
|
||||
if (image_lock_ == INVALID_HANDLE_VALUE || dll_lock_ == INVALID_HANDLE_VALUE)
|
||||
return Failed("Lock privacy broker images", error);
|
||||
|
||||
SECURITY_ATTRIBUTES inherit{sizeof(inherit), nullptr, TRUE};
|
||||
ScopedHandle parent;
|
||||
if (!DuplicateHandle(GetCurrentProcess(), GetCurrentProcess(),
|
||||
GetCurrentProcess(), &parent.value,
|
||||
SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, TRUE,
|
||||
0))
|
||||
return Failed("Duplicate privacy parent process handle", error);
|
||||
stop_ = CreateEventW(&inherit, TRUE, FALSE, nullptr);
|
||||
ready_ = CreateEventW(&inherit, TRUE, FALSE, nullptr);
|
||||
mapping_ = CreateFileMappingW(INVALID_HANDLE_VALUE, &inherit, PAGE_READWRITE,
|
||||
0, sizeof(PrivacyBandShared), nullptr);
|
||||
if (!stop_ || !ready_ || !mapping_) {
|
||||
const auto code = GetLastError();
|
||||
return Failed("Create high-band broker IPC", error, code);
|
||||
}
|
||||
shared_ = static_cast<PrivacyBandShared*>(MapViewOfFile(
|
||||
mapping_, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(PrivacyBandShared)));
|
||||
if (!shared_) {
|
||||
return Failed("Map high-band broker IPC", error);
|
||||
}
|
||||
new (shared_) PrivacyBandShared{};
|
||||
// Immutable after process startup; no cross-process string objects or
|
||||
// concurrent writes while the window thread paints.
|
||||
if (!MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS,
|
||||
text.unlock_hint.c_str(), -1, shared_->unlock_hint,
|
||||
static_cast<int>(std::size(shared_->unlock_hint))))
|
||||
return Failed("Encode privacy screen text", error);
|
||||
shared_->parent_pid = GetCurrentProcessId();
|
||||
HANDLE handles[] = {parent.value, stop_, ready_, mapping_};
|
||||
std::wstring bootstrap;
|
||||
for (HANDLE handle : handles) {
|
||||
if (!bootstrap.empty()) bootstrap += L",";
|
||||
bootstrap += std::to_wstring(reinterpret_cast<uintptr_t>(handle));
|
||||
}
|
||||
auto environment = ChildEnvironment(bootstrap);
|
||||
if (environment.empty()) {
|
||||
return Failed("Build broker environment", error);
|
||||
}
|
||||
|
||||
job_ = CreateJobObjectW(nullptr, nullptr);
|
||||
JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{};
|
||||
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||
if (!job_ || !SetInformationJobObject(job_, JobObjectExtendedLimitInformation,
|
||||
&limits, sizeof(limits))) {
|
||||
return Failed("Bind high-band broker lifetime", error);
|
||||
}
|
||||
SIZE_T bytes = 0;
|
||||
InitializeProcThreadAttributeList(nullptr, 1, 0, &bytes);
|
||||
std::vector<unsigned char> attributes(bytes);
|
||||
STARTUPINFOEXW startup{};
|
||||
startup.StartupInfo.cb = sizeof(startup);
|
||||
startup.lpAttributeList =
|
||||
reinterpret_cast<LPPROC_THREAD_ATTRIBUTE_LIST>(attributes.data());
|
||||
if (!InitializeProcThreadAttributeList(startup.lpAttributeList, 1, 0,
|
||||
&bytes)) {
|
||||
return Failed("Initialize broker handle list", error);
|
||||
}
|
||||
bool ok = UpdateProcThreadAttribute(
|
||||
startup.lpAttributeList, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST,
|
||||
handles, sizeof(handles), nullptr, nullptr) != FALSE;
|
||||
PROCESS_INFORMATION child{};
|
||||
std::wstring command = L"\"" + broker_path_.wstring() + L"\"";
|
||||
if (ok) {
|
||||
ok = CreateProcessW(
|
||||
broker_path_.c_str(), command.data(), nullptr, nullptr, TRUE,
|
||||
CREATE_SUSPENDED | CREATE_NO_WINDOW | CREATE_UNICODE_ENVIRONMENT |
|
||||
EXTENDED_STARTUPINFO_PRESENT,
|
||||
environment.data(), broker_directory_.c_str(),
|
||||
&startup.StartupInfo, &child) != FALSE;
|
||||
}
|
||||
const DWORD create_error = GetLastError();
|
||||
DeleteProcThreadAttributeList(startup.lpAttributeList);
|
||||
if (!ok)
|
||||
return Failed("Create owned RuntimeBroker process", error, create_error);
|
||||
process_ = child.hProcess;
|
||||
shared_->broker_pid = child.dwProcessId;
|
||||
if (!AssignProcessToJobObject(job_, process_)) {
|
||||
const DWORD code = GetLastError();
|
||||
TerminateProcess(process_, code);
|
||||
CloseHandle(child.hThread);
|
||||
return Failed("Assign owned RuntimeBroker to privacy job", error, code);
|
||||
}
|
||||
const auto path = dll.wstring();
|
||||
const auto size = (path.size() + 1) * sizeof(wchar_t);
|
||||
void* remote_path = VirtualAllocEx(process_, nullptr, size,
|
||||
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
|
||||
SIZE_T written = 0;
|
||||
const auto loader =
|
||||
GetProcAddress(GetModuleHandleW(L"kernel32.dll"), "LoadLibraryW");
|
||||
ok =
|
||||
remote_path && loader &&
|
||||
WriteProcessMemory(process_, remote_path, path.c_str(), size, &written) &&
|
||||
written == size &&
|
||||
QueueUserAPC(reinterpret_cast<PAPCFUNC>(loader), child.hThread,
|
||||
reinterpret_cast<ULONG_PTR>(remote_path));
|
||||
DWORD injection_error = GetLastError();
|
||||
if (ok && ResumeThread(child.hThread) == DWORD(-1)) {
|
||||
ok = false;
|
||||
injection_error = GetLastError();
|
||||
}
|
||||
CloseHandle(child.hThread);
|
||||
if (!ok)
|
||||
return Failed("Load high-band window module into owned broker", error,
|
||||
injection_error);
|
||||
HANDLE startup_events[] = {ready_, process_};
|
||||
const DWORD result = WaitForMultipleObjects(2, startup_events, FALSE, 5000);
|
||||
if (result != WAIT_OBJECT_0) {
|
||||
DWORD code = ERROR_TIMEOUT;
|
||||
if (result == WAIT_OBJECT_0 + 1) GetExitCodeProcess(process_, &code);
|
||||
return Failed("High-band broker did not become ready", error, code);
|
||||
}
|
||||
std::vector<PrivacyBandWindow> windows;
|
||||
if (!Windows(windows, error)) return false;
|
||||
LOG_INFO("Privacy high-band broker ready: process={}, band={}, monitors={}",
|
||||
process_id(), kPrivacyWindowBand, windows.size());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool PrivacyBandGuard::Healthy(std::string& error) {
|
||||
if (!process_ || !shared_)
|
||||
return Failed("High-band broker is not initialized", error,
|
||||
ERROR_INVALID_HANDLE);
|
||||
const LONG code = InterlockedCompareExchange(&shared_->error, 0, 0);
|
||||
if (code != ERROR_SUCCESS) {
|
||||
char operation[256]{};
|
||||
WideCharToMultiByte(CP_UTF8, 0, shared_->operation, -1, operation,
|
||||
sizeof(operation), nullptr, nullptr);
|
||||
return Failed(operation[0] ? operation : "High-band window startup failed",
|
||||
error, code);
|
||||
}
|
||||
if (WaitForSingleObject(process_, 0) != WAIT_TIMEOUT) {
|
||||
DWORD exit_code = ERROR_PROCESS_ABORTED;
|
||||
GetExitCodeProcess(process_, &exit_code);
|
||||
return Failed("High-band window process exited", error, exit_code);
|
||||
}
|
||||
if (!InterlockedCompareExchange(&shared_->active, 0, 0) ||
|
||||
GetTickCount64() - static_cast<ULONGLONG>(InterlockedCompareExchange64(
|
||||
&shared_->heartbeat, 0, 0)) >
|
||||
1500)
|
||||
return Failed("High-band window thread stopped responding", error,
|
||||
ERROR_TIMEOUT);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool PrivacyBandGuard::Windows(std::vector<PrivacyBandWindow>& windows,
|
||||
std::string& error) {
|
||||
if (!Healthy(error)) return false;
|
||||
for (int attempt = 0; attempt < 4; ++attempt) {
|
||||
const LONG before = InterlockedCompareExchange(&shared_->sequence, 0, 0);
|
||||
if (before & 1) continue;
|
||||
const auto count = shared_->count;
|
||||
if (count == 0 || count > kPrivacyMaxMonitors) break;
|
||||
windows.assign(shared_->windows, shared_->windows + count);
|
||||
if (before == InterlockedCompareExchange(&shared_->sequence, 0, 0)) {
|
||||
for (const auto& window : windows) {
|
||||
DWORD pid = 0;
|
||||
GetWindowThreadProcessId(reinterpret_cast<HWND>(window.hwnd), &pid);
|
||||
if (pid != process_id())
|
||||
return Failed("Invalid high-band window owner", error,
|
||||
ERROR_INVALID_HANDLE);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return Failed("High-band monitor snapshot is changing or invalid", error,
|
||||
ERROR_RETRY);
|
||||
}
|
||||
|
||||
bool PrivacyBandGuard::Stop(std::string& error) {
|
||||
bool ok = true;
|
||||
if (process_) {
|
||||
if (stop_) SetEvent(stop_);
|
||||
if (WaitForSingleObject(process_, 1500) != WAIT_OBJECT_0) {
|
||||
// This handle came only from our CreateProcessW call, never discovery.
|
||||
if (!TerminateProcess(process_, ERROR_CANCELLED) ||
|
||||
WaitForSingleObject(process_, 1500) != WAIT_OBJECT_0)
|
||||
ok = Failed("Stop owned high-band broker", error);
|
||||
}
|
||||
if (!ok) return false;
|
||||
CloseHandle(process_);
|
||||
process_ = nullptr;
|
||||
}
|
||||
if (job_) CloseHandle(job_);
|
||||
job_ = nullptr;
|
||||
if (shared_) UnmapViewOfFile(shared_);
|
||||
shared_ = nullptr;
|
||||
for (HANDLE handle : {stop_, ready_, mapping_, image_lock_, dll_lock_})
|
||||
if (handle && handle != INVALID_HANDLE_VALUE) CloseHandle(handle);
|
||||
stop_ = ready_ = mapping_ = image_lock_ = dll_lock_ = nullptr;
|
||||
if (!broker_path_.empty()) DeleteFileW(broker_path_.c_str());
|
||||
if (!broker_directory_.empty()) RemoveDirectoryW(broker_directory_.c_str());
|
||||
broker_path_.clear();
|
||||
broker_directory_.clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-10
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _PRIVACY_BAND_GUARD_H_
|
||||
#define _PRIVACY_BAND_GUARD_H_
|
||||
|
||||
#include <Windows.h>
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "privacy_band_ipc.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
struct PrivacyScreenText;
|
||||
|
||||
// Lifecycle and snapshots are owned by the privacy control thread. The broker
|
||||
// owns all high-band HWND operations. This class never injects an existing
|
||||
// process and never terminates a process found by name or PID.
|
||||
class PrivacyBandGuard {
|
||||
public:
|
||||
~PrivacyBandGuard();
|
||||
static bool Available(std::string& error);
|
||||
bool Start(const PrivacyScreenText& text, std::string& error);
|
||||
bool Stop(std::string& error);
|
||||
bool Healthy(std::string& error);
|
||||
bool Windows(std::vector<PrivacyBandWindow>& windows, std::string& error);
|
||||
bool stopped() const { return !process_; }
|
||||
DWORD process_id() const { return process_ ? GetProcessId(process_) : 0; }
|
||||
|
||||
private:
|
||||
HANDLE process_ = nullptr;
|
||||
HANDLE job_ = nullptr;
|
||||
HANDLE stop_ = nullptr;
|
||||
HANDLE ready_ = nullptr;
|
||||
HANDLE mapping_ = nullptr;
|
||||
HANDLE image_lock_ = nullptr;
|
||||
HANDLE dll_lock_ = nullptr;
|
||||
PrivacyBandShared* shared_ = nullptr;
|
||||
std::filesystem::path broker_path_;
|
||||
std::filesystem::path broker_directory_;
|
||||
};
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif // _PRIVACY_BAND_GUARD_H_
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-10
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _PRIVACY_BAND_IPC_H_
|
||||
#define _PRIVACY_BAND_IPC_H_
|
||||
|
||||
#include <Windows.h>
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
constexpr uint32_t kPrivacyBandMagic = 0x43534231;
|
||||
constexpr uint32_t kPrivacyBandVersion = 2;
|
||||
constexpr DWORD kPrivacyWindowBand = 18; // ZBID_ABOVELOCK_UX, current desktop.
|
||||
constexpr size_t kPrivacyMaxMonitors = 64;
|
||||
constexpr wchar_t kPrivacyBandBootstrap[] = L"CROSSDESK_PRIVACY_BAND_BOOTSTRAP";
|
||||
constexpr wchar_t kPrivacyBandDll[] = L"crossdesk_privacy_window.dll";
|
||||
|
||||
struct PrivacyBandWindow {
|
||||
uint64_t hwnd = 0;
|
||||
RECT rect{};
|
||||
};
|
||||
|
||||
// Unnamed mapping and synchronization/process handles are inherited only by
|
||||
// the newly created broker. No global window-name/PID discovery or named IPC.
|
||||
struct PrivacyBandShared {
|
||||
uint32_t magic = kPrivacyBandMagic;
|
||||
uint32_t version = kPrivacyBandVersion;
|
||||
uint32_t size = sizeof(PrivacyBandShared);
|
||||
DWORD parent_pid = 0;
|
||||
DWORD broker_pid = 0;
|
||||
volatile LONG sequence = 0;
|
||||
volatile LONG error = ERROR_IO_PENDING;
|
||||
volatile LONG active = 0;
|
||||
alignas(8) volatile LONG64 heartbeat = 0;
|
||||
wchar_t operation[128]{};
|
||||
wchar_t unlock_hint[128]{};
|
||||
uint32_t count = 0;
|
||||
PrivacyBandWindow windows[kPrivacyMaxMonitors]{};
|
||||
};
|
||||
|
||||
using CreatePrivacyWindowInBand = HWND(WINAPI*)(DWORD, ATOM, LPCWSTR, DWORD,
|
||||
int, int, int, int, HWND, HMENU,
|
||||
HINSTANCE, LPVOID, DWORD);
|
||||
using GetPrivacyWindowBand = BOOL(WINAPI*)(HWND, DWORD*);
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif // _PRIVACY_BAND_IPC_H_
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-10
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _PRIVACY_CURSOR_API_H_
|
||||
#define _PRIVACY_CURSOR_API_H_
|
||||
|
||||
#include <Windows.h>
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
// A separate cursor plane can remain visible above a capture-excluded HWND.
|
||||
// Magnification's documented cursor API hides that plane across input queues;
|
||||
// it does not change cursor schemes, shapes, positions, focus or DPI awareness.
|
||||
class PrivacyCursorApi {
|
||||
public:
|
||||
PrivacyCursorApi() = default;
|
||||
PrivacyCursorApi(const PrivacyCursorApi&) = delete;
|
||||
PrivacyCursorApi& operator=(const PrivacyCursorApi&) = delete;
|
||||
~PrivacyCursorApi() { RestoreAndClose(); }
|
||||
bool Initialize() {
|
||||
module_ = LoadLibraryExW(L"Magnification.dll", nullptr,
|
||||
LOAD_LIBRARY_SEARCH_SYSTEM32);
|
||||
if (!module_) return false;
|
||||
const auto initialize = reinterpret_cast<InitializeFn>(
|
||||
GetProcAddress(module_, "MagInitialize"));
|
||||
uninitialize_ = reinterpret_cast<InitializeFn>(
|
||||
GetProcAddress(module_, "MagUninitialize"));
|
||||
show_ = reinterpret_cast<ShowFn>(
|
||||
GetProcAddress(module_, "MagShowSystemCursor"));
|
||||
if (!initialize || !uninitialize_ || !show_) {
|
||||
SetLastError(ERROR_PROC_NOT_FOUND);
|
||||
return false;
|
||||
}
|
||||
initialized_ = initialize() != FALSE;
|
||||
return initialized_;
|
||||
}
|
||||
bool Hide() { return initialized_ && show_(FALSE); }
|
||||
bool RestoreAndClose() {
|
||||
bool ok = true;
|
||||
if (initialized_) {
|
||||
ok = show_(TRUE) != FALSE;
|
||||
ok = (uninitialize_() != FALSE) && ok;
|
||||
initialized_ = false;
|
||||
}
|
||||
if (module_) {
|
||||
FreeLibrary(module_);
|
||||
module_ = nullptr;
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
private:
|
||||
using InitializeFn = BOOL(WINAPI*)();
|
||||
using ShowFn = BOOL(WINAPI*)(BOOL);
|
||||
HMODULE module_ = nullptr;
|
||||
InitializeFn uninitialize_ = nullptr;
|
||||
ShowFn show_ = nullptr;
|
||||
bool initialized_ = false;
|
||||
};
|
||||
|
||||
struct PrivacyCursorReady {
|
||||
volatile LONG error;
|
||||
};
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif // _PRIVACY_CURSOR_API_H_
|
||||
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-11
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#include "privacy_cursor_guard.h"
|
||||
|
||||
#include <filesystem>
|
||||
#include <vector>
|
||||
|
||||
#include "privacy_cursor_api.h"
|
||||
#include "rd_log.h"
|
||||
|
||||
namespace crossdesk {
|
||||
namespace {
|
||||
struct Handle {
|
||||
HANDLE value = nullptr;
|
||||
~Handle() {
|
||||
if (value) CloseHandle(value);
|
||||
}
|
||||
};
|
||||
bool Failed(const char* operation, std::string& error,
|
||||
DWORD code = GetLastError()) {
|
||||
error = std::string(operation) + " failed (Windows error " +
|
||||
std::to_string(code) + ")";
|
||||
LOG_ERROR("Privacy cursor: {}", error);
|
||||
return false;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
PrivacyCursorGuard::~PrivacyCursorGuard() {
|
||||
std::string error;
|
||||
Stop(error);
|
||||
// The inherited parent handle still lets a slow helper restore on exit.
|
||||
if (process_) CloseHandle(process_);
|
||||
if (stop_event_) CloseHandle(stop_event_);
|
||||
}
|
||||
|
||||
bool PrivacyCursorGuard::Start(std::string& error) {
|
||||
if (process_ || restore_pending_)
|
||||
return Failed("Cursor guard already active", error, ERROR_BUSY);
|
||||
helper_failure_reported_ = false;
|
||||
std::vector<wchar_t> module(32768);
|
||||
const DWORD count = GetModuleFileNameW(nullptr, module.data(),
|
||||
static_cast<DWORD>(module.size()));
|
||||
if (!count || count >= module.size())
|
||||
return Failed("GetModuleFileNameW", error);
|
||||
const auto helper = std::filesystem::path(module.data()).parent_path() /
|
||||
L"crossdesk_privacy_cursor_helper.exe";
|
||||
SECURITY_ATTRIBUTES security{sizeof(security), nullptr, TRUE};
|
||||
Handle parent;
|
||||
if (!DuplicateHandle(GetCurrentProcess(), GetCurrentProcess(),
|
||||
GetCurrentProcess(), &parent.value, SYNCHRONIZE, TRUE,
|
||||
0))
|
||||
return Failed("Duplicate parent process handle", error);
|
||||
Handle stop{CreateEventW(&security, TRUE, FALSE, nullptr)};
|
||||
Handle ready{CreateEventW(&security, TRUE, FALSE, nullptr)};
|
||||
Handle mapping{CreateFileMappingW(INVALID_HANDLE_VALUE, &security,
|
||||
PAGE_READWRITE, 0,
|
||||
sizeof(PrivacyCursorReady), nullptr)};
|
||||
if (!stop.value || !ready.value || !mapping.value)
|
||||
return Failed("Create cursor guard IPC", error);
|
||||
auto shared = static_cast<PrivacyCursorReady*>(MapViewOfFile(
|
||||
mapping.value, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(PrivacyCursorReady)));
|
||||
if (!shared) return Failed("Map cursor guard readiness", error);
|
||||
InterlockedExchange(&shared->error, ERROR_IO_PENDING);
|
||||
|
||||
SIZE_T bytes = 0;
|
||||
InitializeProcThreadAttributeList(nullptr, 1, 0, &bytes);
|
||||
std::vector<unsigned char> attributes(bytes);
|
||||
STARTUPINFOEXW startup{};
|
||||
startup.StartupInfo.cb = sizeof(startup);
|
||||
startup.lpAttributeList =
|
||||
reinterpret_cast<LPPROC_THREAD_ATTRIBUTE_LIST>(attributes.data());
|
||||
if (!InitializeProcThreadAttributeList(startup.lpAttributeList, 1, 0,
|
||||
&bytes)) {
|
||||
const DWORD code = GetLastError();
|
||||
UnmapViewOfFile(shared);
|
||||
return Failed("Initialize cursor guard handle list", error, code);
|
||||
}
|
||||
HANDLE inherited[] = {parent.value, stop.value, ready.value, mapping.value};
|
||||
bool ok = UpdateProcThreadAttribute(
|
||||
startup.lpAttributeList, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST,
|
||||
inherited, sizeof(inherited), nullptr, nullptr) != FALSE;
|
||||
PROCESS_INFORMATION child{};
|
||||
if (ok) {
|
||||
std::wstring command = L"\"" + helper.wstring() + L"\"";
|
||||
for (HANDLE handle : inherited)
|
||||
command += L" " + std::to_wstring(reinterpret_cast<uintptr_t>(handle));
|
||||
ok =
|
||||
CreateProcessW(helper.c_str(), command.data(), nullptr, nullptr, TRUE,
|
||||
CREATE_NO_WINDOW | EXTENDED_STARTUPINFO_PRESENT |
|
||||
CREATE_BREAKAWAY_FROM_JOB,
|
||||
nullptr, nullptr, &startup.StartupInfo, &child) != FALSE;
|
||||
}
|
||||
const DWORD start_error = GetLastError();
|
||||
DeleteProcThreadAttributeList(startup.lpAttributeList);
|
||||
if (!ok) {
|
||||
UnmapViewOfFile(shared);
|
||||
return Failed("Start crossdesk_privacy_cursor_helper.exe", error,
|
||||
start_error);
|
||||
}
|
||||
CloseHandle(child.hThread);
|
||||
process_ = child.hProcess;
|
||||
stop_event_ = stop.value;
|
||||
stop.value = nullptr;
|
||||
HANDLE startup_events[] = {ready.value, process_};
|
||||
const DWORD result = WaitForMultipleObjects(2, startup_events, FALSE, 2000);
|
||||
const DWORD ready_error =
|
||||
static_cast<DWORD>(InterlockedCompareExchange(&shared->error, 0, 0));
|
||||
UnmapViewOfFile(shared);
|
||||
if (result != WAIT_OBJECT_0 || ready_error != ERROR_SUCCESS) {
|
||||
std::string cleanup;
|
||||
Stop(cleanup);
|
||||
return Failed(
|
||||
"Hide system cursor", error,
|
||||
ready_error == ERROR_IO_PENDING ? ERROR_TIMEOUT : ready_error);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool PrivacyCursorGuard::RestoreAfterFailure(std::string& error) {
|
||||
PrivacyCursorApi cursor;
|
||||
if (!cursor.Initialize() || !cursor.RestoreAndClose()) {
|
||||
restore_pending_ = true;
|
||||
return Failed("Restore system cursor after helper failure", error);
|
||||
}
|
||||
restore_pending_ = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool PrivacyCursorGuard::Healthy(std::string& error) {
|
||||
if (process_ && WaitForSingleObject(process_, 0) == WAIT_TIMEOUT) return true;
|
||||
if (restore_pending_ || (process_ && !helper_failure_reported_))
|
||||
RestoreAfterFailure(error);
|
||||
helper_failure_reported_ = true;
|
||||
error = "System cursor helper exited; remote operation paused";
|
||||
return false;
|
||||
}
|
||||
|
||||
bool PrivacyCursorGuard::Stop(std::string& error) {
|
||||
if (!process_) return !restore_pending_ || RestoreAfterFailure(error);
|
||||
if (!SetEvent(stop_event_)) return Failed("Stop cursor helper", error);
|
||||
if (WaitForSingleObject(process_, 2000) != WAIT_OBJECT_0) {
|
||||
return Failed("Wait for cursor helper restoration", error, ERROR_TIMEOUT);
|
||||
}
|
||||
DWORD code = ERROR_GEN_FAILURE;
|
||||
GetExitCodeProcess(process_, &code);
|
||||
CloseHandle(process_);
|
||||
process_ = nullptr;
|
||||
CloseHandle(stop_event_);
|
||||
stop_event_ = nullptr;
|
||||
if (code != ERROR_SUCCESS) return RestoreAfterFailure(error);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-10
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _PRIVACY_CURSOR_GUARD_H_
|
||||
#define _PRIVACY_CURSOR_GUARD_H_
|
||||
|
||||
#include <Windows.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
// Called only on the privacy message thread. The child owns cursor suppression
|
||||
// and restores it when either this guard stops it or its parent process exits.
|
||||
class PrivacyCursorGuard {
|
||||
public:
|
||||
PrivacyCursorGuard() = default;
|
||||
PrivacyCursorGuard(const PrivacyCursorGuard&) = delete;
|
||||
PrivacyCursorGuard& operator=(const PrivacyCursorGuard&) = delete;
|
||||
~PrivacyCursorGuard();
|
||||
bool Start(std::string& error);
|
||||
bool Healthy(std::string& error);
|
||||
bool Stop(std::string& error);
|
||||
bool stopped() const { return !process_ && !restore_pending_; }
|
||||
|
||||
private:
|
||||
bool RestoreAfterFailure(std::string& error);
|
||||
HANDLE process_ = nullptr;
|
||||
HANDLE stop_event_ = nullptr;
|
||||
bool restore_pending_ = false;
|
||||
bool helper_failure_reported_ = false;
|
||||
};
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif // _PRIVACY_CURSOR_GUARD_H_
|
||||
@@ -0,0 +1,304 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-10
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#include "privacy_input_guard.h"
|
||||
|
||||
#include "rd_log.h"
|
||||
#include "windows_input_marker.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
PrivacyInputGuard::~PrivacyInputGuard() { Stop(); }
|
||||
|
||||
void PrivacyInputGuard::Fault(const std::string& operation, DWORD code) {
|
||||
{
|
||||
std::lock_guard lock(error_mutex_);
|
||||
error_ = operation + " (Windows error " + std::to_string(code) + ")";
|
||||
LOG_ERROR("Privacy input: {}", error_);
|
||||
}
|
||||
healthy_.store(false);
|
||||
// Wake the owner's message loop; recovery is never performed in a hook.
|
||||
PostMessageW(owner_, WM_NULL, 0, 0);
|
||||
}
|
||||
|
||||
bool PrivacyInputGuard::Start(HWND owner, UINT exit_message,
|
||||
std::string& error) {
|
||||
if (thread_.joinable()) {
|
||||
error = "Privacy input thread is already running";
|
||||
return false;
|
||||
}
|
||||
owner_ = owner;
|
||||
exit_message_ = exit_message;
|
||||
healthy_.store(false);
|
||||
{
|
||||
std::lock_guard lock(error_mutex_);
|
||||
error_.clear();
|
||||
}
|
||||
stop_ = CreateEventW(nullptr, TRUE, FALSE, nullptr);
|
||||
ready_ = CreateEventW(nullptr, TRUE, FALSE, nullptr);
|
||||
if (!stop_ || !ready_) {
|
||||
Fault("Create privacy input events failed", GetLastError());
|
||||
Healthy(error);
|
||||
Stop();
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
thread_ = std::thread([this] { Run(); });
|
||||
} catch (const std::exception& e) {
|
||||
error = std::string("Start privacy input thread failed: ") + e.what();
|
||||
Stop();
|
||||
return false;
|
||||
}
|
||||
if (WaitForSingleObject(ready_, 2000) != WAIT_OBJECT_0) {
|
||||
error = "Privacy input thread startup timed out";
|
||||
Stop();
|
||||
return false;
|
||||
}
|
||||
if (!Healthy(error)) {
|
||||
Stop();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void PrivacyInputGuard::Stop() {
|
||||
if (stop_) SetEvent(stop_);
|
||||
if (thread_.joinable()) thread_.join();
|
||||
if (stop_) CloseHandle(stop_);
|
||||
if (ready_) CloseHandle(ready_);
|
||||
stop_ = ready_ = nullptr;
|
||||
owner_ = nullptr;
|
||||
}
|
||||
|
||||
bool PrivacyInputGuard::Healthy(std::string& error) const {
|
||||
if (healthy_.load() && running_.load() &&
|
||||
GetTickCount64() - last_pump_.load() < 1500)
|
||||
return true;
|
||||
std::lock_guard lock(error_mutex_);
|
||||
error = error_.empty() ? "Privacy input message thread stopped responding"
|
||||
: error_;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool PrivacyInputGuard::Renew(HHOOK& hook, int kind, HOOKPROC procedure) {
|
||||
HHOOK replacement =
|
||||
SetWindowsHookExW(kind, procedure, GetModuleHandleW(nullptr), 0);
|
||||
if (!replacement) {
|
||||
Fault("Renew privacy input hook failed", GetLastError());
|
||||
return false;
|
||||
}
|
||||
HHOOK previous = hook;
|
||||
hook = replacement;
|
||||
if (UnhookWindowsHookEx(previous)) return true;
|
||||
const DWORD code = GetLastError();
|
||||
if (code != ERROR_INVALID_HOOK_HANDLE) retired_.push_back(previous);
|
||||
Fault("Privacy input hook was lost or could not be retired", code);
|
||||
return false;
|
||||
}
|
||||
|
||||
bool PrivacyInputGuard::PumpMessages() {
|
||||
MSG message{};
|
||||
while (PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE)) {
|
||||
if (message.message == WM_QUIT) return false;
|
||||
TranslateMessage(&message);
|
||||
DispatchMessageW(&message);
|
||||
}
|
||||
last_pump_.store(GetTickCount64());
|
||||
return WaitForSingleObject(stop_, 0) == WAIT_TIMEOUT;
|
||||
}
|
||||
|
||||
bool PrivacyInputGuard::ReleasePressedInputs() {
|
||||
// Both hooks must already be installed: otherwise a new local press can
|
||||
// slip between this snapshot and the transition to blocked input.
|
||||
std::array<bool, 256> pressed{};
|
||||
for (int key = 1; key < 256; ++key)
|
||||
pressed[key] = (GetAsyncKeyState(key) & 0x8000) != 0;
|
||||
// Keep held modifiers available to the physical emergency-exit chord.
|
||||
// Our injected releases intentionally do not clear this hook-side state.
|
||||
physical_keys_ = pressed;
|
||||
|
||||
std::vector<INPUT> releases;
|
||||
for (int key = 1; key < 256; ++key) {
|
||||
if (!pressed[key]) continue;
|
||||
// Generic modifier VKs alias their left/right variants. Release each
|
||||
// side once; use the generic VK only if Windows reports no specific side.
|
||||
if ((key == VK_SHIFT && (pressed[VK_LSHIFT] || pressed[VK_RSHIFT])) ||
|
||||
(key == VK_CONTROL && (pressed[VK_LCONTROL] || pressed[VK_RCONTROL])) ||
|
||||
(key == VK_MENU && (pressed[VK_LMENU] || pressed[VK_RMENU])))
|
||||
continue;
|
||||
INPUT input{};
|
||||
input.type = INPUT_MOUSE;
|
||||
input.mi.dwExtraInfo = kInjectedMouseInputMarker;
|
||||
switch (key) {
|
||||
// GetAsyncKeyState reports physical mouse buttons, as do these flags,
|
||||
// including when Windows swaps the primary and secondary buttons.
|
||||
case VK_LBUTTON:
|
||||
input.mi.dwFlags = MOUSEEVENTF_LEFTUP;
|
||||
break;
|
||||
case VK_RBUTTON:
|
||||
input.mi.dwFlags = MOUSEEVENTF_RIGHTUP;
|
||||
break;
|
||||
case VK_MBUTTON:
|
||||
input.mi.dwFlags = MOUSEEVENTF_MIDDLEUP;
|
||||
break;
|
||||
case VK_XBUTTON1:
|
||||
case VK_XBUTTON2:
|
||||
input.mi.dwFlags = MOUSEEVENTF_XUP;
|
||||
input.mi.mouseData = key == VK_XBUTTON1 ? XBUTTON1 : XBUTTON2;
|
||||
break;
|
||||
default: {
|
||||
input = {};
|
||||
input.type = INPUT_KEYBOARD;
|
||||
input.ki.wVk = static_cast<WORD>(key);
|
||||
const UINT scan = MapVirtualKeyW(key, MAPVK_VK_TO_VSC_EX);
|
||||
input.ki.wScan = static_cast<WORD>(scan & 0xff);
|
||||
input.ki.dwFlags = KEYEVENTF_KEYUP;
|
||||
if ((scan & 0xff00) == 0xe000)
|
||||
input.ki.dwFlags |= KEYEVENTF_EXTENDEDKEY;
|
||||
input.ki.dwExtraInfo = kInjectedKeyboardInputMarker;
|
||||
break;
|
||||
}
|
||||
}
|
||||
LOG_INFO("Privacy input handoff: releasing held input vk=0x{:02X}", key);
|
||||
releases.push_back(input);
|
||||
}
|
||||
if (!releases.empty()) {
|
||||
const UINT count = static_cast<UINT>(releases.size());
|
||||
SetLastError(ERROR_SUCCESS);
|
||||
const UINT sent = SendInput(count, releases.data(), sizeof(INPUT));
|
||||
if (sent != count) {
|
||||
Fault("Release held privacy inputs failed (sent " + std::to_string(sent) +
|
||||
"/" + std::to_string(count) + ")",
|
||||
GetLastError());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// SendInput acknowledges insertion, not delivery. Pump this hook thread
|
||||
// until the tagged releases have passed through and Windows reports no
|
||||
// pressed inputs. The caller cannot publish input_blocked/ready earlier.
|
||||
const auto deadline = GetTickCount64() + 1000;
|
||||
for (;;) {
|
||||
if (!PumpMessages()) {
|
||||
Fault("Privacy input handoff cancelled", ERROR_CANCELLED);
|
||||
return false;
|
||||
}
|
||||
int held = 0;
|
||||
for (int key = 1; key < 256; ++key) {
|
||||
if (GetAsyncKeyState(key) & 0x8000) {
|
||||
held = key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!held) {
|
||||
LOG_INFO("Privacy input handoff complete: released {} held inputs",
|
||||
releases.size());
|
||||
return true;
|
||||
}
|
||||
if (GetTickCount64() >= deadline) {
|
||||
Fault("Privacy input release timed out, still pressed vk=" +
|
||||
std::to_string(held),
|
||||
ERROR_TIMEOUT);
|
||||
return false;
|
||||
}
|
||||
if (MsgWaitForMultipleObjectsEx(1, &stop_, 10, QS_ALLINPUT,
|
||||
MWMO_INPUTAVAILABLE) == WAIT_FAILED) {
|
||||
Fault("Wait for privacy input releases failed", GetLastError());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void PrivacyInputGuard::Run() {
|
||||
current_ = this;
|
||||
running_.store(true);
|
||||
try {
|
||||
keyboard_ = SetWindowsHookExW(WH_KEYBOARD_LL, KeyboardHook,
|
||||
GetModuleHandleW(nullptr), 0);
|
||||
if (!keyboard_)
|
||||
Fault("Install privacy keyboard hook failed", GetLastError());
|
||||
if (keyboard_) {
|
||||
mouse_ =
|
||||
SetWindowsHookExW(WH_MOUSE_LL, MouseHook, GetModuleHandleW(nullptr), 0);
|
||||
if (!mouse_) Fault("Install privacy mouse hook failed", GetLastError());
|
||||
}
|
||||
healthy_.store(keyboard_ && mouse_ && ReleasePressedInputs());
|
||||
last_pump_.store(GetTickCount64());
|
||||
SetEvent(ready_);
|
||||
auto last_refresh = GetTickCount64();
|
||||
LOG_INFO("Privacy input message thread started: thread={}",
|
||||
GetCurrentThreadId());
|
||||
while (keyboard_ && mouse_ &&
|
||||
WaitForSingleObject(stop_, 0) == WAIT_TIMEOUT) {
|
||||
if (!PumpMessages()) break;
|
||||
const auto now = GetTickCount64();
|
||||
if (healthy_.load() && now - last_refresh >= 1000) {
|
||||
last_refresh = now;
|
||||
// Install replacements before retiring old hooks, with no input gap.
|
||||
if (Renew(keyboard_, WH_KEYBOARD_LL, KeyboardHook))
|
||||
Renew(mouse_, WH_MOUSE_LL, MouseHook);
|
||||
}
|
||||
if (MsgWaitForMultipleObjectsEx(1, &stop_, 100, QS_ALLINPUT,
|
||||
MWMO_INPUTAVAILABLE) == WAIT_FAILED) {
|
||||
Fault("Wait for privacy input messages failed", GetLastError());
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
Fault(std::string("Privacy input thread failed: ") + e.what(),
|
||||
ERROR_GEN_FAILURE);
|
||||
SetEvent(ready_);
|
||||
}
|
||||
healthy_.store(false);
|
||||
auto unhook = [this](HHOOK hook) {
|
||||
if (hook && !UnhookWindowsHookEx(hook) &&
|
||||
GetLastError() != ERROR_INVALID_HOOK_HANDLE)
|
||||
Fault("Release privacy input hook failed", GetLastError());
|
||||
};
|
||||
unhook(keyboard_);
|
||||
unhook(mouse_);
|
||||
for (auto hook : retired_) unhook(hook);
|
||||
keyboard_ = mouse_ = nullptr;
|
||||
retired_.clear();
|
||||
physical_keys_.fill(false);
|
||||
current_ = nullptr;
|
||||
running_.store(false);
|
||||
PostMessageW(owner_, WM_NULL, 0, 0);
|
||||
// Windows also removes hooks installed by a thread when that thread exits.
|
||||
LOG_INFO("Privacy input message thread stopped");
|
||||
}
|
||||
|
||||
LRESULT CALLBACK PrivacyInputGuard::KeyboardHook(int code, WPARAM wp,
|
||||
LPARAM lp) {
|
||||
auto self = current_;
|
||||
if (code < 0 || !self) return CallNextHookEx(nullptr, code, wp, lp);
|
||||
const auto& key = *reinterpret_cast<KBDLLHOOKSTRUCT*>(lp);
|
||||
if ((key.flags & LLKHF_INJECTED) &&
|
||||
key.dwExtraInfo == kInjectedKeyboardInputMarker)
|
||||
return CallNextHookEx(nullptr, code, wp, lp);
|
||||
if (!(key.flags & LLKHF_INJECTED) && key.vkCode < 256) {
|
||||
self->physical_keys_[key.vkCode] = !(key.flags & LLKHF_UP);
|
||||
const auto& keys = self->physical_keys_;
|
||||
if (key.vkCode == VK_F12 && !(key.flags & LLKHF_UP) &&
|
||||
(keys[VK_LCONTROL] || keys[VK_RCONTROL]) &&
|
||||
(keys[VK_LMENU] || keys[VK_RMENU]) &&
|
||||
(keys[VK_LSHIFT] || keys[VK_RSHIFT]))
|
||||
PostMessageW(self->owner_, self->exit_message_, 0, 0);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
LRESULT CALLBACK PrivacyInputGuard::MouseHook(int code, WPARAM wp, LPARAM lp) {
|
||||
auto self = current_;
|
||||
if (code < 0 || !self) return CallNextHookEx(nullptr, code, wp, lp);
|
||||
const auto& mouse = *reinterpret_cast<MSLLHOOKSTRUCT*>(lp);
|
||||
if ((mouse.flags & LLMHF_INJECTED) &&
|
||||
mouse.dwExtraInfo == kInjectedMouseInputMarker)
|
||||
return CallNextHookEx(nullptr, code, wp, lp);
|
||||
return 1;
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-10
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _PRIVACY_INPUT_GUARD_H_
|
||||
#define _PRIVACY_INPUT_GUARD_H_
|
||||
|
||||
#include <Windows.h>
|
||||
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
// The owner controls lifecycle; the dedicated message thread owns LL hooks.
|
||||
// Rendering, capture verification and session queries never run on this thread.
|
||||
class PrivacyInputGuard {
|
||||
public:
|
||||
~PrivacyInputGuard();
|
||||
bool Start(HWND owner, UINT exit_message, std::string& error);
|
||||
void Stop();
|
||||
bool Healthy(std::string& error) const;
|
||||
bool stopped() const { return !thread_.joinable(); }
|
||||
|
||||
private:
|
||||
void Run();
|
||||
bool ReleasePressedInputs();
|
||||
bool PumpMessages();
|
||||
void Fault(const std::string& operation, DWORD code);
|
||||
bool Renew(HHOOK& hook, int kind, HOOKPROC procedure);
|
||||
static LRESULT CALLBACK KeyboardHook(int code, WPARAM wp, LPARAM lp);
|
||||
static LRESULT CALLBACK MouseHook(int code, WPARAM wp, LPARAM lp);
|
||||
|
||||
std::thread thread_;
|
||||
HANDLE stop_ = nullptr;
|
||||
HANDLE ready_ = nullptr;
|
||||
HWND owner_ = nullptr;
|
||||
UINT exit_message_ = 0;
|
||||
std::atomic<bool> healthy_{false};
|
||||
std::atomic<bool> running_{false};
|
||||
std::atomic<ULONGLONG> last_pump_{0};
|
||||
mutable std::mutex error_mutex_;
|
||||
std::string error_;
|
||||
HHOOK keyboard_ = nullptr;
|
||||
HHOOK mouse_ = nullptr;
|
||||
std::vector<HHOOK> retired_;
|
||||
std::array<bool, 256> physical_keys_{};
|
||||
inline static thread_local PrivacyInputGuard* current_ = nullptr;
|
||||
};
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif // _PRIVACY_INPUT_GUARD_H_
|
||||
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-11
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#include "privacy_probe_renderer.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "privacy_probe_pattern.h"
|
||||
|
||||
namespace crossdesk {
|
||||
namespace {
|
||||
struct Canvas {
|
||||
HDC dc = CreateCompatibleDC(nullptr);
|
||||
HFONT font = nullptr;
|
||||
HGDIOBJ old_font = nullptr;
|
||||
HBITMAP bitmap = nullptr;
|
||||
HGDIOBJ old_bitmap = nullptr;
|
||||
~Canvas() {
|
||||
if (old_bitmap) SelectObject(dc, old_bitmap);
|
||||
if (old_font) SelectObject(dc, old_font);
|
||||
if (bitmap) DeleteObject(bitmap);
|
||||
if (font) DeleteObject(font);
|
||||
if (dc) DeleteDC(dc);
|
||||
}
|
||||
};
|
||||
bool Failed(const char* operation, std::string& error) {
|
||||
error = std::string(operation) + " (Windows error " +
|
||||
std::to_string(GetLastError()) + ")";
|
||||
return false;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
bool RenderPrivacyProbe(const std::string& title, UINT dpi, int maximum_width,
|
||||
int maximum_height, uint32_t value,
|
||||
PrivacyProbeImage& image, std::string& error) {
|
||||
using Pattern = PrivacyProbePattern;
|
||||
const int length =
|
||||
MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, title.data(),
|
||||
static_cast<int>(title.size()), nullptr, 0);
|
||||
if (length <= 0) return Failed("Decode privacy verification text", error);
|
||||
std::wstring text(length, L'\0');
|
||||
if (!MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, title.data(),
|
||||
static_cast<int>(title.size()), text.data(), length))
|
||||
return Failed("Decode privacy verification text", error);
|
||||
const int available_text_width =
|
||||
maximum_width - Pattern::kTextLeft - Pattern::kPadding;
|
||||
if (available_text_width < 80 ||
|
||||
maximum_height < Pattern::kSide + 2 * Pattern::kPadding) {
|
||||
error = "Display is too small for the privacy verification panel";
|
||||
return false;
|
||||
}
|
||||
Canvas canvas;
|
||||
if (!canvas.dc)
|
||||
return Failed("Create privacy verification drawing context", error);
|
||||
canvas.font = CreateFontW(
|
||||
-MulDiv(28, static_cast<int>(dpi), 96), 0, 0, 0, FW_SEMIBOLD, FALSE,
|
||||
FALSE, FALSE, DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS,
|
||||
ANTIALIASED_QUALITY, DEFAULT_PITCH, L"Microsoft YaHei UI");
|
||||
if (!canvas.font) return Failed("Create privacy verification font", error);
|
||||
canvas.old_font = SelectObject(canvas.dc, canvas.font);
|
||||
if (!canvas.old_font || canvas.old_font == HGDI_ERROR) {
|
||||
canvas.old_font = nullptr;
|
||||
return Failed("Select privacy verification font", error);
|
||||
}
|
||||
SIZE extent{};
|
||||
if (!GetTextExtentPoint32W(canvas.dc, text.data(), length, &extent))
|
||||
return Failed("Measure privacy verification text", error);
|
||||
const int text_width =
|
||||
(std::min)(available_text_width,
|
||||
(std::min)(static_cast<int>(extent.cx) + 4,
|
||||
MulDiv(520, static_cast<int>(dpi), 96)));
|
||||
RECT text_bounds{0, 0, text_width, 0};
|
||||
constexpr UINT text_flags = DT_LEFT | DT_WORDBREAK | DT_NOPREFIX;
|
||||
if (!DrawTextW(canvas.dc, text.data(), length, &text_bounds,
|
||||
text_flags | DT_CALCRECT))
|
||||
return Failed("Lay out privacy verification text", error);
|
||||
const int width = Pattern::kTextLeft + text_width + Pattern::kPadding;
|
||||
const int height =
|
||||
(std::max)(Pattern::kSide, static_cast<int>(text_bounds.bottom)) +
|
||||
2 * Pattern::kPadding;
|
||||
if (height > maximum_height || text_bounds.right > text_width) {
|
||||
error =
|
||||
"Display is too small for localized privacy verification text at this "
|
||||
"DPI";
|
||||
return false;
|
||||
}
|
||||
BITMAPINFO info{};
|
||||
info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
|
||||
info.bmiHeader.biWidth = width;
|
||||
info.bmiHeader.biHeight = -height;
|
||||
info.bmiHeader.biPlanes = 1;
|
||||
info.bmiHeader.biBitCount = 32;
|
||||
info.bmiHeader.biCompression = BI_RGB;
|
||||
void* bits = nullptr;
|
||||
canvas.bitmap =
|
||||
CreateDIBSection(canvas.dc, &info, DIB_RGB_COLORS, &bits, nullptr, 0);
|
||||
if (!canvas.bitmap || !bits)
|
||||
return Failed("Create privacy verification image", error);
|
||||
canvas.old_bitmap = SelectObject(canvas.dc, canvas.bitmap);
|
||||
if (!canvas.old_bitmap || canvas.old_bitmap == HGDI_ERROR) {
|
||||
canvas.old_bitmap = nullptr;
|
||||
return Failed("Select privacy verification image", error);
|
||||
}
|
||||
auto pixels = static_cast<uint32_t*>(bits);
|
||||
std::fill(pixels, pixels + static_cast<size_t>(width) * height, 0xFFF4F6F9u);
|
||||
for (int y = 0; y < height; ++y)
|
||||
for (int x = 0; x < width; ++x)
|
||||
if (x == 0 || y == 0 || x == width - 1 || y == height - 1)
|
||||
pixels[static_cast<size_t>(y) * width + x] = 0xFFD0D6E0u;
|
||||
SetBkMode(canvas.dc, TRANSPARENT);
|
||||
SetTextColor(canvas.dc, RGB(34, 46, 64));
|
||||
RECT text_rect{Pattern::kTextLeft, (height - text_bounds.bottom) / 2,
|
||||
width - Pattern::kPadding, (height + text_bounds.bottom) / 2};
|
||||
// Explicit clipping makes font fallback/overhang unable to reach the code.
|
||||
IntersectClipRect(canvas.dc, text_rect.left, text_rect.top, text_rect.right,
|
||||
text_rect.bottom);
|
||||
if (!DrawTextW(canvas.dc, text.data(), length, &text_rect, text_flags))
|
||||
return Failed("Draw privacy verification text", error);
|
||||
if (!GdiFlush()) return Failed("Flush privacy verification text", error);
|
||||
for (size_t index = 0; index < static_cast<size_t>(width) * height; ++index)
|
||||
pixels[index] |= 0xFF000000u;
|
||||
for (int row = 0; row < Pattern::kRows; ++row)
|
||||
for (int column = 0; column < Pattern::kColumns; ++column) {
|
||||
const uint32_t color =
|
||||
Pattern::White(value, column, row) ? 0xFFFFFFFFu : 0xFF000000u;
|
||||
for (int y = 0; y < Pattern::kCell; ++y)
|
||||
for (int x = 0; x < Pattern::kCell; ++x)
|
||||
pixels[static_cast<size_t>(Pattern::kPadding + row * Pattern::kCell +
|
||||
y) *
|
||||
width +
|
||||
Pattern::kPadding + column * Pattern::kCell + x] = color;
|
||||
}
|
||||
image.width = width;
|
||||
image.height = height;
|
||||
image.pixels.assign(pixels, pixels + static_cast<size_t>(width) * height);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-11
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _PRIVACY_PROBE_RENDERER_H_
|
||||
#define _PRIVACY_PROBE_RENDERER_H_
|
||||
|
||||
#include <Windows.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
struct PrivacyProbeImage {
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
std::vector<uint32_t> pixels;
|
||||
};
|
||||
|
||||
// Produces an opaque BGRA panel. The localized text and the machine-readable
|
||||
// square occupy disjoint rectangles. Caller runs on the owning window thread.
|
||||
bool RenderPrivacyProbe(const std::string& title, UINT dpi, int maximum_width,
|
||||
int maximum_height, uint32_t value,
|
||||
PrivacyProbeImage& image, std::string& error);
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif // _PRIVACY_PROBE_RENDERER_H_
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-11
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _PRIVACY_WINDOW_VISIBILITY_H_
|
||||
#define _PRIVACY_WINDOW_VISIBILITY_H_
|
||||
|
||||
#include <Windows.h>
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
// Called for a visible foreign window whose full bounds intersect a cover.
|
||||
// Inspect it on every check: an OSD helper can grow into a content window.
|
||||
inline bool IsNonObstructingPrivacyWindow(HWND window, const RECT& bounds) {
|
||||
const auto styles = GetWindowLongPtrW(window, GWL_EXSTYLE);
|
||||
if (!(styles & WS_EX_LAYERED)) return false;
|
||||
|
||||
BYTE alpha = 0;
|
||||
DWORD flags = 0;
|
||||
// WS_EX_TRANSPARENT alone does not imply invisible pixels. Likewise,
|
||||
// UpdateLayeredWindow windows can reject this query; failure proves nothing.
|
||||
if (GetLayeredWindowAttributes(window, nullptr, &alpha, &flags) &&
|
||||
(flags & LWA_ALPHA) && alpha == 0)
|
||||
return true;
|
||||
|
||||
constexpr LONG_PTR helper_styles =
|
||||
WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE | WS_EX_TRANSPARENT;
|
||||
const auto width = static_cast<int64_t>(bounds.right) - bounds.left;
|
||||
const auto height = static_cast<int64_t>(bounds.bottom) - bounds.top;
|
||||
// Single-pixel, nonactivating OSD placeholders cannot display meaningful
|
||||
// desktop content. Use the whole window, never its clipped intersection;
|
||||
// larger OSDs and windows with unknown opacity still require coverage checks.
|
||||
return (styles & helper_styles) == helper_styles && width == 1 && height == 1;
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif // _PRIVACY_WINDOW_VISIBILITY_H_
|
||||
@@ -1,11 +1,12 @@
|
||||
#include "screen_capturer_dxgi.h"
|
||||
|
||||
#include <display_stream_id.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <display_stream_id.h>
|
||||
#include "libyuv.h"
|
||||
#include "rd_log.h"
|
||||
|
||||
@@ -218,11 +219,13 @@ void ScreenCapturerDxgi::EnumerateDisplays() {
|
||||
mi.rcMonitor.left, mi.rcMonitor.top,
|
||||
mi.rcMonitor.right, mi.rcMonitor.bottom);
|
||||
// primary first
|
||||
if (is_primary)
|
||||
if (is_primary) {
|
||||
display_info_list_.insert(display_info_list_.begin(), info);
|
||||
else
|
||||
outputs_.insert(outputs_.begin(), output);
|
||||
} else {
|
||||
display_info_list_.push_back(info);
|
||||
outputs_.push_back(output);
|
||||
outputs_.push_back(output);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -246,6 +249,11 @@ bool ScreenCapturerDxgi::CreateDuplicationForMonitor(int monitor_index) {
|
||||
}
|
||||
|
||||
staging_.Reset();
|
||||
DXGI_OUTDUPL_DESC desc{};
|
||||
duplication_->GetDesc(&desc);
|
||||
rotation_ = desc.Rotation;
|
||||
LOG_INFO("DXGI: duplication ready, monitor={}, rotation={}", monitor_index,
|
||||
static_cast<int>(rotation_));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -276,6 +284,7 @@ bool ScreenCapturerDxgi::RecreateDuplicationForCurrentMonitor() {
|
||||
}
|
||||
|
||||
void ScreenCapturerDxgi::ReleaseDuplication() {
|
||||
++duplication_generation_;
|
||||
staging_.Reset();
|
||||
if (duplication_) {
|
||||
duplication_->ReleaseFrame();
|
||||
@@ -284,16 +293,27 @@ void ScreenCapturerDxgi::ReleaseDuplication() {
|
||||
}
|
||||
|
||||
void ScreenCapturerDxgi::CaptureLoop() {
|
||||
const int timeout_ms = 33;
|
||||
const int timeout_ms = (std::max)(1, 1000 / (std::max)(1, fps_));
|
||||
bool cached_frame_valid = false;
|
||||
int cached_monitor = -1;
|
||||
uint64_t cached_generation = 0;
|
||||
std::vector<uint8_t> rotated_frame;
|
||||
auto last_duplication_retry =
|
||||
std::chrono::steady_clock::now() - std::chrono::milliseconds(1000);
|
||||
while (running_) {
|
||||
if (paused_) {
|
||||
cached_frame_valid = false;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Duplication/staging textures belong to one monitor. Do not release or
|
||||
// replace them in SwitchTo while AcquireNextFrame/Map is using them.
|
||||
std::unique_lock capture_lock(switch_mutex_);
|
||||
if (paused_ || !running_) continue;
|
||||
if (!duplication_) {
|
||||
cached_frame_valid = false;
|
||||
capture_lock.unlock();
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
if (now - last_duplication_retry >= std::chrono::milliseconds(500)) {
|
||||
last_duplication_retry = now;
|
||||
@@ -303,15 +323,32 @@ void ScreenCapturerDxgi::CaptureLoop() {
|
||||
continue;
|
||||
}
|
||||
|
||||
const int frame_monitor = monitor_index_.load();
|
||||
DXGI_OUTDUPL_FRAME_INFO frame_info{};
|
||||
Microsoft::WRL::ComPtr<IDXGIResource> desktop_resource;
|
||||
HRESULT hr = duplication_->AcquireNextFrame(
|
||||
timeout_ms, &frame_info, desktop_resource.GetAddressOf());
|
||||
if (hr == DXGI_ERROR_WAIT_TIMEOUT) {
|
||||
// DXGI explicitly reports an unchanged desktop. Re-emit only a frame
|
||||
// successfully acquired for this monitor, so a static screen still has
|
||||
// a delivery heartbeat after temporary privacy probes are removed.
|
||||
if (cached_frame_valid && cached_generation == duplication_generation_ &&
|
||||
cached_monitor == monitor_index_.load() && callback_ && nv12_frame_) {
|
||||
const auto stream_id = MakeDisplayStreamId(cached_monitor);
|
||||
capture_lock.unlock();
|
||||
callback_(nv12_frame_, nv12_width_ * nv12_height_ * 3 / 2, nv12_width_,
|
||||
nv12_height_, stream_id.c_str(), nullptr);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Never replay a cached image after an acquisition/conversion failure or
|
||||
// a backend rebuild. Normal error/reset handling must pause privacy.
|
||||
cached_frame_valid = false;
|
||||
if (FAILED(hr)) {
|
||||
LOG_ERROR("DXGI: AcquireNextFrame failed, hr={}", (int)hr);
|
||||
capture_lock.unlock();
|
||||
if (callback_)
|
||||
callback_(nullptr, ScreenCapturer::kBackendReset, 0, 0, "", nullptr);
|
||||
RecreateDuplicationForCurrentMonitor();
|
||||
continue;
|
||||
}
|
||||
@@ -356,9 +393,42 @@ void ScreenCapturerDxgi::CaptureLoop() {
|
||||
continue;
|
||||
}
|
||||
|
||||
auto pixels = static_cast<const uint8_t*>(mapped.pData);
|
||||
int stride = static_cast<int>(mapped.RowPitch);
|
||||
int logical_width = static_cast<int>(src_desc.Width);
|
||||
int logical_height = static_cast<int>(src_desc.Height);
|
||||
libyuv::RotationMode rotation = libyuv::kRotate0;
|
||||
switch (rotation_) {
|
||||
case DXGI_MODE_ROTATION_ROTATE90:
|
||||
rotation = libyuv::kRotate90;
|
||||
break;
|
||||
case DXGI_MODE_ROTATION_ROTATE180:
|
||||
rotation = libyuv::kRotate180;
|
||||
break;
|
||||
case DXGI_MODE_ROTATION_ROTATE270:
|
||||
rotation = libyuv::kRotate270;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (rotation != libyuv::kRotate0) {
|
||||
const bool swap_axes = rotation != libyuv::kRotate180;
|
||||
const int rotated_width = swap_axes ? logical_height : logical_width;
|
||||
rotated_frame.resize(static_cast<size_t>(logical_width) * logical_height *
|
||||
4);
|
||||
if (libyuv::ARGBRotate(pixels, stride, rotated_frame.data(),
|
||||
rotated_width * 4, logical_width, logical_height,
|
||||
rotation) != 0) {
|
||||
d3d_context_->Unmap(staging_.Get(), 0);
|
||||
duplication_->ReleaseFrame();
|
||||
continue;
|
||||
}
|
||||
pixels = rotated_frame.data();
|
||||
stride = rotated_width * 4;
|
||||
if (swap_axes) std::swap(logical_width, logical_height);
|
||||
}
|
||||
int even_width = logical_width & ~1;
|
||||
int even_height = static_cast<int>(src_desc.Height) & ~1;
|
||||
int even_height = logical_height & ~1;
|
||||
if (even_width <= 0 || even_height <= 0) {
|
||||
d3d_context_->Unmap(staging_.Get(), 0);
|
||||
duplication_->ReleaseFrame();
|
||||
@@ -374,25 +444,29 @@ void ScreenCapturerDxgi::CaptureLoop() {
|
||||
nv12_height_ = even_height;
|
||||
}
|
||||
|
||||
libyuv::ARGBToNV12(static_cast<const uint8_t*>(mapped.pData),
|
||||
static_cast<int>(mapped.RowPitch), nv12_frame_,
|
||||
even_width, nv12_frame_ + even_width * even_height,
|
||||
even_width, even_width, even_height);
|
||||
const int converted =
|
||||
libyuv::ARGBToNV12(pixels, stride, nv12_frame_, even_width,
|
||||
nv12_frame_ + even_width * even_height, even_width,
|
||||
even_width, even_height);
|
||||
|
||||
if (callback_) {
|
||||
int idx = monitor_index_.load();
|
||||
cached_generation = duplication_generation_;
|
||||
d3d_context_->Unmap(staging_.Get(), 0);
|
||||
duplication_->ReleaseFrame();
|
||||
capture_lock.unlock();
|
||||
|
||||
if (converted == 0 && frame_monitor == monitor_index_.load() && callback_) {
|
||||
int idx = frame_monitor;
|
||||
if (idx >= 0 && idx < static_cast<int>(display_info_list_.size())) {
|
||||
const std::string stream_id = MakeDisplayStreamId(idx);
|
||||
callback_(nv12_frame_, nv12_size, even_width, even_height,
|
||||
stream_id.c_str(), nullptr);
|
||||
cached_monitor = idx;
|
||||
cached_frame_valid = true;
|
||||
} else {
|
||||
LOG_ERROR("DXGI: CaptureLoop invalid monitor_index {} (list size {})",
|
||||
idx, display_info_list_.size());
|
||||
}
|
||||
}
|
||||
|
||||
d3d_context_->Unmap(staging_.Get(), 0);
|
||||
duplication_->ReleaseFrame();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,8 @@ class ScreenCapturerDxgi : public ScreenCapturer {
|
||||
Microsoft::WRL::ComPtr<ID3D11DeviceContext> d3d_context_;
|
||||
Microsoft::WRL::ComPtr<IDXGIOutputDuplication> duplication_;
|
||||
Microsoft::WRL::ComPtr<ID3D11Texture2D> staging_;
|
||||
DXGI_MODE_ROTATION rotation_ = DXGI_MODE_ROTATION_IDENTITY;
|
||||
uint64_t duplication_generation_ = 0;
|
||||
|
||||
std::atomic<bool> running_{false};
|
||||
std::atomic<bool> paused_{false};
|
||||
|
||||
@@ -351,6 +351,10 @@ int ScreenCapturerWgc::ResetToInitialMonitor() {
|
||||
}
|
||||
void ScreenCapturerWgc::OnFrame(const WgcSession::wgc_session_frame& frame,
|
||||
int id) {
|
||||
if (!frame.data && frame.width == 0 && frame.height == 0) {
|
||||
if (on_data_) on_data_(nullptr, ScreenCapturer::kBackendReset, 0, 0, "", nullptr);
|
||||
return;
|
||||
}
|
||||
if (!running_ || !on_data_) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -127,6 +127,13 @@ class WgcPluginCapturer final : public ScreenCapturer {
|
||||
DestroyFn destroy_fn_ = nullptr;
|
||||
};
|
||||
|
||||
const char* CaptureBackendName(const ScreenCapturer* capturer) {
|
||||
if (dynamic_cast<const ScreenCapturerDxgi*>(capturer)) return "DXGI";
|
||||
if (dynamic_cast<const WgcPluginCapturer*>(capturer)) return "WGC";
|
||||
if (dynamic_cast<const ScreenCapturerGdi*>(capturer)) return "GDI";
|
||||
return "unavailable";
|
||||
}
|
||||
|
||||
std::string BuildSecureCaptureCommand(int left, int top, int width, int height,
|
||||
bool show_cursor,
|
||||
const std::string& stage,
|
||||
@@ -330,7 +337,13 @@ bool QuerySecureDesktopHelperFrame(
|
||||
ScreenCapturerWin::ScreenCapturerWin() {}
|
||||
ScreenCapturerWin::~ScreenCapturerWin() { Destroy(); }
|
||||
|
||||
void ScreenCapturerWin::NotifyPrivacyCapture(bool running) {
|
||||
if (privacy_)
|
||||
privacy_->CaptureChanged(CaptureBackendName(impl_.get()), running);
|
||||
}
|
||||
|
||||
int ScreenCapturerWin::Init(const int fps, cb_desktop_data cb) {
|
||||
NotifyPrivacyCapture(false);
|
||||
fps_ = fps;
|
||||
cb_orig_ = cb;
|
||||
native_output_logged_.store(false, std::memory_order_relaxed);
|
||||
@@ -344,12 +357,17 @@ int ScreenCapturerWin::Init(const int fps, cb_desktop_data cb) {
|
||||
cb_ = [this](unsigned char* data, int size, int w, int h,
|
||||
const char* reported_stream_id,
|
||||
const MiniRtcNativeVideoFrame* native_frame) {
|
||||
if (size == ScreenCapturer::kBackendReset) {
|
||||
if (privacy_) privacy_->CaptureInterrupted();
|
||||
return;
|
||||
}
|
||||
if (secure_desktop_capture_active_.load(std::memory_order_relaxed)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const char* raw_stream_id = reported_stream_id ? reported_stream_id : "";
|
||||
std::string mapped_stream_id;
|
||||
DisplayInfo privacy_display{"", 0, 0, 0, 0};
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(alias_mutex_);
|
||||
auto it = stream_id_alias_.find(raw_stream_id);
|
||||
@@ -363,6 +381,21 @@ int ScreenCapturerWin::Init(const int fps, cb_desktop_data cb) {
|
||||
mapped_stream_id = ResolveDisplayStreamId(
|
||||
mapped_stream_id.c_str(), canonical_displays_.size(),
|
||||
monitor_index_.load(std::memory_order_relaxed));
|
||||
for (size_t index = 0; index < canonical_displays_.size(); ++index) {
|
||||
if (mapped_stream_id == MakeDisplayStreamId(index)) {
|
||||
privacy_display = canonical_displays_[index];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (privacy_) {
|
||||
if (privacy_->Engaged() && !IsWindowsPrivacyDesktopAvailable()) {
|
||||
privacy_->Fail("Secure or unavailable desktop; privacy cannot cover system security UI. Remote operation paused.");
|
||||
return;
|
||||
}
|
||||
if (!privacy_->ObserveFrame(data, size > 0 ? static_cast<size_t>(size) : 0,
|
||||
w, h, privacy_display.left, privacy_display.top,
|
||||
privacy_display.width, privacy_display.height)) return;
|
||||
}
|
||||
if (mapped_stream_id.empty()) {
|
||||
if (!invalid_stream_id_logged_.exchange(true,
|
||||
@@ -393,50 +426,34 @@ int ScreenCapturerWin::Init(const int fps, cb_desktop_data cb) {
|
||||
native_frame);
|
||||
};
|
||||
|
||||
impl_.reset();
|
||||
int ret = -1;
|
||||
|
||||
impl_ = WgcPluginCapturer::Create();
|
||||
impl_is_wgc_plugin_ = (impl_ != nullptr);
|
||||
ret = impl_ ? impl_->Init(fps_, cb_) : -1;
|
||||
if (ret == 0) {
|
||||
LOG_INFO("Windows capturer: using WGC plugin");
|
||||
auto try_init = [&](std::unique_ptr<ScreenCapturer> candidate) {
|
||||
ret = candidate ? candidate->Init(fps_, cb_) : -1;
|
||||
if (ret != 0) {
|
||||
LOG_WARN("Windows capturer: {} init failed (ret={})",
|
||||
CaptureBackendName(candidate.get()), ret);
|
||||
return false;
|
||||
}
|
||||
impl_ = std::move(candidate);
|
||||
return true;
|
||||
};
|
||||
// Short-circuiting also keeps the unused WGC plugin unloaded.
|
||||
if ((IsBackendEnabled(ScreenCaptureMethod::Dxgi) &&
|
||||
try_init(std::make_unique<ScreenCapturerDxgi>())) ||
|
||||
(IsBackendEnabled(ScreenCaptureMethod::Wgc) &&
|
||||
try_init(WgcPluginCapturer::Create())) ||
|
||||
(IsBackendEnabled(ScreenCaptureMethod::Gdi) &&
|
||||
try_init(std::make_unique<ScreenCapturerGdi>()))) {
|
||||
LOG_INFO("Windows capturer: using {}", CaptureBackendName(impl_.get()));
|
||||
BuildCanonicalFromImpl();
|
||||
monitor_index_.store(0, std::memory_order_relaxed);
|
||||
initial_monitor_index_ = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
LOG_WARN("Windows capturer: WGC plugin init failed (ret={}), try DXGI", ret);
|
||||
impl_.reset();
|
||||
impl_is_wgc_plugin_ = false;
|
||||
|
||||
impl_ = std::make_unique<ScreenCapturerDxgi>();
|
||||
impl_is_wgc_plugin_ = false;
|
||||
ret = impl_->Init(fps_, cb_);
|
||||
if (ret == 0) {
|
||||
LOG_INFO("Windows capturer: using DXGI Desktop Duplication");
|
||||
BuildCanonicalFromImpl();
|
||||
monitor_index_.store(0, std::memory_order_relaxed);
|
||||
initial_monitor_index_ = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
LOG_WARN("Windows capturer: DXGI init failed (ret={}), fallback to GDI", ret);
|
||||
impl_.reset();
|
||||
|
||||
impl_ = std::make_unique<ScreenCapturerGdi>();
|
||||
impl_is_wgc_plugin_ = false;
|
||||
ret = impl_->Init(fps_, cb_);
|
||||
if (ret == 0) {
|
||||
LOG_INFO("Windows capturer: using GDI BitBlt");
|
||||
BuildCanonicalFromImpl();
|
||||
monitor_index_.store(0, std::memory_order_relaxed);
|
||||
initial_monitor_index_ = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
LOG_ERROR("Windows capturer: all implementations failed, ret={}", ret);
|
||||
impl_.reset();
|
||||
LOG_ERROR("Windows capturer: selected capture method {} failed, ret={}",
|
||||
static_cast<int>(capture_method_), ret);
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -446,7 +463,6 @@ int ScreenCapturerWin::Destroy() {
|
||||
if (impl_) {
|
||||
impl_->Destroy();
|
||||
impl_.reset();
|
||||
impl_is_wgc_plugin_ = false;
|
||||
}
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(alias_mutex_);
|
||||
@@ -459,8 +475,15 @@ int ScreenCapturerWin::Destroy() {
|
||||
|
||||
void ScreenCapturerWin::EmitCapturedFrame(
|
||||
unsigned char* data, int size, int width, int height,
|
||||
const char* stream_id, const MiniRtcNativeVideoFrame* native_frame) {
|
||||
if (!cb_orig_) {
|
||||
const char* stream_id, const MiniRtcNativeVideoFrame* native_frame,
|
||||
bool from_secure_desktop) {
|
||||
// A helper IPC already in flight when enable began must not bypass the
|
||||
// verified ordinary-desktop callback when it completes later.
|
||||
if (from_secure_desktop && privacy_ && privacy_->Engaged()) {
|
||||
privacy_->Fail("Secure desktop helper frame arrived during privacy; remote operation paused");
|
||||
return;
|
||||
}
|
||||
if (!cb_orig_ || (privacy_ && !privacy_->RemoteAllowed())) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -499,11 +522,43 @@ void ScreenCapturerWin::EmitCapturedFrame(
|
||||
owned_frame->Release();
|
||||
}
|
||||
|
||||
void ScreenCapturerWin::RestoreMonitor(int monitor_index) {
|
||||
RebuildAliasesFromImpl();
|
||||
if (monitor_index > 0 && impl_->SwitchTo(monitor_index) != 0) {
|
||||
monitor_index_.store(0, std::memory_order_relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
bool ScreenCapturerWin::TryStartBackend(
|
||||
std::unique_ptr<ScreenCapturer> candidate, int monitor_index) {
|
||||
if (!candidate) {
|
||||
LOG_WARN("Windows capturer: WGC plugin unavailable");
|
||||
return false;
|
||||
}
|
||||
const char* name = CaptureBackendName(candidate.get());
|
||||
int ret = candidate->Init(fps_, cb_);
|
||||
if (ret == 0)
|
||||
ret = candidate->Start(show_cursor_.load(std::memory_order_relaxed));
|
||||
if (ret != 0) {
|
||||
LOG_WARN("Windows capturer: {} initialization/start failed (ret={})", name,
|
||||
ret);
|
||||
candidate->Destroy();
|
||||
return false;
|
||||
}
|
||||
// Commit only after the replacement is running. Failed candidates release
|
||||
// their resources without replacing the current backend.
|
||||
impl_ = std::move(candidate);
|
||||
RestoreMonitor(monitor_index);
|
||||
LOG_INFO("Windows capturer: started {}", name);
|
||||
return true;
|
||||
}
|
||||
|
||||
int ScreenCapturerWin::Start(bool show_cursor) {
|
||||
if (!impl_) return -1;
|
||||
if (running_.load(std::memory_order_relaxed)) {
|
||||
return 0;
|
||||
}
|
||||
NotifyPrivacyCapture(false);
|
||||
|
||||
show_cursor_.store(show_cursor, std::memory_order_relaxed);
|
||||
paused_.store(false, std::memory_order_relaxed);
|
||||
@@ -525,40 +580,23 @@ int ScreenCapturerWin::Start(bool show_cursor) {
|
||||
}
|
||||
}
|
||||
if (ret != 0) {
|
||||
if (capture_method_ != ScreenCaptureMethod::Auto) {
|
||||
LOG_ERROR("Windows capturer: selected backend {} failed to start (ret={})",
|
||||
CaptureBackendName(impl_.get()), ret);
|
||||
return ret;
|
||||
}
|
||||
LOG_WARN("Windows capturer: refresh/start failed (ret={}), trying fallback",
|
||||
ret);
|
||||
|
||||
auto try_init_start = [&](std::unique_ptr<ScreenCapturer> cand) -> bool {
|
||||
int r = cand->Init(fps_, cb_);
|
||||
if (r != 0) return false;
|
||||
int s = cand->Start(show_cursor);
|
||||
if (s == 0) {
|
||||
impl_ = std::move(cand);
|
||||
impl_is_wgc_plugin_ = false;
|
||||
RebuildAliasesFromImpl();
|
||||
if (requested_monitor > 0 &&
|
||||
impl_->SwitchTo(requested_monitor) != 0) {
|
||||
monitor_index_.store(0, std::memory_order_relaxed);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
bool fallback_started = false;
|
||||
if (impl_is_wgc_plugin_) {
|
||||
if (try_init_start(std::make_unique<ScreenCapturerDxgi>())) {
|
||||
LOG_INFO("Windows capturer: fallback to DXGI");
|
||||
fallback_started = true;
|
||||
} else if (try_init_start(std::make_unique<ScreenCapturerGdi>())) {
|
||||
LOG_INFO("Windows capturer: fallback to GDI");
|
||||
fallback_started = true;
|
||||
}
|
||||
} else if (dynamic_cast<ScreenCapturerDxgi*>(impl_.get())) {
|
||||
if (try_init_start(std::make_unique<ScreenCapturerGdi>())) {
|
||||
LOG_INFO("Windows capturer: fallback to GDI");
|
||||
fallback_started = true;
|
||||
}
|
||||
if (dynamic_cast<ScreenCapturerDxgi*>(impl_.get()) &&
|
||||
TryStartBackend(WgcPluginCapturer::Create(), requested_monitor)) {
|
||||
fallback_started = true;
|
||||
}
|
||||
if (!fallback_started && (dynamic_cast<WgcPluginCapturer*>(impl_.get()) ||
|
||||
dynamic_cast<ScreenCapturerDxgi*>(impl_.get()))) {
|
||||
fallback_started = TryStartBackend(std::make_unique<ScreenCapturerGdi>(),
|
||||
requested_monitor);
|
||||
}
|
||||
|
||||
if (!fallback_started) {
|
||||
@@ -568,6 +606,7 @@ int ScreenCapturerWin::Start(bool show_cursor) {
|
||||
}
|
||||
|
||||
running_.store(true, std::memory_order_relaxed);
|
||||
NotifyPrivacyCapture(true);
|
||||
secure_desktop_capture_active_.store(false, std::memory_order_relaxed);
|
||||
post_secure_desktop_waiting_for_frame_.store(false,
|
||||
std::memory_order_relaxed);
|
||||
@@ -581,6 +620,7 @@ int ScreenCapturerWin::Start(bool show_cursor) {
|
||||
}
|
||||
|
||||
int ScreenCapturerWin::Stop() {
|
||||
NotifyPrivacyCapture(false);
|
||||
running_.store(false, std::memory_order_relaxed);
|
||||
secure_desktop_capture_active_.store(false, std::memory_order_relaxed);
|
||||
post_secure_desktop_waiting_for_frame_.store(false,
|
||||
@@ -610,9 +650,12 @@ int ScreenCapturerWin::Resume(int monitor_index) {
|
||||
|
||||
int ScreenCapturerWin::SwitchTo(int monitor_index) {
|
||||
if (!impl_) return -1;
|
||||
if (privacy_) privacy_->DisplayChanging();
|
||||
const int ret = impl_->SwitchTo(monitor_index);
|
||||
if (ret == 0) {
|
||||
monitor_index_.store(monitor_index, std::memory_order_relaxed);
|
||||
} else if (privacy_) {
|
||||
privacy_->Fail("Display switch failed; remote operation paused");
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
@@ -735,53 +778,19 @@ void ScreenCapturerWin::StopSecureCaptureThread() {
|
||||
}
|
||||
|
||||
bool ScreenCapturerWin::RestartCaptureBackendAfterSecureDesktop() {
|
||||
NotifyPrivacyCapture(false);
|
||||
if (!impl_ || !running_.load(std::memory_order_relaxed)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const bool show_cursor = show_cursor_.load(std::memory_order_relaxed);
|
||||
const int current_monitor = monitor_index_.load(std::memory_order_relaxed);
|
||||
auto restore_monitor = [&]() {
|
||||
RebuildAliasesFromImpl();
|
||||
if (current_monitor > 0 && impl_->SwitchTo(current_monitor) != 0) {
|
||||
monitor_index_.store(0, std::memory_order_relaxed);
|
||||
}
|
||||
};
|
||||
auto try_started_backend = [&](std::unique_ptr<ScreenCapturer> cand,
|
||||
const char* name,
|
||||
bool is_wgc_plugin) -> bool {
|
||||
if (!cand) {
|
||||
return false;
|
||||
}
|
||||
const int init_ret = cand->Init(fps_, cb_);
|
||||
if (init_ret != 0) {
|
||||
LOG_WARN("Windows capturer: {} init after secure desktop failed (ret={})",
|
||||
name, init_ret);
|
||||
return false;
|
||||
}
|
||||
const int start_ret = cand->Start(show_cursor);
|
||||
if (start_ret != 0) {
|
||||
LOG_WARN(
|
||||
"Windows capturer: {} start after secure desktop failed (ret={})",
|
||||
name, start_ret);
|
||||
cand->Destroy();
|
||||
return false;
|
||||
}
|
||||
if (impl_) {
|
||||
impl_->Destroy();
|
||||
}
|
||||
impl_ = std::move(cand);
|
||||
impl_is_wgc_plugin_ = is_wgc_plugin;
|
||||
restore_monitor();
|
||||
LOG_INFO("Windows capturer: restarted {} after secure desktop", name);
|
||||
return true;
|
||||
};
|
||||
|
||||
LOG_INFO("Windows capturer: restarting capture backend after secure desktop");
|
||||
impl_->Stop();
|
||||
int ret = impl_->Start(show_cursor);
|
||||
if (ret == 0) {
|
||||
restore_monitor();
|
||||
RestoreMonitor(current_monitor);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -795,20 +804,16 @@ bool ScreenCapturerWin::RestartCaptureBackendAfterSecureDesktop() {
|
||||
ret = impl_->Start(show_cursor);
|
||||
}
|
||||
if (ret == 0) {
|
||||
restore_monitor();
|
||||
RestoreMonitor(current_monitor);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (impl_is_wgc_plugin_ &&
|
||||
try_started_backend(WgcPluginCapturer::Create(), "WGC plugin", true)) {
|
||||
return true;
|
||||
}
|
||||
if (try_started_backend(std::make_unique<ScreenCapturerDxgi>(), "DXGI",
|
||||
false)) {
|
||||
return true;
|
||||
}
|
||||
if (try_started_backend(std::make_unique<ScreenCapturerGdi>(), "GDI",
|
||||
false)) {
|
||||
if ((IsBackendEnabled(ScreenCaptureMethod::Dxgi) &&
|
||||
TryStartBackend(std::make_unique<ScreenCapturerDxgi>(), current_monitor)) ||
|
||||
(IsBackendEnabled(ScreenCaptureMethod::Wgc) &&
|
||||
TryStartBackend(WgcPluginCapturer::Create(), current_monitor)) ||
|
||||
(IsBackendEnabled(ScreenCaptureMethod::Gdi) &&
|
||||
TryStartBackend(std::make_unique<ScreenCapturerGdi>(), current_monitor))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1123,6 +1128,14 @@ void ScreenCapturerWin::SecureDesktopCaptureLoop() {
|
||||
std::vector<uint8_t> secure_frame;
|
||||
|
||||
while (running_.load(std::memory_order_relaxed)) {
|
||||
// A privacy session must NEVER continue using the helper's secure-desktop
|
||||
// GDI frames. Its HWNDs belong to the ordinary interactive desktop only.
|
||||
if (privacy_ && privacy_->Engaged()) {
|
||||
if (!IsWindowsPrivacyDesktopAvailable())
|
||||
privacy_->Fail("Secure desktop or lock screen; remote operation paused. Privacy cannot cover Windows security UI.");
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(25));
|
||||
continue;
|
||||
}
|
||||
if (paused_.load(std::memory_order_relaxed)) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||
continue;
|
||||
@@ -1295,7 +1308,7 @@ void ScreenCapturerWin::SecureDesktopCaptureLoop() {
|
||||
if (frame_delivered && !secure_frame.empty()) {
|
||||
EmitCapturedFrame(secure_frame.data(),
|
||||
static_cast<int>(secure_frame.size()), captured_width,
|
||||
captured_height, display_name.c_str());
|
||||
captured_height, display_name.c_str(), nullptr, true);
|
||||
}
|
||||
|
||||
if (!frame_delivered && !frame_pending) {
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
#include <vector>
|
||||
|
||||
#include "screen_capturer.h"
|
||||
#include "screen_capture_method.h"
|
||||
#include "privacy_controller.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
@@ -43,10 +45,19 @@ class ScreenCapturerWin : public ScreenCapturer {
|
||||
int ResetToInitialMonitor() override;
|
||||
|
||||
std::vector<DisplayInfo> GetDisplayInfoList() override;
|
||||
void SetPrivacyController(PrivacyController* privacy) { privacy_ = privacy; }
|
||||
// Set before Init(), while capture is stopped.
|
||||
void SetCaptureMethod(ScreenCaptureMethod method) { capture_method_ = method; }
|
||||
|
||||
private:
|
||||
std::unique_ptr<ScreenCapturer> impl_;
|
||||
bool impl_is_wgc_plugin_ = false;
|
||||
ScreenCaptureMethod capture_method_ = ScreenCaptureMethod::Auto;
|
||||
bool IsBackendEnabled(ScreenCaptureMethod method) const {
|
||||
return capture_method_ == ScreenCaptureMethod::Auto ||
|
||||
capture_method_ == method;
|
||||
}
|
||||
PrivacyController* privacy_ = nullptr;
|
||||
void NotifyPrivacyCapture(bool running);
|
||||
int fps_ = 60;
|
||||
cb_desktop_data cb_;
|
||||
cb_desktop_data cb_orig_;
|
||||
@@ -86,9 +97,13 @@ class ScreenCapturerWin : public ScreenCapturer {
|
||||
|
||||
void BuildCanonicalFromImpl();
|
||||
void RebuildAliasesFromImpl();
|
||||
void RestoreMonitor(int monitor_index);
|
||||
bool TryStartBackend(std::unique_ptr<ScreenCapturer> candidate,
|
||||
int monitor_index);
|
||||
void EmitCapturedFrame(unsigned char* data, int size, int width, int height,
|
||||
const char* stream_id,
|
||||
const MiniRtcNativeVideoFrame* native_frame = nullptr);
|
||||
const MiniRtcNativeVideoFrame* native_frame = nullptr,
|
||||
bool from_secure_desktop = false);
|
||||
void StopSecureCaptureThread();
|
||||
bool RestartCaptureBackendAfterSecureDesktop();
|
||||
void SecureDesktopCaptureLoop();
|
||||
|
||||
@@ -339,6 +339,7 @@ void WgcSessionImpl::OnClosed(
|
||||
winrt::Windows::Graphics::Capture::GraphicsCaptureItem const&,
|
||||
winrt::Windows::Foundation::IInspectable const&) {
|
||||
std::lock_guard locker(lock_);
|
||||
if (observer_) observer_->OnFrame({}, id_);
|
||||
const bool was_running = is_running_;
|
||||
const bool was_paused = is_paused_;
|
||||
try {
|
||||
|
||||
@@ -1,104 +1,95 @@
|
||||
#include "speaker_capturer_wasapi.h"
|
||||
|
||||
#include <utility>
|
||||
|
||||
#include "rd_log.h"
|
||||
|
||||
#define MINIAUDIO_IMPLEMENTATION
|
||||
#include "miniaudio.h"
|
||||
|
||||
#define SAVE_AUDIO_FILE 0
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
static ma_device_config device_config_;
|
||||
static ma_device device_;
|
||||
static ma_format format_ = ma_format_s16;
|
||||
static ma_uint32 sample_rate_ = ma_standard_sample_rate_48000;
|
||||
static ma_uint32 channels_ = 1;
|
||||
static FILE* fp_ = nullptr;
|
||||
|
||||
void data_callback(ma_device* pDevice, void* pOutput, const void* pInput,
|
||||
ma_uint32 frameCount) {
|
||||
SpeakerCapturerWasapi* ptr = (SpeakerCapturerWasapi*)pDevice->pUserData;
|
||||
if (ptr) {
|
||||
if (SAVE_AUDIO_FILE) {
|
||||
fwrite(pInput, frameCount * ma_get_bytes_per_frame(format_, channels_), 1,
|
||||
fp_);
|
||||
}
|
||||
|
||||
ptr->GetCallback()((unsigned char*)pInput,
|
||||
frameCount * ma_get_bytes_per_frame(format_, channels_),
|
||||
"audio");
|
||||
}
|
||||
|
||||
(void)pOutput;
|
||||
}
|
||||
|
||||
SpeakerCapturerWasapi::speaker_data_cb SpeakerCapturerWasapi::GetCallback() {
|
||||
return cb_;
|
||||
}
|
||||
|
||||
SpeakerCapturerWasapi::SpeakerCapturerWasapi() {}
|
||||
// Each attempt owns its device. A failed/old session must never uninitialize
|
||||
// a device belonging to another capturer or reuse already released storage.
|
||||
class SpeakerCapturerWasapi::Impl {
|
||||
public:
|
||||
ma_device device{};
|
||||
bool initialized = false;
|
||||
speaker_data_cb callback;
|
||||
};
|
||||
|
||||
SpeakerCapturerWasapi::SpeakerCapturerWasapi() : impl_(new Impl) {}
|
||||
SpeakerCapturerWasapi::~SpeakerCapturerWasapi() {
|
||||
if (SAVE_AUDIO_FILE) {
|
||||
fclose(fp_);
|
||||
}
|
||||
Destroy();
|
||||
delete impl_;
|
||||
}
|
||||
|
||||
int SpeakerCapturerWasapi::Init(speaker_data_cb cb) {
|
||||
if (inited_) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
cb_ = cb;
|
||||
|
||||
if (SAVE_AUDIO_FILE) {
|
||||
fopen_s(&fp_, "system_audio.pcm", "wb");
|
||||
}
|
||||
|
||||
ma_result result;
|
||||
if (impl_->initialized) return 0;
|
||||
impl_->callback = std::move(cb);
|
||||
ma_device_config config = ma_device_config_init(ma_device_type_loopback);
|
||||
config.capture.pDeviceID =
|
||||
nullptr; // Resolve the current default each attempt.
|
||||
config.capture.format = ma_format_s16;
|
||||
config.capture.channels = 1;
|
||||
config.sampleRate = 48000;
|
||||
config.pUserData = impl_;
|
||||
config.dataCallback = [](ma_device* device, void*, const void* input,
|
||||
ma_uint32 frames) {
|
||||
auto* state = static_cast<Impl*>(device->pUserData);
|
||||
if (state && state->callback && input && frames > 0)
|
||||
state->callback((unsigned char*)input, frames * sizeof(int16_t), "audio");
|
||||
};
|
||||
ma_backend backends[] = {ma_backend_wasapi};
|
||||
|
||||
device_config_ = ma_device_config_init(ma_device_type_loopback);
|
||||
device_config_.capture.pDeviceID = NULL;
|
||||
device_config_.capture.format = format_;
|
||||
device_config_.capture.channels = channels_;
|
||||
device_config_.sampleRate = sample_rate_;
|
||||
device_config_.dataCallback = data_callback;
|
||||
device_config_.pUserData = this;
|
||||
|
||||
result = ma_device_init_ex(backends, sizeof(backends) / sizeof(backends[0]),
|
||||
NULL, &device_config_, &device_);
|
||||
const ma_result result =
|
||||
ma_device_init_ex(backends, 1, nullptr, &config, &impl_->device);
|
||||
if (result != MA_SUCCESS) {
|
||||
LOG_ERROR("Failed to initialize loopback device");
|
||||
impl_->callback = nullptr;
|
||||
LOG_ERROR("Failed to initialize WASAPI loopback device: {}",
|
||||
static_cast<int>(result));
|
||||
return -1;
|
||||
}
|
||||
|
||||
inited_ = true;
|
||||
|
||||
impl_->initialized = true;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int SpeakerCapturerWasapi::Start() {
|
||||
ma_result result = ma_device_start(&device_);
|
||||
if (!impl_->initialized) return -1;
|
||||
if (IsRunning()) return 0;
|
||||
const ma_result result = ma_device_start(&impl_->device);
|
||||
if (result != MA_SUCCESS) {
|
||||
ma_device_uninit(&device_);
|
||||
LOG_ERROR("Failed to start device");
|
||||
LOG_ERROR("Failed to start WASAPI loopback device: {}",
|
||||
static_cast<int>(result));
|
||||
Destroy();
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
int SpeakerCapturerWasapi::Stop() {
|
||||
ma_device_stop(&device_);
|
||||
if (!impl_->initialized || !IsRunning()) return 0;
|
||||
const ma_result result = ma_device_stop(&impl_->device);
|
||||
if (result != MA_SUCCESS) {
|
||||
LOG_WARN("Failed to stop WASAPI loopback device: {}",
|
||||
static_cast<int>(result));
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int SpeakerCapturerWasapi::Destroy() {
|
||||
ma_device_uninit(&device_);
|
||||
if (impl_->initialized) {
|
||||
// ma_device_uninit also stops and drains the device callback thread.
|
||||
ma_device_uninit(&impl_->device);
|
||||
impl_->initialized = false;
|
||||
}
|
||||
impl_->callback = nullptr;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int SpeakerCapturerWasapi::Pause() { return 0; }
|
||||
} // namespace crossdesk
|
||||
bool SpeakerCapturerWasapi::IsRunning() const {
|
||||
return impl_->initialized && ma_device_is_started(&impl_->device);
|
||||
}
|
||||
int SpeakerCapturerWasapi::Pause() { return Stop(); }
|
||||
int SpeakerCapturerWasapi::Resume() { return Start(); }
|
||||
} // namespace crossdesk
|
||||
|
||||
@@ -14,24 +14,21 @@ namespace crossdesk {
|
||||
class SpeakerCapturerWasapi : public SpeakerCapturer {
|
||||
public:
|
||||
SpeakerCapturerWasapi();
|
||||
~SpeakerCapturerWasapi();
|
||||
~SpeakerCapturerWasapi() override;
|
||||
|
||||
public:
|
||||
virtual int Init(speaker_data_cb cb);
|
||||
virtual int Destroy();
|
||||
virtual int Start();
|
||||
virtual int Stop();
|
||||
int Init(speaker_data_cb cb) override;
|
||||
int Destroy() override;
|
||||
int Start() override;
|
||||
int Stop() override;
|
||||
bool IsRunning() const override;
|
||||
|
||||
int Pause();
|
||||
int Resume();
|
||||
|
||||
speaker_data_cb GetCallback();
|
||||
|
||||
private:
|
||||
speaker_data_cb cb_ = nullptr;
|
||||
|
||||
private:
|
||||
bool inited_ = false;
|
||||
class Impl;
|
||||
Impl* impl_ = nullptr;
|
||||
};
|
||||
} // namespace crossdesk
|
||||
#endif
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-10
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _WINDOWS_THREAD_DPI_H_
|
||||
#define _WINDOWS_THREAD_DPI_H_
|
||||
|
||||
#include <Windows.h>
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
// Keep newer DPI entry points out of the import table: privacy capability
|
||||
// checks must still be able to report unsupported on pre-2004 Windows.
|
||||
class ScopedWindowsPhysicalCoordinates {
|
||||
public:
|
||||
using SetContext = DPI_AWARENESS_CONTEXT(WINAPI*)(DPI_AWARENESS_CONTEXT);
|
||||
ScopedWindowsPhysicalCoordinates() {
|
||||
setter_ = reinterpret_cast<SetContext>(GetProcAddress(
|
||||
GetModuleHandleW(L"user32.dll"), "SetThreadDpiAwarenessContext"));
|
||||
if (setter_) {
|
||||
previous_ = setter_(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
|
||||
// Ordinary remote input retains compatibility with Windows 10 1607.
|
||||
// The privacy backend itself requires build 19041 before using this.
|
||||
if (!previous_)
|
||||
previous_ = setter_(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE);
|
||||
}
|
||||
}
|
||||
~ScopedWindowsPhysicalCoordinates() {
|
||||
if (setter_ && previous_) setter_(previous_);
|
||||
}
|
||||
ScopedWindowsPhysicalCoordinates(const ScopedWindowsPhysicalCoordinates&) =
|
||||
delete;
|
||||
ScopedWindowsPhysicalCoordinates& operator=(
|
||||
const ScopedWindowsPhysicalCoordinates&) = delete;
|
||||
bool active() const { return previous_ != nullptr; }
|
||||
|
||||
private:
|
||||
SetContext setter_ = nullptr;
|
||||
DPI_AWARENESS_CONTEXT previous_ = nullptr;
|
||||
};
|
||||
|
||||
inline UINT PrivacyWindowDpi(HWND hwnd) {
|
||||
using GetDpi = UINT(WINAPI*)(HWND);
|
||||
static const auto get_dpi = reinterpret_cast<GetDpi>(
|
||||
GetProcAddress(GetModuleHandleW(L"user32.dll"), "GetDpiForWindow"));
|
||||
return get_dpi ? get_dpi(hwnd) : 96;
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif // _WINDOWS_THREAD_DPI_H_
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-10
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _PRIVACY_BACKEND_H_
|
||||
#define _PRIVACY_BACKEND_H_
|
||||
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
struct PrivacyScreenText {
|
||||
std::string verification = "Privacy screen verification";
|
||||
std::string unlock_hint = "To turn off the privacy screen, press";
|
||||
};
|
||||
|
||||
struct PrivacyCapabilities {
|
||||
bool overlay = false;
|
||||
bool block_input = false;
|
||||
std::string reason;
|
||||
};
|
||||
|
||||
struct PrivacyHealth {
|
||||
std::string failure;
|
||||
bool emergency_exit = false;
|
||||
};
|
||||
|
||||
// Lifecycle, message pumping and native resource operations run on the
|
||||
// controller's message thread. Const metadata queries are serialized with
|
||||
// those operations by the controller mutex and must not call native UI APIs.
|
||||
// No platform backend is installed on macOS/Linux yet.
|
||||
class PrivacyBackend {
|
||||
public:
|
||||
virtual ~PrivacyBackend() = default;
|
||||
virtual PrivacyCapabilities Query() = 0;
|
||||
virtual bool Enable(bool block_input, const PrivacyScreenText& text,
|
||||
std::string& error) = 0;
|
||||
virtual void Recover() = 0;
|
||||
virtual bool IsRecovered() const = 0;
|
||||
virtual PrivacyHealth Poll(bool force_check = false) = 0;
|
||||
// Wait on both native window messages and controller commands.
|
||||
// Wake is the only backend operation callable from another thread.
|
||||
virtual void WaitForEvents(unsigned timeout_ms) = 0;
|
||||
virtual void Wake() = 0;
|
||||
virtual bool PaintChallenge(uint32_t value, const std::string& title,
|
||||
std::string& error) = 0;
|
||||
// Remove only the temporary capture probes, keeping all privacy covers up.
|
||||
// The controller drains queued probe frames before admitting clean video.
|
||||
virtual bool ClearChallenges(std::string& error) = 0;
|
||||
virtual bool HasMonitor(int left, int top, int width, int height) const = 0;
|
||||
};
|
||||
|
||||
#ifdef _WIN32
|
||||
std::unique_ptr<PrivacyBackend> CreateWindowsPrivacyBackend();
|
||||
bool IsWindowsPrivacyDesktopAvailable();
|
||||
#endif
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif // _PRIVACY_BACKEND_H_
|
||||
@@ -0,0 +1,496 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-11
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#include "privacy_controller.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
|
||||
#include "rd_log.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
uint64_t PrivacyController::Now() {
|
||||
return static_cast<uint64_t>(
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::steady_clock::now().time_since_epoch())
|
||||
.count());
|
||||
}
|
||||
|
||||
PrivacyController::PrivacyController(Factory factory) {
|
||||
status_.state = PrivacyState::unsupported;
|
||||
std::strcpy(status_.reason,
|
||||
"Privacy screen is not available on this platform");
|
||||
#ifdef _WIN32
|
||||
if (!factory) factory = CreateWindowsPrivacyBackend;
|
||||
#endif
|
||||
thread_ = std::thread([this, factory] { Run(factory); });
|
||||
}
|
||||
|
||||
PrivacyController::~PrivacyController() { Shutdown(); }
|
||||
|
||||
void PrivacyController::WakeLocked() {
|
||||
if (backend_) backend_->Wake();
|
||||
wake_.notify_all();
|
||||
}
|
||||
|
||||
void PrivacyController::Shutdown() {
|
||||
{
|
||||
std::lock_guard lock(mutex_);
|
||||
quit_ = true;
|
||||
remote_paused_.store(true);
|
||||
WakeLocked();
|
||||
}
|
||||
if (thread_.joinable()) thread_.join();
|
||||
}
|
||||
|
||||
PrivacyStatus PrivacyController::Snapshot() const {
|
||||
std::lock_guard lock(mutex_);
|
||||
return status_;
|
||||
}
|
||||
|
||||
void PrivacyController::SetText(const PrivacyScreenText& text) {
|
||||
std::lock_guard lock(mutex_);
|
||||
text_ = text;
|
||||
}
|
||||
|
||||
void PrivacyController::SetState(PrivacyState state,
|
||||
const std::string& reason) {
|
||||
status_.state = state;
|
||||
status_.remote_paused = remote_paused_.load();
|
||||
++status_.revision;
|
||||
std::strncpy(status_.reason, reason.c_str(), sizeof(status_.reason) - 1);
|
||||
status_.reason[sizeof(status_.reason) - 1] = '\0';
|
||||
LOG_INFO(
|
||||
"Privacy: state={}, overlay={}, input_blocked={}, paused={}, reason={}",
|
||||
static_cast<int>(state), status_.overlay_active, status_.input_blocked,
|
||||
status_.remote_paused, reason);
|
||||
}
|
||||
|
||||
void PrivacyController::Enable(bool block_input) {
|
||||
std::lock_guard lock(mutex_);
|
||||
if (quit_) return;
|
||||
if (status_.state == PrivacyState::on ||
|
||||
status_.state == PrivacyState::starting ||
|
||||
status_.state == PrivacyState::stopping ||
|
||||
status_.state == PrivacyState::failed) {
|
||||
SetState(status_.state, status_.reason);
|
||||
return;
|
||||
}
|
||||
if (!status_.supported || (block_input && !status_.input_block_supported)) {
|
||||
SetState(PrivacyState::unsupported, status_.reason);
|
||||
return;
|
||||
}
|
||||
// Failure recovery is explicit: first turn off, then request a fresh enable.
|
||||
engaged_.store(true);
|
||||
remote_paused_.store(true);
|
||||
block_requested_ = block_input;
|
||||
automatic_enable_ = false;
|
||||
enable_pending_ = true;
|
||||
ever_enabled_ = false;
|
||||
SetState(PrivacyState::starting,
|
||||
"Creating privacy windows and checking capture");
|
||||
WakeLocked();
|
||||
}
|
||||
|
||||
void PrivacyController::EnableOnConnection() {
|
||||
std::lock_guard lock(mutex_);
|
||||
if (quit_ || status_.state == PrivacyState::on ||
|
||||
status_.state == PrivacyState::starting ||
|
||||
status_.state == PrivacyState::failed)
|
||||
return;
|
||||
// Also accept a reconnect while the previous disconnect is still cleaning
|
||||
// up. Run() keeps the gate closed between recovery and this new enable.
|
||||
engaged_.store(true);
|
||||
remote_paused_.store(true);
|
||||
block_requested_ = true;
|
||||
automatic_enable_ = true;
|
||||
capture_start_deadline_ = Now() + 10000;
|
||||
enable_pending_ = true;
|
||||
ever_enabled_ = false;
|
||||
SetState(PrivacyState::starting,
|
||||
"Connected; automatically enabling privacy and verifying capture");
|
||||
WakeLocked();
|
||||
}
|
||||
|
||||
void PrivacyController::Disable() {
|
||||
std::lock_guard lock(mutex_);
|
||||
if (quit_) return;
|
||||
if (!engaged_.load() && !status_.overlay_active) {
|
||||
SetState(status_.state, status_.reason);
|
||||
return;
|
||||
}
|
||||
enable_pending_ = false;
|
||||
disable_pending_ = true;
|
||||
remote_paused_.store(true);
|
||||
SetState(PrivacyState::stopping, "Restoring local desktop and input");
|
||||
WakeLocked();
|
||||
}
|
||||
|
||||
void PrivacyController::Disconnected() {
|
||||
std::lock_guard lock(mutex_);
|
||||
if (quit_ ||
|
||||
(!engaged_.load() && !status_.overlay_active && !remote_paused_.load()))
|
||||
return;
|
||||
remote_paused_.store(true);
|
||||
enable_pending_ = false;
|
||||
disable_pending_ = true;
|
||||
SetState(PrivacyState::stopping,
|
||||
"Last controller disconnected; restoring desktop, input and session "
|
||||
"state");
|
||||
WakeLocked();
|
||||
}
|
||||
|
||||
void PrivacyController::FailLocked(const std::string& reason) {
|
||||
if (!engaged_.load() || status_.state == PrivacyState::failed ||
|
||||
status_.state == PrivacyState::stopping)
|
||||
return;
|
||||
remote_paused_.store(true);
|
||||
enable_pending_ = false;
|
||||
SetState(PrivacyState::failed, reason);
|
||||
}
|
||||
|
||||
void PrivacyController::Fail(const std::string& reason) {
|
||||
std::lock_guard lock(mutex_);
|
||||
FailLocked(reason);
|
||||
}
|
||||
|
||||
void PrivacyController::BeginVerification(const std::string& reason) {
|
||||
remote_paused_.store(true);
|
||||
verifier_.Reset(true);
|
||||
verification_phase_ = VerificationPhase::challenge;
|
||||
clean_frames_ = 0;
|
||||
last_frame_received_ = 0;
|
||||
drain_after_recovery_.store(false);
|
||||
started_ = Now();
|
||||
last_issue_ = 0;
|
||||
frame_monitor_set_ = false;
|
||||
first_verified_frame_logged_ = false;
|
||||
SetState(PrivacyState::starting,
|
||||
!reason.empty() ? reason
|
||||
: automatic_enable_ && !capture_running_
|
||||
? "Waiting for desktop capture startup"
|
||||
: "Verifying live desktop through " + capture_backend_);
|
||||
}
|
||||
|
||||
void PrivacyController::CaptureChanged(const std::string& backend,
|
||||
bool running) {
|
||||
std::lock_guard lock(mutex_);
|
||||
capture_backend_ = backend;
|
||||
capture_running_ = running;
|
||||
if (!engaged_.load()) return;
|
||||
if (ever_enabled_) {
|
||||
FailLocked(
|
||||
"Capture backend stopped or rebuilt; remote operation paused. Turn "
|
||||
"privacy off to recover.");
|
||||
} else if (status_.state == PrivacyState::starting) {
|
||||
BeginVerification();
|
||||
}
|
||||
}
|
||||
|
||||
void PrivacyController::DisplayChanging() {
|
||||
std::lock_guard lock(mutex_);
|
||||
if (status_.state == PrivacyState::on) BeginVerification();
|
||||
}
|
||||
|
||||
void PrivacyController::CaptureInterrupted() {
|
||||
std::lock_guard lock(mutex_);
|
||||
if (!engaged_.load()) return;
|
||||
if (ever_enabled_) {
|
||||
FailLocked(
|
||||
"Capture component is rebuilding; remote operation paused. Turn "
|
||||
"privacy off to recover.");
|
||||
} else if (status_.state == PrivacyState::starting) {
|
||||
// The capturer owns recovery and backend selection. Require fresh frames
|
||||
// from it without letting repeated resets extend verification forever.
|
||||
const auto attempt_started = started_;
|
||||
BeginVerification(
|
||||
"Capture component interrupted; verifying recovered frames");
|
||||
if (attempt_started) started_ = attempt_started;
|
||||
}
|
||||
}
|
||||
|
||||
bool PrivacyController::ObserveFrame(const uint8_t* y, size_t size, int width,
|
||||
int height, int left, int top,
|
||||
int physical_width, int physical_height) {
|
||||
if (!engaged_.load() && !drain_after_recovery_.load()) return RemoteAllowed();
|
||||
std::lock_guard lock(mutex_);
|
||||
if (!engaged_.load()) {
|
||||
// Disabling during verification can leave already-captured probe frames
|
||||
// queued behind HWND destruction. Do not leak them after reporting Off.
|
||||
if (drain_after_recovery_.load()) {
|
||||
if (!y || width <= 0 || height <= 0 ||
|
||||
size < static_cast<size_t>(width) * height)
|
||||
return false;
|
||||
if (verifier_.ContainsChallenge(y, size, width, height)) {
|
||||
clean_frames_ = 0;
|
||||
return false;
|
||||
}
|
||||
if (++clean_frames_ < 2) return false;
|
||||
drain_after_recovery_.store(false);
|
||||
verifier_.Reset();
|
||||
}
|
||||
return RemoteAllowed();
|
||||
}
|
||||
if (!backend_ || !status_.overlay_active ||
|
||||
(status_.state != PrivacyState::starting &&
|
||||
status_.state != PrivacyState::on))
|
||||
return false;
|
||||
if (!backend_->HasMonitor(left, top, physical_width, physical_height) ||
|
||||
width != (physical_width & ~1) || height != (physical_height & ~1)) {
|
||||
if (ever_enabled_)
|
||||
FailLocked(
|
||||
"Capture geometry changed or cannot be verified; remote operation "
|
||||
"paused");
|
||||
return false;
|
||||
}
|
||||
if (frame_monitor_set_ && (frame_left_ != left || frame_top_ != top)) {
|
||||
BeginVerification();
|
||||
}
|
||||
frame_left_ = left;
|
||||
frame_top_ = top;
|
||||
frame_monitor_set_ = true;
|
||||
if (!y || size < static_cast<size_t>(width) * height) {
|
||||
FailLocked(
|
||||
"Capture returned an invalid desktop frame; remote operation paused");
|
||||
return false;
|
||||
}
|
||||
last_frame_received_ = Now();
|
||||
if (verification_phase_ == VerificationPhase::challenge) {
|
||||
verifier_.Observe(y, size, width, height, last_frame_received_);
|
||||
return false; // Verification pixels are never sent to the encoder.
|
||||
}
|
||||
if (verifier_.ContainsChallenge(y, size, width, height)) {
|
||||
clean_frames_ = 0;
|
||||
if (verification_phase_ == VerificationPhase::live) {
|
||||
drain_after_recovery_.store(true);
|
||||
FailLocked(
|
||||
"Capture reintroduced verification pixels; remote operation paused");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (verification_phase_ == VerificationPhase::draining) {
|
||||
if (clean_frames_ < 2) ++clean_frames_;
|
||||
return false; // Only the owner thread can publish the verified On state.
|
||||
}
|
||||
if (status_.state != PrivacyState::on) return false;
|
||||
if (!first_verified_frame_logged_) {
|
||||
first_verified_frame_logged_ = true;
|
||||
LOG_INFO(
|
||||
"Privacy: first clean verified desktop frame admitted, backend={}, "
|
||||
"size={}x{}, origin=({}, {})",
|
||||
capture_backend_, width, height, left, top);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void PrivacyController::Run(Factory factory) {
|
||||
std::unique_lock lock(mutex_);
|
||||
try {
|
||||
if (factory) backend_ = factory();
|
||||
if (backend_) {
|
||||
const auto caps = backend_->Query();
|
||||
last_capability_check_ = Now();
|
||||
status_.supported = caps.overlay;
|
||||
status_.input_block_supported = caps.block_input;
|
||||
// A connection can arm the gate before this thread has initialized.
|
||||
if (!engaged_.load())
|
||||
SetState(caps.overlay ? PrivacyState::off : PrivacyState::unsupported,
|
||||
caps.reason);
|
||||
}
|
||||
while (!quit_) {
|
||||
if (backend_ && !engaged_.load() &&
|
||||
Now() - last_capability_check_ >= 2000) {
|
||||
last_capability_check_ = Now();
|
||||
const auto caps = backend_->Query();
|
||||
if (status_.supported != caps.overlay ||
|
||||
status_.input_block_supported != caps.block_input) {
|
||||
status_.supported = caps.overlay;
|
||||
status_.input_block_supported = caps.block_input;
|
||||
SetState(caps.overlay ? PrivacyState::off : PrivacyState::unsupported,
|
||||
caps.reason);
|
||||
}
|
||||
}
|
||||
if (disable_pending_) {
|
||||
if (backend_) backend_->Recover();
|
||||
disable_pending_ = false;
|
||||
if (backend_ && !backend_->IsRecovered()) {
|
||||
enable_pending_ = false;
|
||||
SetState(PrivacyState::failed,
|
||||
"Could not release every privacy resource; remote operation "
|
||||
"remains paused");
|
||||
continue;
|
||||
}
|
||||
status_.overlay_active = status_.input_blocked = false;
|
||||
const bool drain = challenges_visible_ ||
|
||||
verification_phase_ == VerificationPhase::draining ||
|
||||
drain_after_recovery_.load();
|
||||
challenges_visible_ = false;
|
||||
clean_frames_ = 0;
|
||||
drain_after_recovery_.store(drain);
|
||||
if (!drain) verifier_.Reset();
|
||||
verification_phase_ = VerificationPhase::live;
|
||||
if (enable_pending_) {
|
||||
SetState(PrivacyState::starting,
|
||||
"Reconnected; automatically restoring privacy protection");
|
||||
} else {
|
||||
ever_enabled_ = false;
|
||||
automatic_enable_ = false;
|
||||
block_requested_ = false;
|
||||
capture_start_deadline_ = 0;
|
||||
started_ = last_issue_ = 0;
|
||||
frame_monitor_set_ = false;
|
||||
first_verified_frame_logged_ = false;
|
||||
engaged_.store(false);
|
||||
remote_paused_.store(false);
|
||||
SetState(PrivacyState::off, "Privacy screen is off");
|
||||
}
|
||||
}
|
||||
if (enable_pending_) {
|
||||
enable_pending_ = false;
|
||||
std::string error;
|
||||
if (automatic_enable_) {
|
||||
const auto caps =
|
||||
backend_ ? backend_->Query()
|
||||
: PrivacyCapabilities{false, false,
|
||||
"No privacy backend is installed"};
|
||||
status_.supported = caps.overlay;
|
||||
status_.input_block_supported = caps.block_input;
|
||||
if (!caps.overlay || !caps.block_input)
|
||||
FailLocked("Automatic privacy unavailable: " + caps.reason);
|
||||
}
|
||||
if (status_.state != PrivacyState::failed) {
|
||||
if (!capture_running_ && !automatic_enable_) {
|
||||
FailLocked("No running desktop capture; remote operation paused");
|
||||
} else if (!backend_->Enable(block_requested_, text_, error)) {
|
||||
backend_->Recover();
|
||||
status_.overlay_active = !backend_->IsRecovered();
|
||||
FailLocked(error);
|
||||
} else {
|
||||
status_.overlay_active = true;
|
||||
status_.input_blocked = block_requested_;
|
||||
BeginVerification();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Pump the native queue even after recovery: the message sink is reused.
|
||||
const auto health =
|
||||
backend_ ? backend_->Poll(status_.state == PrivacyState::starting &&
|
||||
verifier_.Verified())
|
||||
: PrivacyHealth{};
|
||||
if (backend_ && engaged_.load()) {
|
||||
if (health.emergency_exit) {
|
||||
remote_paused_.store(true);
|
||||
enable_pending_ = false;
|
||||
disable_pending_ = true;
|
||||
SetState(PrivacyState::stopping,
|
||||
"Local privacy exit; restoring desktop and input before "
|
||||
"resuming remote operation");
|
||||
continue;
|
||||
} else if (!health.failure.empty()) {
|
||||
FailLocked(health.failure);
|
||||
}
|
||||
if (status_.overlay_active &&
|
||||
(status_.state == PrivacyState::starting ||
|
||||
status_.state == PrivacyState::on)) {
|
||||
const auto now = Now();
|
||||
if (verification_phase_ == VerificationPhase::challenge &&
|
||||
now - last_issue_ >= 250) {
|
||||
uint32_t challenge;
|
||||
do {
|
||||
challenge = random_();
|
||||
} while (challenge == 0 || challenge == UINT32_MAX);
|
||||
std::string error;
|
||||
challenges_visible_ = true;
|
||||
// Record even a partially painted multi-monitor challenge, so
|
||||
// cleanup can reject its queued frames if another monitor fails.
|
||||
verifier_.Issue(challenge, now);
|
||||
if (!backend_->PaintChallenge(challenge, text_.verification,
|
||||
error)) {
|
||||
FailLocked(error);
|
||||
} else {
|
||||
last_issue_ = now;
|
||||
}
|
||||
}
|
||||
if (status_.state == PrivacyState::starting &&
|
||||
verification_phase_ == VerificationPhase::challenge &&
|
||||
verifier_.Verified() && capture_running_) {
|
||||
std::string error;
|
||||
if (!backend_->ClearChallenges(error)) {
|
||||
FailLocked(error);
|
||||
} else {
|
||||
challenges_visible_ = false;
|
||||
verification_phase_ = VerificationPhase::draining;
|
||||
clean_frames_ = 0;
|
||||
started_ = now;
|
||||
SetState(PrivacyState::starting,
|
||||
"Capture exclusion verified; waiting for clean desktop "
|
||||
"frames");
|
||||
}
|
||||
}
|
||||
if (status_.state == PrivacyState::starting &&
|
||||
verification_phase_ == VerificationPhase::draining &&
|
||||
clean_frames_ >= 2 && capture_running_) {
|
||||
verification_phase_ = VerificationPhase::live;
|
||||
ever_enabled_ = true;
|
||||
automatic_enable_ = false;
|
||||
remote_paused_.store(false);
|
||||
SetState(PrivacyState::on, "Privacy screen verified using " +
|
||||
capture_backend_ +
|
||||
"; verification windows removed");
|
||||
}
|
||||
if (status_.state == PrivacyState::on &&
|
||||
now - last_frame_received_ > 1500) {
|
||||
// Live sessions rely on capture delivery and native window/backend
|
||||
// health; verification pixels are never painted during operation.
|
||||
FailLocked(
|
||||
"Capture stopped delivering desktop frames; remote operation "
|
||||
"paused");
|
||||
} else if (status_.state == PrivacyState::starting &&
|
||||
automatic_enable_ && !capture_running_) {
|
||||
if (now >= capture_start_deadline_)
|
||||
FailLocked(
|
||||
"Desktop capture did not start; automatic privacy remains "
|
||||
"paused");
|
||||
} else if (status_.state == PrivacyState::starting &&
|
||||
now - started_ > 3000) {
|
||||
if (verification_phase_ == VerificationPhase::draining) {
|
||||
FailLocked(
|
||||
"Capture did not clear its verification pixels; remote "
|
||||
"operation paused");
|
||||
} else {
|
||||
FailLocked("Current capture backend " + capture_backend_ +
|
||||
" did not pass privacy verification; remote operation "
|
||||
"paused. Turn privacy off to recover.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (backend_) {
|
||||
// Keep window notifications and emergency exits responsive. Low-level
|
||||
// input hooks are pumped independently by PrivacyInputGuard.
|
||||
const unsigned timeout_ms = engaged_.load() ? 25 : 2000;
|
||||
lock.unlock();
|
||||
backend_->WaitForEvents(timeout_ms);
|
||||
lock.lock();
|
||||
} else {
|
||||
// Unsupported platforms have no native health or capability polling.
|
||||
wake_.wait(lock);
|
||||
}
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
if (!lock.owns_lock()) lock.lock();
|
||||
remote_paused_.store(true);
|
||||
SetState(PrivacyState::failed,
|
||||
std::string("Privacy component failed: ") + e.what());
|
||||
}
|
||||
if (backend_) {
|
||||
backend_->Recover();
|
||||
backend_.reset();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-10
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _PRIVACY_CONTROLLER_H_
|
||||
#define _PRIVACY_CONTROLLER_H_
|
||||
|
||||
#include <remote_action.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <random>
|
||||
#include <thread>
|
||||
|
||||
#include "privacy_backend.h"
|
||||
#include "privacy_frame_verifier.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
class PrivacyController {
|
||||
public:
|
||||
using Factory = std::function<std::unique_ptr<PrivacyBackend>()>;
|
||||
explicit PrivacyController(Factory factory = {});
|
||||
~PrivacyController();
|
||||
PrivacyController(const PrivacyController&) = delete;
|
||||
PrivacyController& operator=(const PrivacyController&) = delete;
|
||||
|
||||
PrivacyStatus Snapshot() const;
|
||||
// The cover uses a snapshot of these strings when the next session starts.
|
||||
void SetText(const PrivacyScreenText& text);
|
||||
void Enable(bool block_input);
|
||||
// Arms protection synchronously before an incoming connection is published.
|
||||
// Unlike a manual request, startup/unavailable capture cannot leave the
|
||||
// newly connected peer with ordinary unprotected remote access.
|
||||
void EnableOnConnection();
|
||||
void Disable();
|
||||
// Called only after the last control session has been removed. Failures keep
|
||||
// protection while a session exists; ending that session recovers everything
|
||||
// and must not leave the next connection in a paused/failed state.
|
||||
void Disconnected();
|
||||
void Shutdown();
|
||||
void Fail(const std::string& reason);
|
||||
bool RemoteAllowed() const { return !remote_paused_.load(); }
|
||||
bool Engaged() const { return engaged_.load(); }
|
||||
void CaptureChanged(const std::string& backend, bool running);
|
||||
void CaptureInterrupted();
|
||||
void DisplayChanging();
|
||||
bool ObserveFrame(const uint8_t* y, size_t size, int width, int height,
|
||||
int left, int top, int physical_width, int physical_height);
|
||||
|
||||
private:
|
||||
enum class VerificationPhase { challenge, draining, live };
|
||||
void Run(Factory factory);
|
||||
void WakeLocked();
|
||||
void SetState(PrivacyState state, const std::string& reason);
|
||||
void FailLocked(const std::string& reason);
|
||||
void BeginVerification(const std::string& reason = {});
|
||||
static uint64_t Now();
|
||||
|
||||
mutable std::mutex mutex_;
|
||||
std::condition_variable wake_;
|
||||
std::thread thread_;
|
||||
std::unique_ptr<PrivacyBackend> backend_;
|
||||
PrivacyStatus status_{};
|
||||
PrivacyFrameVerifier verifier_;
|
||||
PrivacyScreenText text_;
|
||||
std::mt19937 random_{std::random_device{}()};
|
||||
std::atomic<bool> remote_paused_{false};
|
||||
std::atomic<bool> engaged_{false};
|
||||
std::atomic<bool> drain_after_recovery_{false};
|
||||
VerificationPhase verification_phase_ = VerificationPhase::challenge;
|
||||
bool challenges_visible_ = false;
|
||||
unsigned clean_frames_ = 0;
|
||||
uint64_t last_frame_received_ = 0;
|
||||
bool quit_ = false;
|
||||
bool enable_pending_ = false;
|
||||
bool disable_pending_ = false;
|
||||
bool block_requested_ = false;
|
||||
bool capture_running_ = false;
|
||||
bool ever_enabled_ = false;
|
||||
bool automatic_enable_ = false;
|
||||
uint64_t capture_start_deadline_ = 0;
|
||||
std::string capture_backend_;
|
||||
uint64_t started_ = 0;
|
||||
uint64_t last_issue_ = 0;
|
||||
uint64_t last_capability_check_ = 0;
|
||||
int frame_left_ = 0;
|
||||
int frame_top_ = 0;
|
||||
bool frame_monitor_set_ = false;
|
||||
bool first_verified_frame_logged_ = false;
|
||||
};
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif // _PRIVACY_CONTROLLER_H_
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-10
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _PRIVACY_FRAME_VERIFIER_H_
|
||||
#define _PRIVACY_FRAME_VERIFIER_H_
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
|
||||
#include "privacy_probe_pattern.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
// An opaque square code beside localized text, UNDER the privacy cover.
|
||||
// Used only while transmission is gated during capture validation. Probes are
|
||||
// then removed, and queued frames containing their tokens must be discarded.
|
||||
class PrivacyFrameVerifier {
|
||||
public:
|
||||
void Reset(bool retain_history = false) {
|
||||
challenges_.clear();
|
||||
if (!retain_history) issued_values_.clear();
|
||||
matches_ = 0;
|
||||
}
|
||||
void Issue(uint32_t value, uint64_t now) {
|
||||
challenges_.push_back({value, now, false});
|
||||
issued_values_.push_back(value);
|
||||
// Covers more than the startup/drain deadlines, including backend retries.
|
||||
while (issued_values_.size() > 128) issued_values_.pop_front();
|
||||
// A validation attempt is time-bounded. Keep every issued token so an old
|
||||
// queued frame cannot escape merely because its token expired for matching.
|
||||
}
|
||||
bool Observe(const uint8_t* y, size_t size, int width, int height,
|
||||
uint64_t now) {
|
||||
uint32_t value = 0;
|
||||
if (!Decode(y, size, width, height, value)) return false;
|
||||
for (auto& challenge : challenges_) {
|
||||
if (challenge.value != value || now - challenge.issued > 750) continue;
|
||||
if (!challenge.seen) {
|
||||
challenge.seen = true;
|
||||
++matches_;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
bool Verified() const { return matches_ >= 2; }
|
||||
bool ContainsChallenge(const uint8_t* y, size_t size, int width,
|
||||
int height) const {
|
||||
uint32_t value = 0;
|
||||
if (!Decode(y, size, width, height, value)) return false;
|
||||
for (const auto issued : issued_values_)
|
||||
if (issued == value) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private:
|
||||
static bool Decode(const uint8_t* y, size_t size, int width, int height,
|
||||
uint32_t& value) {
|
||||
using Pattern = PrivacyProbePattern;
|
||||
if (!y || width < Pattern::kCaptureExtent ||
|
||||
height < Pattern::kCaptureExtent ||
|
||||
size < static_cast<size_t>(width) * height)
|
||||
return false;
|
||||
value = 0;
|
||||
for (int bit = 0; bit < 32; ++bit) {
|
||||
const int x = Pattern::kOffset + Pattern::kPadding +
|
||||
(bit % Pattern::kColumns) * Pattern::kCell +
|
||||
Pattern::kCell / 2;
|
||||
const int row = Pattern::kOffset + Pattern::kPadding +
|
||||
(bit / Pattern::kColumns) * Pattern::kCell +
|
||||
Pattern::kCell / 2;
|
||||
const auto pixel = y[static_cast<size_t>(row) * width + x];
|
||||
const auto inverse =
|
||||
y[static_cast<size_t>(row + 4 * Pattern::kCell) * width + x];
|
||||
if ((pixel > 60 && pixel < 190) || (inverse > 60 && inverse < 190) ||
|
||||
(pixel >= 190) == (inverse >= 190))
|
||||
return false;
|
||||
if (pixel >= 190) value |= uint32_t{1} << bit;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
struct Challenge {
|
||||
uint32_t value;
|
||||
uint64_t issued;
|
||||
bool seen;
|
||||
};
|
||||
std::deque<Challenge> challenges_;
|
||||
std::deque<uint32_t> issued_values_;
|
||||
unsigned matches_ = 0;
|
||||
};
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif // _PRIVACY_FRAME_VERIFIER_H_
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-11
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _PRIVACY_PROBE_PATTERN_H_
|
||||
#define _PRIVACY_PROBE_PATTERN_H_
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
// Physical-pixel geometry shared by the renderer and raw-frame verifier.
|
||||
// Text can scale/wrap independently without moving any sampling location.
|
||||
struct PrivacyProbePattern {
|
||||
static constexpr int kOffset = 16;
|
||||
static constexpr int kPadding = 16;
|
||||
static constexpr int kGap = 24;
|
||||
static constexpr int kCell = 12;
|
||||
static constexpr int kColumns = 8;
|
||||
static constexpr int kRows = 8;
|
||||
static constexpr int kSide = kColumns * kCell;
|
||||
static constexpr int kCaptureExtent = kOffset + kPadding + kSide;
|
||||
static constexpr int kTextLeft = kPadding + kSide + kGap;
|
||||
|
||||
// Top half carries all 32 bits; bottom half carries their complements.
|
||||
static constexpr bool White(uint32_t value, int column, int row) {
|
||||
const int index = row * kColumns + column;
|
||||
const bool bit = (value & (uint32_t{1} << (index % 32))) != 0;
|
||||
return index < 32 ? bit : !bit;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif // _PRIVACY_PROBE_PATTERN_H_
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-11
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _SCREEN_CAPTURE_METHOD_H_
|
||||
#define _SCREEN_CAPTURE_METHOD_H_
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
// Persisted values; keep these in the same order as the settings choices.
|
||||
enum class ScreenCaptureMethod { Auto = 0, Dxgi = 1, Wgc = 2, Gdi = 3 };
|
||||
|
||||
inline bool IsValidScreenCaptureMethod(long value) {
|
||||
return value >= static_cast<long>(ScreenCaptureMethod::Auto) &&
|
||||
value <= static_cast<long>(ScreenCaptureMethod::Gdi);
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -16,6 +16,9 @@ namespace crossdesk {
|
||||
|
||||
class ScreenCapturer {
|
||||
public:
|
||||
// Windows backends emit a control-only callback BEFORE automatic recovery.
|
||||
// No video buffer accompanies this notification; wrappers must consume it.
|
||||
static constexpr int kBackendReset = -1;
|
||||
// |stream_id| is a logical MiniRTC stream ID (DisplayN), not a platform
|
||||
// display name or physical handle. |native_frame| is borrowed for the
|
||||
// duration of the callback; retain its owner before using it asynchronously.
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
#include "speaker_capture_controller.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
|
||||
#include "rd_log.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
struct SpeakerCaptureController::State {
|
||||
Factory factory;
|
||||
RetryPolicy policy;
|
||||
std::mutex mutex;
|
||||
std::condition_variable wake;
|
||||
bool enabled = false;
|
||||
bool shutdown = false;
|
||||
uint64_t generation = 0;
|
||||
std::atomic<bool> running{false};
|
||||
|
||||
// Never hold this mutex across a native capture operation. It only drains
|
||||
// an in-flight audio delivery when a session is disabled/destroyed.
|
||||
std::mutex callback_mutex;
|
||||
SpeakerCapturer::speaker_data_cb callback;
|
||||
bool deliver = false;
|
||||
uint64_t callback_attempt = 0;
|
||||
};
|
||||
|
||||
SpeakerCaptureController::SpeakerCaptureController(
|
||||
Factory factory, SpeakerCapturer::speaker_data_cb cb)
|
||||
: SpeakerCaptureController(std::move(factory), std::move(cb),
|
||||
RetryPolicy{}) {}
|
||||
|
||||
SpeakerCaptureController::SpeakerCaptureController(
|
||||
Factory factory, SpeakerCapturer::speaker_data_cb cb, RetryPolicy policy)
|
||||
: state_(std::make_shared<State>()) {
|
||||
state_->factory = std::move(factory);
|
||||
state_->callback = std::move(cb);
|
||||
policy.max_attempts = std::max(1u, policy.max_attempts);
|
||||
state_->policy = policy;
|
||||
// The worker owns only shared state, never this/controller/GUI. Even a
|
||||
// driver stuck inside a native API cannot block shutdown or outlive an
|
||||
// unguarded callback target. There is at most one worker per controller.
|
||||
std::thread([state = state_] { Run(state); }).detach();
|
||||
}
|
||||
|
||||
SpeakerCaptureController::~SpeakerCaptureController() { Shutdown(); }
|
||||
|
||||
void SpeakerCaptureController::SetEnabled(bool enabled) {
|
||||
std::lock_guard<std::mutex> lock(state_->mutex);
|
||||
if (state_->shutdown || state_->enabled == enabled) return;
|
||||
state_->enabled = enabled;
|
||||
++state_->generation;
|
||||
if (!enabled) {
|
||||
std::lock_guard<std::mutex> callback_lock(state_->callback_mutex);
|
||||
state_->deliver = false;
|
||||
state_->running = false;
|
||||
}
|
||||
state_->wake.notify_one();
|
||||
}
|
||||
|
||||
bool SpeakerCaptureController::IsRunning() const { return state_->running; }
|
||||
|
||||
void SpeakerCaptureController::Shutdown() {
|
||||
std::lock_guard<std::mutex> lock(state_->mutex);
|
||||
state_->shutdown = true;
|
||||
state_->enabled = false;
|
||||
state_->running = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> callback_lock(state_->callback_mutex);
|
||||
state_->deliver = false;
|
||||
state_->callback = nullptr;
|
||||
}
|
||||
state_->wake.notify_one();
|
||||
}
|
||||
|
||||
void SpeakerCaptureController::Run(const std::shared_ptr<State>& state) {
|
||||
uint64_t callback_attempt = 0;
|
||||
std::unique_lock<std::mutex> lock(state->mutex);
|
||||
while (!state->shutdown) {
|
||||
state->wake.wait(lock, [&] { return state->shutdown || state->enabled; });
|
||||
if (state->shutdown) break;
|
||||
const uint64_t generation = state->generation;
|
||||
const auto cancelled = [&] {
|
||||
return state->shutdown || !state->enabled ||
|
||||
state->generation != generation;
|
||||
};
|
||||
|
||||
for (unsigned attempt = 1; !cancelled(); ++attempt) {
|
||||
const uint64_t current_attempt = ++callback_attempt;
|
||||
lock.unlock();
|
||||
auto capturer = state->factory();
|
||||
std::weak_ptr<State> weak = state;
|
||||
auto callback = [weak, current_attempt](unsigned char* data, size_t size,
|
||||
const char* name) {
|
||||
if (auto current = weak.lock()) {
|
||||
std::lock_guard<std::mutex> callback_lock(current->callback_mutex);
|
||||
if (current->deliver &&
|
||||
current->callback_attempt == current_attempt &&
|
||||
current->callback) {
|
||||
current->callback(data, size, name);
|
||||
}
|
||||
}
|
||||
};
|
||||
const bool initialized = capturer && capturer->Init(callback) == 0;
|
||||
lock.lock();
|
||||
const bool should_start = initialized && !cancelled();
|
||||
lock.unlock();
|
||||
const bool started = should_start && capturer->Start() == 0;
|
||||
lock.lock();
|
||||
if (started && !cancelled()) {
|
||||
{
|
||||
std::lock_guard<std::mutex> callback_lock(state->callback_mutex);
|
||||
state->callback_attempt = current_attempt;
|
||||
state->deliver = true;
|
||||
state->running = true;
|
||||
}
|
||||
LOG_INFO("System audio capture started (attempt {})", attempt);
|
||||
while (!cancelled()) {
|
||||
if (state->wake.wait_for(lock, state->policy.health_interval,
|
||||
cancelled))
|
||||
break;
|
||||
lock.unlock();
|
||||
const bool healthy = capturer->IsRunning();
|
||||
lock.lock();
|
||||
if (!healthy) {
|
||||
LOG_WARN("System audio capture stopped unexpectedly");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
std::lock_guard<std::mutex> callback_lock(state->callback_mutex);
|
||||
state->deliver = false;
|
||||
state->running = false;
|
||||
}
|
||||
lock.unlock();
|
||||
// Recreate on every attempt/reconnection to discard stale displays,
|
||||
// PulseAudio sources and invalidated WASAPI devices.
|
||||
if (capturer) capturer->Destroy();
|
||||
capturer.reset();
|
||||
lock.lock();
|
||||
if (cancelled()) break;
|
||||
if (attempt >= state->policy.max_attempts) {
|
||||
LOG_ERROR(
|
||||
"System audio capture disabled after {} attempts; toggle audio "
|
||||
"or reconnect to retry",
|
||||
attempt);
|
||||
state->wake.wait(lock, cancelled);
|
||||
break;
|
||||
}
|
||||
const auto delay =
|
||||
state->policy.initial_delay * (1u << std::min(attempt - 1, 5u));
|
||||
LOG_WARN("System audio capture failed (attempt {}); retry in {} ms",
|
||||
attempt, delay.count());
|
||||
state->wake.wait_for(lock, delay, cancelled);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-09
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _SPEAKER_CAPTURE_CONTROLLER_H_
|
||||
#define _SPEAKER_CAPTURE_CONTROLLER_H_
|
||||
|
||||
#include <chrono>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
|
||||
#include "speaker_capturer.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
// Serializes platform capture operations away from the UI and transport
|
||||
// threads. Stop/Shutdown revoke callbacks before returning; native teardown
|
||||
// can then finish without keeping the GUI or its peer alive.
|
||||
class SpeakerCaptureController {
|
||||
public:
|
||||
using Factory = std::function<std::unique_ptr<SpeakerCapturer>()>;
|
||||
struct RetryPolicy {
|
||||
unsigned max_attempts = 3;
|
||||
std::chrono::milliseconds initial_delay{1000};
|
||||
std::chrono::milliseconds health_interval{100};
|
||||
};
|
||||
|
||||
SpeakerCaptureController(Factory factory,
|
||||
SpeakerCapturer::speaker_data_cb cb);
|
||||
SpeakerCaptureController(Factory factory, SpeakerCapturer::speaker_data_cb cb,
|
||||
RetryPolicy policy);
|
||||
~SpeakerCaptureController();
|
||||
SpeakerCaptureController(const SpeakerCaptureController&) = delete;
|
||||
SpeakerCaptureController& operator=(const SpeakerCaptureController&) = delete;
|
||||
|
||||
void SetEnabled(bool enabled);
|
||||
bool IsRunning() const;
|
||||
void Shutdown();
|
||||
|
||||
private:
|
||||
struct State;
|
||||
static void Run(const std::shared_ptr<State>& state);
|
||||
std::shared_ptr<State> state_;
|
||||
};
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -24,6 +24,8 @@ class SpeakerCapturer {
|
||||
virtual int Destroy() = 0;
|
||||
virtual int Start() = 0;
|
||||
virtual int Stop() = 0;
|
||||
// Called on the same control worker as Init/Start/Stop. Must not block.
|
||||
virtual bool IsRunning() const = 0;
|
||||
};
|
||||
} // namespace crossdesk
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -56,8 +56,6 @@ int main() {
|
||||
bool ok = true;
|
||||
ok &= ExpectContains("mouse_controller.cpp", mouse_controller,
|
||||
"INPUT ip = {0};");
|
||||
ok &= ExpectContains("mouse_controller.cpp", mouse_controller,
|
||||
"SetCursorPos failed");
|
||||
ok &= ExpectContains("mouse_controller.cpp", mouse_controller,
|
||||
"SendInput failed for mouse");
|
||||
return ok ? 0 : 1;
|
||||
|
||||
@@ -156,12 +156,40 @@ function setup_targets()
|
||||
add_frameworks("Security", "CoreFoundation")
|
||||
end
|
||||
|
||||
target("privacy")
|
||||
set_kind("object")
|
||||
add_deps("rd_log", "crossdesk_wire")
|
||||
add_files("apps/desktop/src/privacy/privacy_controller.cpp")
|
||||
add_includedirs("apps/desktop/src/privacy", {public = true})
|
||||
if is_os("windows") then
|
||||
add_files("apps/desktop/src/platform/windows/privacy/*.cpp")
|
||||
add_deps("crossdesk_privacy_cursor_helper")
|
||||
add_deps("crossdesk_privacy_window", {inherit = false})
|
||||
add_includedirs("apps/desktop/src/platform/windows/input")
|
||||
add_syslinks("Wtsapi32", "Ole32", {public = true})
|
||||
end
|
||||
|
||||
if is_os("windows") then
|
||||
target("crossdesk_privacy_window")
|
||||
set_kind("shared")
|
||||
set_runtimes("MT")
|
||||
add_files("apps/desktop/src/platform/windows/privacy/band_module/main.cpp")
|
||||
add_files("apps/desktop/src/platform/windows/privacy/band_module/privacy_cover_renderer.cpp")
|
||||
add_files("apps/desktop/resources/windows/crossdesk_privacy_window.rc")
|
||||
add_syslinks("user32", "gdi32", "dwmapi")
|
||||
|
||||
target("crossdesk_privacy_cursor_helper")
|
||||
set_kind("binary")
|
||||
add_files("apps/desktop/src/platform/windows/privacy/cursor_helper/main.cpp")
|
||||
end
|
||||
|
||||
target("screen_capturer")
|
||||
set_kind("object")
|
||||
add_deps("rd_log", "common", "crossdesk_wire")
|
||||
add_includedirs("apps/desktop/src/screen_capturer", {public = true})
|
||||
add_includedirs("deps/submodules/minirtc/src/api", {public = true})
|
||||
if is_os("windows") then
|
||||
add_deps("privacy")
|
||||
add_packages("libyuv")
|
||||
add_files("apps/desktop/src/screen_capturer/captured_nv12_frame.cpp")
|
||||
add_files("apps/desktop/src/platform/windows/screen_capturer/screen_capturer_dxgi.cpp",
|
||||
@@ -193,13 +221,15 @@ function setup_targets()
|
||||
set_kind("object")
|
||||
add_deps("rd_log", "crossdesk_wire")
|
||||
add_includedirs("apps/desktop/src/speaker_capturer", {public = true})
|
||||
add_files("apps/desktop/src/speaker_capturer/speaker_capture_controller.cpp")
|
||||
if is_os("windows") then
|
||||
add_packages("miniaudio")
|
||||
add_files("apps/desktop/src/platform/windows/speaker_capturer/*.cpp")
|
||||
add_includedirs("apps/desktop/src/platform/windows/speaker_capturer")
|
||||
elseif is_os("macosx") then
|
||||
add_files("apps/desktop/src/platform/macos/speaker_capturer/*.cpp",
|
||||
"apps/desktop/src/platform/macos/speaker_capturer/*.mm")
|
||||
add_files("apps/desktop/src/platform/macos/speaker_capturer/*.cpp")
|
||||
add_files("apps/desktop/src/platform/macos/speaker_capturer/*.mm",
|
||||
{mxxflags = "-fobjc-arc"})
|
||||
add_includedirs("apps/desktop/src/platform/macos/speaker_capturer")
|
||||
elseif is_os("linux") then
|
||||
add_files("apps/desktop/src/platform/linux/speaker_capturer/*.cpp")
|
||||
@@ -268,7 +298,8 @@ function setup_targets()
|
||||
set_kind("object")
|
||||
add_deps("rd_log", "autostart")
|
||||
add_files("apps/desktop/src/config_center/*.cpp")
|
||||
add_includedirs("apps/desktop/src/config_center", {public = true})
|
||||
add_includedirs("apps/desktop/src", "apps/desktop/src/config_center",
|
||||
{public = true})
|
||||
|
||||
target("assets")
|
||||
set_kind("headeronly")
|
||||
@@ -316,7 +347,7 @@ function setup_targets()
|
||||
add_deps("rd_log", "common", "assets", "config_center", "minirtc",
|
||||
"path_manager", "screen_capturer", "speaker_capturer",
|
||||
"device_controller", "thumbnail", "version_checker", "tools",
|
||||
"crossdesk_wire")
|
||||
"crossdesk_wire", "privacy")
|
||||
add_files("apps/desktop/src/gui/render.cpp", "apps/desktop/src/gui/application/gui_application.cpp",
|
||||
"apps/desktop/src/gui/rendering/*.cpp",
|
||||
"apps/desktop/src/gui/runtime/*.cpp",
|
||||
|
||||
@@ -100,7 +100,6 @@ typedef NS_ENUM(NSInteger, CrossDeskVideoAdaptationPolicy) {
|
||||
|
||||
- (void)configureWithSignalHost:(NSString *)host
|
||||
signalPort:(NSInteger)signalPort
|
||||
turnPort:(NSInteger)turnPort
|
||||
enableSRTP:(BOOL)enableSRTP;
|
||||
- (void)setHardwareAccelerationEnabled:(BOOL)enabled;
|
||||
- (void)setVideoAdaptationPolicy:(CrossDeskVideoAdaptationPolicy)policy;
|
||||
|
||||
@@ -75,7 +75,6 @@ struct RTCState {
|
||||
CallbackContext controller_context;
|
||||
std::string signal_host;
|
||||
int signal_port = 0;
|
||||
int turn_port = 0;
|
||||
bool enable_srtp = false;
|
||||
bool hardware_acceleration = true;
|
||||
VideoDegradationPreference video_adaptation_policy =
|
||||
@@ -332,14 +331,6 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
CopyCString(params.signal_server_ip, sizeof(params.signal_server_ip),
|
||||
state.signal_host);
|
||||
params.signal_server_port = state.signal_port;
|
||||
CopyCString(params.stun_server_ip, sizeof(params.stun_server_ip),
|
||||
state.signal_host);
|
||||
params.stun_server_port = state.turn_port;
|
||||
CopyCString(params.turn_server_ip, sizeof(params.turn_server_ip),
|
||||
state.signal_host);
|
||||
params.turn_server_port = state.turn_port;
|
||||
params.turn_server_username[0] = '\0';
|
||||
params.turn_server_password[0] = '\0';
|
||||
CopyCString(params.log_path, sizeof(params.log_path), state.log_path);
|
||||
params.hardware_acceleration = state.hardware_acceleration;
|
||||
params.native_video_output = true;
|
||||
@@ -442,13 +433,12 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
|
||||
- (void)configureWithSignalHost:(NSString *)host
|
||||
signalPort:(NSInteger)signalPort
|
||||
turnPort:(NSInteger)turnPort
|
||||
enableSRTP:(BOOL)enableSRTP {
|
||||
NSString *trimmed = [host stringByTrimmingCharactersInSet:
|
||||
NSCharacterSet.whitespaceAndNewlineCharacterSet];
|
||||
const char *host_c_string = trimmed.UTF8String;
|
||||
const std::string host_value = host_c_string ? host_c_string : "";
|
||||
if (host_value.empty() || signalPort <= 0 || turnPort <= 0) return;
|
||||
if (host_value.empty() || signalPort <= 0) return;
|
||||
|
||||
NSString *cache = NSSearchPathForDirectoriesInDomains(
|
||||
NSCachesDirectory, NSUserDomainMask, YES)
|
||||
@@ -465,7 +455,6 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
dispatch_async(_rtcQueue, ^{
|
||||
const bool unchanged = self->_state->signal_host == host_value &&
|
||||
self->_state->signal_port == signalPort &&
|
||||
self->_state->turn_port == turnPort &&
|
||||
self->_state->enable_srtp == enableSRTP &&
|
||||
self->_state->identity_peer != nullptr;
|
||||
if (unchanged) return;
|
||||
@@ -474,7 +463,6 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
|
||||
[self destroyIdentityPeer];
|
||||
self->_state->signal_host = host_value;
|
||||
self->_state->signal_port = static_cast<int>(signalPort);
|
||||
self->_state->turn_port = static_cast<int>(turnPort);
|
||||
self->_state->enable_srtp = enableSRTP;
|
||||
self->_state->log_path = log_path;
|
||||
self->_state->identity_ready = false;
|
||||
|
||||
@@ -716,8 +716,6 @@ private struct ServerSettingsView: View {
|
||||
.autocorrectionDisabled()
|
||||
TextField("信令端口", text: $session.signalPort)
|
||||
.keyboardType(.numberPad)
|
||||
TextField("STUN/TURN 端口", text: $session.turnPort)
|
||||
.keyboardType(.numberPad)
|
||||
}
|
||||
Section {
|
||||
Toggle("启用 SRTP", isOn: $session.enableSRTP)
|
||||
|
||||
@@ -358,7 +358,6 @@ private final class RemoteAudioPlayer {
|
||||
final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDelegate {
|
||||
@Published var signalHost = "api.crossdesk.cn"
|
||||
@Published var signalPort = "9099"
|
||||
@Published var turnPort = "3478"
|
||||
@Published var enableSRTP = false
|
||||
@Published var mouseControlMode = MouseControlMode.saved {
|
||||
didSet { mouseControlMode.save() }
|
||||
@@ -425,7 +424,6 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
|
||||
private struct BridgeConfiguration: Equatable {
|
||||
let host: String
|
||||
let signalPort: Int
|
||||
let turnPort: Int
|
||||
let enableSRTP: Bool
|
||||
}
|
||||
private var bridgeConfiguration: BridgeConfiguration?
|
||||
@@ -453,14 +451,14 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
|
||||
@discardableResult
|
||||
func configureBridge() -> Bool {
|
||||
let host = signalHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let signal = Int(signalPort), let turn = Int(turnPort),
|
||||
(1...65535).contains(signal), (1...65535).contains(turn),
|
||||
guard let signal = Int(signalPort),
|
||||
(1...65535).contains(signal),
|
||||
!host.isEmpty else {
|
||||
signalStatus = "服务器配置无效"
|
||||
return false
|
||||
}
|
||||
let configuration = BridgeConfiguration(host: host, signalPort: signal,
|
||||
turnPort: turn, enableSRTP: enableSRTP)
|
||||
enableSRTP: enableSRTP)
|
||||
if bridgeConfiguration != configuration {
|
||||
bridgeConfiguration = configuration
|
||||
signalConnected = false
|
||||
@@ -475,7 +473,6 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
|
||||
bridge.setVideoAdaptationPolicy(videoAdaptationPolicy.bridgeValue)
|
||||
bridge.configure(withSignalHost: host,
|
||||
signalPort: signal,
|
||||
turnPort: turn,
|
||||
enableSRTP: enableSRTP)
|
||||
return true
|
||||
}
|
||||
|
||||
Vendored
+1
-1
Submodule deps/submodules/minirtc updated: 5fc313c102...9313ae8b92
@@ -26,6 +26,8 @@ enum ControlType {
|
||||
service_command = 6,
|
||||
keyboard_state = 7,
|
||||
cursor_state = 8,
|
||||
privacy_command = 9,
|
||||
privacy_status = 10,
|
||||
};
|
||||
|
||||
enum MouseFlag {
|
||||
@@ -109,6 +111,23 @@ struct ServiceCommand {
|
||||
ServiceCommandFlag flag;
|
||||
};
|
||||
|
||||
enum class PrivacyState { unsupported, off, starting, on, stopping, failed };
|
||||
enum class PrivacyCommandFlag { query, enable, disable };
|
||||
struct PrivacyCommand {
|
||||
PrivacyCommandFlag flag;
|
||||
bool block_local_input;
|
||||
};
|
||||
struct PrivacyStatus {
|
||||
PrivacyState state;
|
||||
bool supported;
|
||||
bool input_block_supported;
|
||||
bool overlay_active;
|
||||
bool input_blocked;
|
||||
bool remote_paused;
|
||||
uint32_t revision;
|
||||
char reason[256];
|
||||
};
|
||||
|
||||
struct RemoteAction {
|
||||
ControlType type = ControlType::invalid;
|
||||
union {
|
||||
@@ -121,6 +140,8 @@ struct RemoteAction {
|
||||
int d;
|
||||
ServiceStatus ss;
|
||||
ServiceCommand c;
|
||||
PrivacyCommand pc;
|
||||
PrivacyStatus ps;
|
||||
};
|
||||
|
||||
std::string to_json() const;
|
||||
@@ -136,4 +157,4 @@ void FreeRemoteAction(RemoteAction& action);
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -106,6 +106,19 @@ std::string RemoteAction::ToJson(const RemoteAction& action) {
|
||||
case ControlType::service_command:
|
||||
object["service_command"] = {{"flag", action.c.flag}};
|
||||
break;
|
||||
case ControlType::privacy_command:
|
||||
object["privacy_command"] = {{"flag", action.pc.flag},
|
||||
{"block_local_input", action.pc.block_local_input}};
|
||||
break;
|
||||
case ControlType::privacy_status:
|
||||
object["privacy_status"] = {{"state", action.ps.state},
|
||||
{"supported", action.ps.supported},
|
||||
{"input_block_supported", action.ps.input_block_supported},
|
||||
{"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}};
|
||||
break;
|
||||
case ControlType::host_infomation: {
|
||||
json displays = json::array();
|
||||
for (std::size_t index = 0; index < action.i.display_num; ++index) {
|
||||
@@ -228,6 +241,33 @@ bool RemoteAction::FromJson(const std::string& json_string,
|
||||
output.c.flag = static_cast<ServiceCommandFlag>(
|
||||
object.at("service_command").at("flag").get<int>());
|
||||
break;
|
||||
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;
|
||||
output.pc.flag = static_cast<PrivacyCommandFlag>(flag);
|
||||
output.pc.block_local_input = command.at("block_local_input").get<bool>();
|
||||
break;
|
||||
}
|
||||
case ControlType::privacy_status: {
|
||||
const auto& status = object.at("privacy_status");
|
||||
const int state = status.at("state").get<int>();
|
||||
const auto reason = status.at("reason").get<std::string>();
|
||||
if (state < 0 || state > 5 || reason.size() >= sizeof(output.ps.reason) ||
|
||||
reason.find('\0') != std::string::npos) return false;
|
||||
output.ps.state = static_cast<PrivacyState>(state);
|
||||
output.ps.supported = status.at("supported").get<bool>();
|
||||
output.ps.input_block_supported = status.at("input_block_supported").get<bool>();
|
||||
output.ps.overlay_active = status.at("overlay_active").get<bool>();
|
||||
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>();
|
||||
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 &&
|
||||
(!output.ps.supported || !output.ps.overlay_active || output.ps.remote_paused)) return false;
|
||||
break;
|
||||
}
|
||||
case ControlType::host_infomation: {
|
||||
ResetHostInfo(output.i);
|
||||
owns_host_info = true;
|
||||
|
||||
Reference in New Issue
Block a user